Coverage for src / mesh / models / orm / editorial_models.py: 70%

60 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-05-04 12:41 +0000

1from __future__ import annotations 

2 

3import os 

4from typing import TYPE_CHECKING 

5 

6from django.db import models 

7from django.urls import reverse 

8from django.utils.translation import gettext_lazy as _ 

9 

10from mesh.models.file_helpers import FILE_DELETE_DEFAULT_ERROR 

11from mesh.models.orm.base_models import BaseChangeTrackingModel 

12from mesh.models.orm.file_models import BaseFileWrapperModel, BaseModelWithFiles 

13from mesh.models.orm.journal_models import JournalSection 

14from mesh.models.orm.submission_models import ( 

15 SUBMISSION_STATE_EDITOR_CHOICES, 

16 Submission, 

17 SubmissionState, 

18 SubmissionVersion, 

19) 

20from mesh.models.orm.user_models import User 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Callable 

24 

25 from django.db.models.manager import RelatedManager 

26 

27 

28class EditorSectionRight(BaseChangeTrackingModel): 

29 """ 

30 ManyToMany table between User & JournalSection. 

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

32 """ 

33 

34 user = models.ForeignKey( 

35 User, 

36 on_delete=models.CASCADE, 

37 related_name="editor_sections", 

38 ) 

39 journal_section = models.ForeignKey( 

40 JournalSection, 

41 verbose_name=_("Journal Section"), 

42 on_delete=models.CASCADE, 

43 related_name="editors", 

44 ) 

45 

46 

47class EditorSubmissionRight(BaseModelWithFiles): 

48 """ 

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

50 "assigned" to a submission. 

51 """ 

52 

53 user = models.ForeignKey( 

54 User, 

55 on_delete=models.CASCADE, 

56 related_name="editor_submissions", 

57 ) 

58 submission = models.ForeignKey( 

59 Submission, 

60 verbose_name=_("Submission"), 

61 on_delete=models.CASCADE, 

62 related_name="editors", 

63 ) 

64 

65 class Meta: 

66 constraints = [ 

67 models.UniqueConstraint( 

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

69 ) 

70 ] 

71 

72 

73class EditorialDecision(BaseModelWithFiles): 

74 """ 

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

76 above rights a priori. 

77 """ 

78 

79 file_fields_deletable = ["additional_files"] 

80 version = models.OneToOneField( 

81 SubmissionVersion, 

82 on_delete=models.CASCADE, 

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

84 related_name="editorial_decision", 

85 ) 

86 value = models.CharField( 

87 verbose_name=_("Decision"), 

88 max_length=64, 

89 choices=SUBMISSION_STATE_EDITOR_CHOICES, 

90 null=False, 

91 blank=False, 

92 ) 

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

94 

95 get_value_display: Callable[[], str] 

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

97 

98 additional_files: RelatedManager[EditorialDecisionFile] 

99 

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

101 """ 

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

103 is empty. 

104 """ 

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

106 return False, FILE_DELETE_DEFAULT_ERROR 

107 

108 if ( 

109 file_field == "additional_files" 

110 and not self.comment 

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

112 ): 

113 return False, _( 

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

115 ) 

116 

117 return True, "" 

118 

119 def get_decision_display(self): 

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

121 return _("Accepted") 

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

123 return _("Rejected") 

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

125 return _("Revision requested") 

126 else: 

127 return "Uknown" 

128 

129 

130class EditorialDecisionFile(BaseFileWrapperModel[EditorialDecision]): 

131 file_extensions = [".pdf"] 

132 attached_to = models.ForeignKey( 

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

134 ) 

135 

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

137 return os.path.join( 

138 "submissions", 

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

140 "editor_decisions", 

141 str(self.attached_to.pk), 

142 filename, 

143 ) 

144 

145 def check_access_right(self, role, right_code: str) -> bool: 

146 if right_code == "read": 

147 return role.can_access_submission(self.attached_to.version.submission) 

148 

149 elif right_code in ["delete"]: 

150 return role.can_edit_editorial_decision(self.attached_to) 

151 

152 return False 

153 

154 def get_absolute_url(self) -> str: 

155 """ 

156 Returns the URL to the model's file. 

157 """ 

158 if not self.file.url: 

159 return "" 

160 file_identifier = self.get_file_identifier() 

161 version_pk = self.attached_to.version.pk 

162 submission_pk = self.attached_to.version.submission.pk 

163 return reverse( 

164 "mesh:serve_decision_file", 

165 kwargs={ 

166 "file_identifier": file_identifier, 

167 "version_pk": version_pk, 

168 "submission_pk": submission_pk, 

169 }, 

170 )