Coverage for src / mesh / models / review_models.py: 88%

103 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-03-10 09:11 +0000

1from __future__ import annotations 

2 

3import os 

4import re 

5from datetime import date 

6from enum import Enum, unique 

7from typing import TYPE_CHECKING, Self 

8 

9from django.conf import settings 

10from django.db import models 

11from django.utils import timezone 

12from django.utils.translation import gettext_lazy as _ 

13 

14from mesh.model.exceptions import ReviewStateError 

15 

16from .base_models import BaseSubmittableModel 

17from .file_models import BaseFileWrapperModel, BaseModelWithFiles 

18from .submission_models import SubmissionLog, SubmissionVersion 

19 

20if TYPE_CHECKING: 

21 from mesh.model.roles.role_handler import RoleHandler 

22 

23 

24@unique 

25class ReviewState(Enum): 

26 """ 

27 Enum of the review states. 

28 """ 

29 

30 # AWAITING ACCEPTANCE Reviewer invited, waiting for reviewer to accept/decline 

31 # the review request. 

32 AWAITING_ACCEPTANCE = "awaiting_accept" 

33 # DECLINED Reviewer invited & declined the request. 

34 DECLINED = "declined" 

35 # PENDING Reviewer invited & accepted the request, waiting for 

36 # the review. 

37 PENDING = "pending" 

38 # SUBMITTED Reviewer invited, accepted the request and the review 

39 # was submitted. 

40 SUBMITTED = "submitted" 

41 

42 

43REVIEW_STATE_CHOICES = [ 

44 (ReviewState.AWAITING_ACCEPTANCE.value, _("Waiting for acceptance")), 

45 (ReviewState.DECLINED.value, _("Declined")), 

46 (ReviewState.PENDING.value, _("Pending")), 

47 (ReviewState.SUBMITTED.value, _("Submitted")), 

48] 

49 

50 

51@unique 

52class RecommendationValue(Enum): 

53 """ 

54 Enum of the recommendation values. 

55 Warning: the value of each state is used in CSS. 

56 """ 

57 

58 ACCEPTED = "accepted" 

59 REJECTED = "rejected" 

60 REVISION_REQUESTED = "rev_requested" 

61 RESUBMIT_SOMEWHERE_ELSE = "resubmit" 

62 

63 

64RECOMMENDATION_CHOICES = [ 

65 (RecommendationValue.ACCEPTED.value, "Accept submission"), 

66 (RecommendationValue.REJECTED.value, "Decline submission"), 

67 (RecommendationValue.REVISION_REQUESTED.value, "Revision(s) required"), 

68 (RecommendationValue.RESUBMIT_SOMEWHERE_ELSE.value, "Re-submit somewhere else"), 

69] 

70 

71 

72class ReviewManager(models.Manager["Review"]): 

73 def get_queryset(self): 

74 return ( 

75 super() 

76 .get_queryset() 

77 .select_related( 

78 "created_by", 

79 "last_modified_by", 

80 "version", 

81 "version__submission", 

82 "version__submission__journal_section", 

83 "reviewer", 

84 ) 

85 .prefetch_related("additional_files") 

86 ) 

87 

88 

89class Review(BaseSubmittableModel, BaseModelWithFiles): 

90 file_fields_required = ["additional_files"] 

91 file_fields_deletable = ["additional_files"] 

92 

93 version = models.ForeignKey( 

94 SubmissionVersion, 

95 on_delete=models.CASCADE, 

96 related_name="reviews", 

97 help_text=_("Automatically filled on save"), 

98 ) 

99 state = models.CharField( 

100 max_length=64, 

101 choices=REVIEW_STATE_CHOICES, 

102 default=ReviewState.AWAITING_ACCEPTANCE.value, 

103 ) 

104 reviewer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) 

105 date_response_due = models.DateField(verbose_name=_("Response due date")) 

106 date_review_due = models.DateField(verbose_name=_("Review due date")) 

107 recommendation = models.CharField( 

108 max_length=64, 

109 choices=RECOMMENDATION_CHOICES, 

110 null=True, 

111 default=None, 

112 ) 

113 accepted = models.BooleanField( 

114 verbose_name=_("Confirmation"), 

115 choices=[(True, _("Accept")), (False, _("Decline"))], 

116 null=True, 

117 blank=False, 

118 ) 

119 date_accepted = models.DateTimeField(editable=False, null=True) 

120 # Optional message for the reviewer to be filled by the editor 

121 # when making a referee request. 

122 request_email = models.TextField(verbose_name=_("Request e-mail"), null=True) 

123 # Optional message from the reviewer to the editor when accepting/declining the 

