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
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +0000
1from __future__ import annotations
3import logging
4import os
5from datetime import date
6from enum import Enum, unique
7from typing import TYPE_CHECKING
9from django.db import models
10from django.urls import reverse
11from django.utils import timezone
12from django.utils.translation import gettext_lazy as _
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
20if TYPE_CHECKING:
21 from django.db.models.manager import RelatedManager
23logger = logging.getLogger(__name__)
26@unique
27class ReviewState(Enum):
28 """
29 Enum of the review states.
30 """
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"
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]
53@unique
54class RecommendationValue(Enum):
55 """
56 Enum of the recommendation values.
57 Warning: the value of each state is used in CSS.
58 """
60 ACCEPTED = "accepted"
61 REJECTED = "rejected"
62 REVISION_REQUESTED = "rev_requested"
63 RESUBMIT_SOMEWHERE_ELSE = "resubmit"
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]
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 )
91class Review(BaseSubmittableModel, BaseModelWithFiles):
92 file_fields_required = ["additional_files"]
93 file_fields_deletable = ["additional_files"]
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)
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)
134 objects = ReviewManager()
136 # RelatedObjects
137 additional_files: "RelatedManager[ReviewAdditionalFile]"
138 # Annotated fields
139 additional_files_censored: "list[ReviewAdditionalFile]"
140 reviewer_censored: "str | User" = ""
142 class Meta:
143 constraints = [
144 models.UniqueConstraint(
145 fields=["version", "reviewer"], name="unique_reviewer_per_round"
146 )
147 ]
149 def __str__(self) -> str:
150 return f"Review: {self.reviewer} - {self.version}"
152 @property
153 def is_response_overdue(self) -> bool:
154 return self.accepted is None and self.date_response_due < date.today()
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 )
164 @property
165 def is_completed(self) -> bool:
166 return self.accepted is False or self.submitted is True
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
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.")
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
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()
200 base_message = (
201 "Referee request accepted by" if accept_value else "Referee request declined by"
202 )
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 )
214 def is_submittable(self) -> bool:
215 return not self.is_completed and self.is_editable()
217 def submit(self, user, date=None) -> None:
218 if not self.is_submittable():
219 raise ReviewStateError("Trying to submit a non-editable review.")
221 self._user = user
222 self.state = ReviewState.SUBMITTED.value
223 self.submitted = True
225 self.save()
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 )
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)
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 )
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)
263 elif right_code in ["delete"]:
264 return role.can_edit_review(self.attached_to)
266 return False
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 )