Coverage for src/mesh/models/review_models.py: 83%
108 statements
« prev ^ index » next coverage.py v7.9.0, created at 2025-09-10 11:20 +0000
« prev ^ index » next coverage.py v7.9.0, created at 2025-09-10 11:20 +0000
1from __future__ import annotations
3import os
4import re
5from datetime import date
6from enum import Enum, unique
7from typing import TYPE_CHECKING, Self, TypeVar
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 _
15from mesh.model.exceptions import ReviewStateError
17from .base_models import BaseSubmittableModel
18from .file_models import BaseFileWrapperModel, BaseModelWithFiles
19from .submission_models import SubmissionLog, SubmissionVersion
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
25_T = TypeVar("_T", bound=models.Model)
28@unique
29class ReviewState(Enum):
30 """
31 Enum of the review states.
32 """
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"
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]
55@unique
56class RecommendationValue(Enum):
57 """
58 Enum of the recommendation values.
59 Warning: the value of each state is used in CSS.
60 """
62 ACCEPTED = "accepted"
63 REJECTED = "rejected"
64 REVISION_REQUESTED = "rev_requested"
65 RESUBMIT_SOMEWHERE_ELSE = "resubmit"
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]
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 )
93class Review(BaseSubmittableModel, BaseModelWithFiles):
94 file_fields_required = ["additional_files"]
95 file_fields_deletable = ["additional_files"]
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)
135 # Quick review/opinion : boolean set at creation time. It will basically only change the email sent
136 quick = models.BooleanField(null=False, default=False)
138 objects: ReviewManager[Self] = ReviewManager()
140 class Meta:
141 constraints = [
142 models.UniqueConstraint(
143 fields=["version", "reviewer"], name="unique_reviewer_per_round"
144 )
145 ]
147 def __str__(self) -> str:
148 return f"Review: {self.reviewer} - {self.version}"
150 @property
151 def is_response_overdue(self) -> bool:
152 return self.accepted is None and self.date_response_due < date.today()
154 @property
155 def is_report_overdue(self) -> bool:
156 return self.is_response_overdue or (
157 self.accepted is True
158 and self.submitted is False
159 and self.date_review_due < date.today()
160 )
162 @property
163 def is_completed(self) -> bool:
164 return self.accepted is False or self.submitted is True
166 @property
167 def is_editable(self) -> bool:
168 return not self.submitted and self.version.review_open
170 def accept(
171 self,
172 accept_value: bool,
173 accept_comment: str | None,
174 user,
175 impersonate_data=None,
176 ) -> None:
177 if not self.is_editable:
178 raise ReviewStateError("Trying to accept a non-editable review.")
180 self._user = user # type:ignore
181 self.accepted = accept_value
182 self.state = ReviewState.PENDING.value if accept_value else ReviewState.DECLINED.value
183 self.date_accepted = timezone.now()
184 self.accept_comment = accept_comment
186 self.save()
188 base_message = (
189 "Referee request accepted by" if accept_value else "Referee request declined by"
190 )
192 SubmissionLog.add_message(
193 self.version.submission,
194 content=_(base_message) + f" {self.reviewer}",
195 content_en=f"{base_message} {self.reviewer}",
196 user=user,
197 # impersonate_data=impersonate_data,
198 significant=True,
199 )
201 @property
202 def is_submittable(self) -> bool:
203 return not self.is_completed and self.is_editable
205 def submit(self, user) -> None:
206 if not self.is_submittable:
207 raise ReviewStateError("Trying to submit a non-editable review.")
209 self._user = user # type:ignore
210 self.state = ReviewState.SUBMITTED.value
211 self.submitted = True
213 self.save()
215 SubmissionLog.add_message(
216 self.version.submission,
217 content=_(f"Review for round #{self.version.number} submitted by")
218 + f" {self.reviewer}",
219 content_en=f"Review for round #{self.version.number} submitted by {self.reviewer}",
220 user=user,
221 # impersonate_data=impersonate_data,
222 significant=True,
223 )
226class ReviewAdditionalFile(BaseFileWrapperModel):
227 attached_to: models.ForeignKey[Review] = models.ForeignKey(
228 Review, on_delete=models.CASCADE, related_name="additional_files"
229 )
230 author_access = models.BooleanField(default=False)
232 @staticmethod
233 def get_upload_path(instance: ReviewAdditionalFile, filename: str) -> str:
234 return os.path.join(
235 "submissions",
236 str(instance.attached_to.version.submission.pk),
237 "versions",
238 str(instance.attached_to.version.number),
239 "review",
240 str(instance.attached_to.pk),
241 filename,
242 )
244 @classmethod
245 def reverse_file_path(cls, file_path: str) -> ReviewAdditionalFile | None:
246 """
247 Check first with a regex if the given path might match. This is not necessary
248 but it saves us a DB query in case in doesn't match the path structure.
249 """
250 regex = "/".join(
251 [
252 "submissions",
253 r"(?P<submission_pk>[0-9]+)",
254 "versions",
255 r"(?P<version_number>[0-9]+)",
256 "review",
257 r"(?P<review_pk>[0-9]+)",
258 r"(?P<file_name>[^\/]+)$",
259 ]
260 )
261 match = re.match(re.compile(regex), file_path)
262 if not match:
263 return None
265 return cls._default_manager.filter(file=file_path).first()
267 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool:
268 """
269 This model is only editable by user role with edit rights on the submission.
270 """
271 if right_code == "read":
272 return role_handler.check_global_rights("can_access_review", self.attached_to)
274 elif right_code in ["delete"]:
275 return role_handler.check_rights("can_edit_review", self.attached_to)
277 return False