124 # referee request 

125 accept_comment = models.TextField(verbose_name=_("Accept comment"), null=True) 

126 # Comment/message from the reviewer to the editor when filling the review form. 

127 comment = models.TextField(verbose_name=_("Review comment"), null=True) 

128 

129 # Quick review/opinion : boolean set at creation time. It will basically only change the email sent 

130 quick = models.BooleanField(null=False, default=False) 

131 

132 objects: ReviewManager[Self] = ReviewManager() 

133 

134 class Meta: 

135 constraints = [ 

136 models.UniqueConstraint( 

137 fields=["version", "reviewer"], name="unique_reviewer_per_round" 

138 ) 

139 ] 

140 

141 def __str__(self) -> str: 

142 return f"Review: {self.reviewer} - {self.version}" 

143 

144 @property 

145 def is_response_overdue(self) -> bool: 

146 return self.accepted is None and self.date_response_due < date.today() 

147 

148 @property 

149 def is_report_overdue(self) -> bool: 

150 return self.is_response_overdue or ( 

151 self.accepted is True 

152 and self.submitted is False 

153 and self.date_review_due < date.today() 

154 ) 

155 

156 @property 

157 def is_completed(self) -> bool: 

158 return self.accepted is False or self.submitted is True 

159 

160 @property 

161 def is_editable(self) -> bool: 

162 return not self.submitted and self.version.review_open 

163 

164 def accept( 

165 self, 

166 accept_value: bool, 

167 accept_comment: str | None, 

168 user, 

169 impersonate_data=None, 

170 ) -> None: 

171 if not self.is_editable: 

172 raise ReviewStateError("Trying to accept a non-editable review.") 

173 

174 self._user = user 

175 self.accepted = accept_value 

176 self.state = ReviewState.PENDING.value if accept_value else ReviewState.DECLINED.value 

177 self.date_accepted = timezone.now() 

178 self.accept_comment = accept_comment 

179 

180 self.save() 

181 

182 base_message = ( 

183 "Referee request accepted by" if accept_value else "Referee request declined by" 

184 ) 

185 

186 SubmissionLog.add_message( 

187 self.version.submission, 

188 content=_(base_message) + f" {self.reviewer}", 

189 content_en=f"{base_message} {self.reviewer}", 

190 user=user, 

191 # impersonate_data=impersonate_data, 

192 significant=True, 

193 ) 

194 

195 @property 

196 def is_submittable(self) -> bool: 

197 return not self.is_completed and self.is_editable 

198 

199 def submit(self, user) -> None: 

200 if not self.is_submittable: 

201 raise ReviewStateError("Trying to submit a non-editable review.") 

202 

203 self._user = user 

204 self.state = ReviewState.SUBMITTED.value 

205 self.submitted = True 

206 

207 self.save() 

208 

209 SubmissionLog.add_message( 

210 self.version.submission, 

211 content=_(f"Review for round #{self.version.number} submitted by") 

212 + f" {self.reviewer}", 

213 content_en=f"Review for round #{self.version.number} submitted by {self.reviewer}", 

214 user=user, 

215 # impersonate_data=impersonate_data, 

216 significant=True, 

217 ) 

218 

219 

220class ReviewAdditionalFile(BaseFileWrapperModel[Review]): 

221 attached_to = models.ForeignKey( 

222 Review, on_delete=models.CASCADE, related_name="additional_files" 

223 ) 

224 author_access = models.BooleanField(default=False) 

225 

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

227 return os.path.join( 

228 "submissions", 

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

230 "versions", 

231 str(self.attached_to.version.number), 

232 "review", 

233 str(self.attached_to.pk), 

234 filename, 

235 ) 

236 

237 @classmethod 

238 def reverse_file_path(cls, file_path: str) -> ReviewAdditionalFile | None: 

239 """ 

240 Check first with a regex if the given path might match. This is not necessary 

241 but it saves us a DB query in case in doesn't match the path structure. 

242 """ 

243 regex = "/".join( 

244 [ 

245 "submissions", 

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

247 "versions", 

248 r"(?P<version_number>[0-9]+)", 

249 "review", 

250 r"(?P<review_pk>[0-9]+)", 

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

252 ] 

253 ) 

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

255 if not match: 

256 return None 

257 

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

259 

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

261 """ 

262 This model is only editable by user role with edit rights on the submission. 

263 """ 

264 if right_code == "read": 

265 return role_handler.check_global_rights("can_access_review", self.attached_to) 

266 

267 elif right_code in ["delete"]: 

268 return role_handler.check_rights("can_edit_review", self.attached_to) 

269 

270 return False