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

60 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-04-28 07:45 +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: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true

24 from mesh.model.roles.role_handler import RoleHandler 

25 

26 

27class EditorSectionRight(BaseChangeTrackingModel): 

28 """ 

29 ManyToMany table between User & JournalSection. 

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

31 """ 

32 

33 user = models.ForeignKey( 

34 settings.AUTH_USER_MODEL, 

35 on_delete=models.CASCADE, 

36 related_name="editor_sections", 

37 ) 

38 journal_section = models.ForeignKey( 

39 JournalSection, 

40 verbose_name=_("Journal Section"), 

41 on_delete=models.CASCADE, 

42 related_name="editors", 

43 ) 

44 

45 

46class EditorSubmissionRight(BaseModelWithFiles): 

47 """ 

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

49 "assigned" to a submission. 

50 """ 

51 

52 user = models.ForeignKey( 

53 settings.AUTH_USER_MODEL, 

54 on_delete=models.CASCADE, 

55 related_name="editor_submissions", 

56 ) 

57 submission = models.ForeignKey( 

58 Submission, 

59 verbose_name=_("Submission"), 

60 on_delete=models.CASCADE, 

61 related_name="editors", 

62 ) 

63 

64 class Meta: 

65 constraints = [ 

66 models.UniqueConstraint( 

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

68 ) 

69 ] 

70 

71 

72class EditorialDecision(BaseModelWithFiles): 

73 """ 

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

75 above rights a priori. 

76 """ 

77 

78 file_fields_deletable = ["additional_files"] 

79 version = models.OneToOneField( 

80 SubmissionVersion, 

81 on_delete=models.CASCADE, 

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

83 related_name="editorial_decision", 

84 ) 

85 value = models.CharField( 

86 verbose_name=_("Decision"), 

87 max_length=64, 

88 choices=SUBMISSION_STATE_EDITOR_CHOICES, 

89 null=False, 

90 blank=False, 

91 ) 

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

93 

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

95 """ 

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

97 is empty. 

98 """ 

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

100 return False, FILE_DELETE_DEFAULT_ERROR 

101 

102 if ( 

103 file_field == "additional_files" 

104 and not self.comment 

105 and len(self.additional_files.all()) < 2 # type:ignore 

106 ): 

107 return False, _( 

108 "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." 

109 ) 

110 

111 return True, "" 

112 

113 def get_decision_display(self): 

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

115 return _("Accepted") 

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

117 return _("Rejected") 

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

119 return _("Revision requested") 

120 else: 

121 return "Uknown" 

122 

123 

124class EditorialDecisionFile(BaseFileWrapperModel): 

125 file_extensions = [".pdf"] 

126 attached_to: models.ForeignKey[EditorialDecision] = models.ForeignKey( 

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

128 ) 

129 

130 @staticmethod 

131 def get_upload_path(instance: EditorialDecisionFile, filename: str) -> str: 

132 return os.path.join( 

133 "submissions", 

134 str(instance.attached_to.version.submission.pk), 

135 "editor_decisions", 

136 str(instance.attached_to.pk), 

137 filename, 

138 ) 

139 

140 @classmethod 

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

142 regex = "/".join( 

143 [ 

144 "submissions", 

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

146 "editor_decisions", 

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

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

149 ] 

150 ) 

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

152 if not match: 

153 return None 

154 

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

156 

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

158 if right_code == "read": 

159 return role_handler.check_global_rights( 

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

161 ) 

162 

163 elif right_code in ["delete"]: 

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

165 

166 return False