Coverage for src / mesh / models / editorial_models.py: 61%

59 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-23 15:44 +0000

1from __future__ import annotations 

2 

3import os 

4import re 

5from typing import TYPE_CHECKING 

6 

7from django.conf import settings 

8from django.db import models 

9from django.utils.translation import gettext_lazy as _ 

10 

11from mesh.model.file_helpers import FILE_DELETE_DEFAULT_ERROR 

12 

13from .base_models import BaseChangeTrackingModel 

14from .file_models import BaseFileWrapperModel, BaseModelWithFiles 

15from .journal_models import JournalSection 

16from .submission_models import ( 

17 SUBMISSION_STATE_EDITOR_CHOICES, 

18 Submission, 

19 SubmissionState, 

20 SubmissionVersion, 

21) 

22 

23if TYPE_CHECKING: 

24 from typing import Callable 

25 

26 from mesh.model.roles.role_handler import RoleHandler 

27 

28 

29class EditorSectionRight(BaseChangeTrackingModel): 

30 """ 

31 ManyToMany table between User & JournalSection. 

32 Used to track Editor access/right to a submission journal_section (section). 

33 """ 

34 

35 user = models.ForeignKey( 

36 settings.AUTH_USER_MODEL, 

37 on_delete=models.CASCADE, 

38 related_name="editor_sections", 

39 ) 

40 journal_section = models.ForeignKey( 

41 JournalSection, 

42 verbose_name=_("Journal Section"), 

43 on_delete=models.CASCADE, 

44 related_name="editors", 

45 ) 

46 

47 

48class EditorSubmissionRight(BaseModelWithFiles): 

49 """ 

50 ManyToMany table representing an editor right to a submission, ie. an editor 

51 "assigned" to a submission. 

52 """ 

53 

54 user = models.ForeignKey( 

55 settings.AUTH_USER_MODEL, 

56 on_delete=models.CASCADE, 

57 related_name="editor_submissions", 

58 ) 

59 submission = models.ForeignKey( 

60 Submission, 

61 verbose_name=_("Submission"), 

62 on_delete=models.CASCADE, 

63 related_name="editors", 

64 ) 

65 

66 class Meta: 

67 constraints = [ 

68 models.UniqueConstraint( 

69 fields=["user", "submission"], name="unique_submission_per_editor" 

70 ) 

71 ] 

72 

73 

74class EditorialDecision(BaseModelWithFiles): 

75 """ 

76 Model representing an editorial decision. Edit restricted to editors and 

77 above rights a priori. 

78 """ 

79 

80 file_fields_deletable = ["additional_files"] 

81 version = models.OneToOneField( 

82 SubmissionVersion, 

83 on_delete=models.CASCADE, 

84 editable=False, # the field will not be displayed in the admin or any other ModelForm 

85 related_name="editorial_decision", 

86 ) 

87 value = models.CharField( 

88 verbose_name=_("Decision"), 

89 max_length=64, 

90 choices=SUBMISSION_STATE_EDITOR_CHOICES, 

91 null=False, 

92 blank=False, 

93 ) 

94 comment = models.TextField(verbose_name=_("Description"), null=True) 

95 

96 get_value_display: "Callable[[], str]" 

97 "Generated by django https://docs.djangoproject.com/en/4.2/ref/models/instances/#django.db.models.Model.get_FOO_display" 

98 

99 def can_delete_file(self, file_field: str) -> tuple[bool, str]: 

100 """ 

101 An additional file cannot be deleted if there's only one and the comment 

102 is empty. 

103 """ 

104 if not super().can_delete_file(file_field): 

105 return False, FILE_DELETE_DEFAULT_ERROR 

106 

107 if ( 

108 file_field == "additional_files" 

109 and not self.comment 

110 and len(self.additional_files.all()) < 2 

111 ): 

112 return False, _( 

113 "The decision requires either a file or a description. If you want to change the file, add the new one then delete the old one." 

114 ) 

115 

116 return True, "" 

117 

118 def get_decision_display(self): 

119 if self.value == SubmissionState.ACCEPTED.value: 

120 return _("Accepted") 

121 elif self.value == SubmissionState.REJECTED.value: 

122 return _("Rejected") 

123 elif self.value == SubmissionState.REVISION_REQUESTED.value: 

124 return _("Revision requested") 

125 else: 

126 return "Uknown" 

127 

128 

129class EditorialDecisionFile(BaseFileWrapperModel[EditorialDecision]): 

130 file_extensions = [".pdf"] 

131 attached_to = models.ForeignKey( 

132 EditorialDecision, on_delete=models.CASCADE, related_name="additional_files" 

133 ) 

134 

135 def get_upload_path(self, filename: str) -> str: 

136 return os.path.join( 

137 "submissions", 

138 str(self.attached_to.version.submission.pk), 

139 "editor_decisions", 

140 str(self.attached_to.pk), 

141 filename, 

142 ) 

143 

144 @classmethod 

145 def reverse_file_path(cls, file_path: str) -> EditorialDecisionFile | None: 

146 regex = "/".join( 

147 [ 

148 "submissions", 

149 r"(?P<submission_pk>[0-9]+)", 

150 "editor_decisions", 

151 r"(?P<decision_pk>[0-9]+)", 

152 r"(?P<file_name>[^\/]+)$", 

153 ] 

154 ) 

155 match = re.match(re.compile(regex), file_path) 

156 if not match: 

157 return None 

158 

159 return cls._default_manager.filter(file=file_path).first() 

160 

161 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool: 

162 if right_code == "read": 

163 return role_handler.check_global_rights( 

164 "can_access_submission", self.attached_to.version.submission 

165 ) 

166 

167 elif right_code in ["delete"]: 

168 return role_handler.check_rights("can_edit_editorial_decision", self.attached_to) 

169 

170 return False