Coverage for src / mesh / models / orm / review_models.py: 86%

117 statements  

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

1from __future__ import annotations 

2 

3import logging 

4import os 

5from datetime import date 

6from enum import Enum, unique 

7from typing import TYPE_CHECKING 

8 

9from django.db import models 

10from django.urls import reverse 

11from django.utils import timezone 

12from django.utils.translation import gettext_lazy as _ 

13 

14from mesh.models.exceptions import ReviewStateError 

15from mesh.models.orm.base_models import BaseSubmittableModel 

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

17from mesh.models.orm.submission_models import SubmissionLog, SubmissionVersion 

18from mesh.models.orm.user_models import User 

19 

20if TYPE_CHECKING: 

21 from django.db.models.manager import RelatedManager 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26@unique 

27class ReviewState(Enum): 

28 """ 

29 Enum of the review states. 

30 """ 

31 

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

33 # the review request. 

34 AWAITING_ACCEPTANCE = "awaiting_accept" 

35 # DECLINED Reviewer invited & declined the request. 

36 DECLINED = "declined" 

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

38 # the review. 

39 PENDING = "pending" 

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

41 # was submitted. 

42 SUBMITTED = "submitted" 

43 

44 

45REVIEW_STATE_CHOICES = [ 

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

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

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

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

50] 

51 

52 

53@unique 

54class RecommendationValue(Enum): 

55 """ 

56 Enum of the recommendation values. 

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

58 """ 

59 

60 ACCEPTED = "accepted" 

61 REJECTED = "rejected" 

62 REVISION_REQUESTED = "rev_requested" 

63 RESUBMIT_SOMEWHERE_ELSE = "resubmit" 

64 

65 

66RECOMMENDATION_CHOICES = [ 

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

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

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

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

71] 

72 

73 

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

75 def get_queryset(self): 

76 return ( 

77 super() 

78 .get_queryset() 

79 .select_related( 

80 "created_by", 

81 "last_modified_by", 

82 "version", 

83 "version__submission", 

84 "version__submission__journal_section", 

85 "reviewer", 

86 ) 

87 .prefetch_related("additional_files") 

88 ) 

89 

90 

91class Review(BaseSubmittableModel, BaseModelWithFiles): 

92 file_fields_required = ["additional_files"] 

93 file_fields_deletable = ["additional_files"] 

94 

95 version = models.ForeignKey( 

96 SubmissionVersion, 

97 on_delete=models.CASCADE, 

98 related_name="reviews", 

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

100 ) 

101 state = models.CharField( 

102 max_length=64, 

103 choices=REVIEW_STATE_CHOICES, 

104 default=ReviewState.AWAITING_ACCEPTANCE.value, 

105 ) 

106 reviewer = models.ForeignKey(User, on_delete=models.PROTECT) 

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

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

109 recommendation = models.CharField( 

110 max_length=64, 

111 choices=RECOMMENDATION_CHOICES, 

112 null=True, 

113 default=None, 

114 ) 

115 accepted = models.BooleanField( 

116 verbose_name=_("Confirmation"), 

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

118 null=True, 

119 blank=False, 

120 ) 

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

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

123 # when making a referee request. 

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

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

126 # referee request 

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

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

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

130 

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

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

133 

134 objects = ReviewManager() 

135 

136 # RelatedObjects 

137 additional_files: "RelatedManager[ReviewAdditionalFile]" 

138 # Annotated fields 

139 additional_files_censored: "list[ReviewAdditionalFile]" 

140 reviewer_censored: "str | User" = "" 

141 

142 class Meta: 

143 constraints = [ 

144 models.UniqueConstraint( 

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

146 ) 

147 ] 

148 

149 def __str__(self) -> str: 

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

151 

152 @property 

153 def is_response_overdue(self) -> bool: 

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

155 

156 @property 

157 def is_report_overdue(self) -> bool: 

158 return self.is_response_overdue or ( 

159 self.accepted is True 

160 and self.submitted is False 

161 and self.date_review_due < date.today() 

162 ) 

163 

164 @property 

165 def is_completed(self) -> bool: 

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

167 

168 def is_editable(self) -> bool: 

169 if self.submitted: 

170 logger.debug(f"Submission {self.pk} not editable : already submitted") 

171 return False 

172 if not self.version.review_open: 

173 logger.debug(f"Submission {self.pk} not editable : review is not open") 

174 return False 

175 return True 

176 

177 def accept( 

178 self, 

179 accept_value: bool, 

180 accept_comment: str | None, 

181 user, 

182 impersonate_data=None, 

183 date=None, 

184 ) -> None: 

185 if not self.is_editable(): 

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

187 

188 self._user = user 

189 self.accepted = accept_value 

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

191 self.date_accepted = date or timezone.now() 

192 self.accept_comment = accept_comment 

193 

194 if date is not None: 

195 self.do_not_update = True 

196 self.last_modified_by_id = user.pk 

197 self.date_last_modified = date 

198 self.save() 

199 

200 base_message = ( 

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

202 ) 

203 

204 SubmissionLog.add_message( 

205 self.version.submission, 

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

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

208 user=user, 

209 # impersonate_data=impersonate_data, 

210 significant=True, 

211 date=date, 

212 ) 

213 

214 def is_submittable(self) -> bool: 

215 return not self.is_completed and self.is_editable() 

216 

217 def submit(self, user, date=None) -> None: 

218 if not self.is_submittable(): 

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

220 

221 self._user = user 

222 self.state = ReviewState.SUBMITTED.value 

223 self.submitted = True 

224 

225 self.save() 

226 

227 SubmissionLog.add_message( 

228 self.version.submission, 

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

230 + f" {self.reviewer}", 

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

232 user=self.reviewer, 

233 # impersonate_data=impersonate_data, 

234 significant=True, 

235 date=date, 

236 ) 

237 

238 

239class ReviewAdditionalFile(BaseFileWrapperModel[Review]): 

240 attached_to = models.ForeignKey( 

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

242 ) 

243 author_access = models.BooleanField(default=False) 

244 

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

246 return os.path.join( 

247 "submissions", 

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

249 "versions", 

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

251 "review", 

252 str(self.attached_to.pk), 

253 filename, 

254 ) 

255 

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

257 """ 

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

259 """ 

260 if right_code == "read": 

261 return role.can_access_review(self.attached_to.version.submission, self.attached_to) 

262 

263 elif right_code in ["delete"]: 

264 return role.can_edit_review(self.attached_to) 

265 

266 return False 

267 

268 def get_absolute_url(self) -> str: 

269 """ 

270 Returns the URL to the model's file. 

271 """ 

272 if not self.file.url: 

273 return "" 

274 file_identifier = self.get_file_identifier() 

275 review_pk = self.attached_to.pk 

276 version_pk = self.attached_to.version.pk 

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

278 return reverse( 

279 "mesh:serve_review_file", 

280 kwargs={ 

281 "file_identifier": file_identifier, 

282 "review_pk": review_pk, 

283 "version_pk": version_pk, 

284 "submission_pk": submission_pk, 

285 }, 

286 )