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
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-10 09:11 +0000
1from __future__ import annotations
3import os
4import re
5from datetime import date
6from enum import Enum, unique
7from typing import TYPE_CHECKING, Self
9from django.conf import settings
10from django.db import models
11from django.utils import timezone
12from django.utils.translation import gettext_lazy as _
14from mesh.model.exceptions import ReviewStateError
16from .base_models import BaseSubmittableModel
17from .file_models import BaseFileWrapperModel, BaseModelWithFiles
18from .submission_models import SubmissionLog, SubmissionVersion
20if TYPE_CHECKING:
21 from mesh.model.roles.role_handler import RoleHandler
24@unique
25class ReviewState(Enum):
26 """
27 Enum of the review states.
28 """
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"
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]
51@unique
52class RecommendationValue(Enum):
53 """
54 Enum of the recommendation values.
55 Warning: the value of each state is used in CSS.
56 """
58 ACCEPTED = "accepted"
59 REJECTED = "rejected"
60 REVISION_REQUESTED = "rev_requested"
61 RESUBMIT_SOMEWHERE_ELSE = "resubmit"
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]
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 )
89class Review(BaseSubmittableModel, BaseModelWithFiles):
90 file_fields_required = ["additional_files"]
91 file_fields_deletable = ["additional_files"]
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)
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)
132 objects: ReviewManager[Self] = ReviewManager()
134 class Meta:
135 constraints = [
136 models.UniqueConstraint(
137 fields=["version", "reviewer"], name="unique_reviewer_per_round"
138 )
139 ]
141 def __str__(self) -> str:
142 return f"Review: {self.reviewer} - {self.version}"
144 @property
145 def is_response_overdue(self) -> bool:
146 return self.accepted is None and self.date_response_due < date.today()
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 )
156 @property
157 def is_completed(self) -> bool:
158 return self.accepted is False or self.submitted is True
160 @property
161 def is_editable(self) -> bool:
162 return not self.submitted and self.version.review_open
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.")
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
180 self.save()
182 base_message = (
183 "Referee request accepted by" if accept_value else "Referee request declined by"
184 )
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 )
195 @property
196 def is_submittable(self) -> bool:
197 return not self.is_completed and self.is_editable
199 def submit(self, user) -> None:
200 if not self.is_submittable:
201 raise ReviewStateError("Trying to submit a non-editable review.")
203 self._user = user
204 self.state = ReviewState.SUBMITTED.value
205 self.submitted = True
207 self.save()
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 )
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)
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 )
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
258 return cls._default_manager.filter(file=file_path).first()
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)
267 elif right_code in ["delete"]:
268 return role_handler.check_rights("can_edit_review", self.attached_to)
270 return False