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

107 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 datetime import date 

6from enum import Enum, unique 

7from typing import TYPE_CHECKING, Self, TypeVar 

8 

9from django.conf import settings 

10from django.db import models 

11from django.db.models.query import QuerySet 

12from django.utils import timezone 

13from django.utils.translation import gettext_lazy as _ 

14 

15from mesh.model.exceptions import ReviewStateError 

16 

17from .base_models import BaseSubmittableModel 

18from .file_models import BaseFileWrapperModel, BaseModelWithFiles 

19from .submission_models import SubmissionLog, SubmissionVersion 

20 

21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true

22 from mesh.model.roles.role_handler import RoleHandler 

23 

24 

25_T = TypeVar("_T", bound=models.Model) 

26 

27 

28@unique 

29class ReviewState(Enum): 

30 """ 

31 Enum of the review states. 

32 """ 

33 

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

35 # the review request. 

36 AWAITING_ACCEPTANCE = "awaiting_accept" 

37 # DECLINED Reviewer invited & declined the request. 

38 DECLINED = "declined" 

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

40 # the review. 

41 PENDING = "pending" 

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

43 # was submitted. 

44 SUBMITTED = "submitted" 

45 

46 

47REVIEW_STATE_CHOICES = [ 

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

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

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

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

52] 

53 

54 

55@unique 

56class RecommendationValue(Enum): 

57 """ 

58 Enum of the recommendation values. 

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

60 """ 

61 

62 ACCEPTED = "accepted" 

63 REJECTED = "rejected" 

64 REVISION_REQUESTED = "rev_requested" 

65 RESUBMIT_SOMEWHERE_ELSE = "resubmit" 

66 

67 

68RECOMMENDATION_CHOICES = [ 

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

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

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

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

73] 

74 

75 

76class ReviewManager(models.Manager[_T]): 

77 def get_queryset(self) -> QuerySet[_T]: 

78 return ( 

79 super() 

80 .get_queryset() 

81 .select_related( 

82 "created_by", 

83 "last_modified_by", 

84 "version", 

85 "version__submission", 

86 "version__submission__journal_section", 

87 "reviewer", 

88 ) 

89 .prefetch_related("additional_files") 

90 ) 

91 

92 

93class Review(BaseSubmittableModel, BaseModelWithFiles): 

94 file_fields_required = ["additional_files"] 

95 file_fields_deletable = ["additional_files"] 

96 

97 version = models.ForeignKey( 

98 SubmissionVersion, 

99 on_delete=models.CASCADE, 

100 related_name="reviews", 

101 blank=True, 

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

103 ) 

104 state = models.CharField( 

105 max_length=64, 

106 choices=REVIEW_STATE_CHOICES, 

107 default=ReviewState.AWAITING_ACCEPTANCE.value, 

108 ) 

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

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

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

112 recommendation = models.CharField( 

113 max_length=64, 

114 choices=RECOMMENDATION_CHOICES, 

115 blank=True, 

116 null=True, 

117 default=None, 

118 ) 

119 accepted = models.BooleanField( 

120 verbose_name=_("Confirmation"), 

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

122 null=True, 

123 blank=False, 

124 ) 

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

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

127 # when making a referee request. 

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

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

130 # referee request 

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

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

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

134 

135 objects: ReviewManager[Self] = ReviewManager() 

136 

137 class Meta: 

138 constraints = [ 

139 models.UniqueConstraint( 

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

141 ) 

142 ] 

143 

144 def __str__(self) -> str: 

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

146 

147 @property 

148 def is_response_overdue(self) -> bool: 

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

150 

151 @property 

152 def is_report_overdue(self) -> bool: 

153 return self.is_response_overdue or ( 

154 self.accepted is True 

155 and self.submitted is False 

156 and self.date_review_due < date.today() 

157 ) 

158 

159 @property 

160 def is_completed(self) -> bool: 

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

162 

163 @property 

164 def is_editable(self) -> bool: 

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

166 

167 def accept( 

168 self, 

169 accept_value: bool, 

170 accept_comment: str | None, 

171 user, 

172 impersonate_data=None, 

173 ) -> None: 

174 if not self.is_editable: 

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

176 

177 self._user = user # type:ignore 

178 self.accepted = accept_value 

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

180 self.date_accepted = timezone.now() 

181 self.accept_comment = accept_comment 

182 

183 self.save() 

184 

185 base_message = ( 

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

187 ) 

188 

189 SubmissionLog.add_message( 

190 self.version.submission, 

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

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

193 user=user, 

194 # impersonate_data=impersonate_data, 

195 significant=True, 

196 ) 

197 

198 @property 

199 def is_submittable(self) -> bool: 

200 return not self.is_completed and self.is_editable 

201 

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

203 if not self.is_submittable: 

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

205 

206 self._user = user # type:ignore 

207 self.state = ReviewState.SUBMITTED.value 

208 self.submitted = True 

209 

210 self.save() 

211 

212 SubmissionLog.add_message( 

213 self.version.submission, 

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

215 + f" {self.reviewer}", 

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

217 user=user, 

218 # impersonate_data=impersonate_data, 

219 significant=True, 

220 ) 

221 

222 

223class ReviewAdditionalFile(BaseFileWrapperModel): 

224 attached_to: models.ForeignKey[Review] = models.ForeignKey( 

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

226 ) 

227 author_access = models.BooleanField(default=False) 

228 

229 @staticmethod 

230 def get_upload_path(instance: ReviewAdditionalFile, filename: str) -> str: 

231 return os.path.join( 

232 "submissions", 

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

234 "versions", 

235 str(instance.attached_to.version.number), 

236 "review", 

237 str(instance.attached_to.pk), 

238 filename, 

239 ) 

240 

241 @classmethod 

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

243 """ 

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

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

246 """ 

247 regex = "/".join( 

248 [ 

249 "submissions", 

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

251 "versions", 

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

253 "review", 

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

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

256 ] 

257 ) 

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

259 if not match: 

260 return None 

261 

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

263 

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

265 """ 

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

267 """ 

268 if right_code == "read": 

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

270 

271 elif right_code in ["delete"]: 

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

273 

274 return False