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
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +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 objects: ReviewManager[Self] = ReviewManager()
137 class Meta:
138 constraints = [
139 models.UniqueConstraint(
140 fields=["version", "reviewer"], name="unique_reviewer_per_round"
141 )
142 ]
144 def __str__(self) -> str:
145 return f"Review: {self.reviewer} - {self.version}"
147 @property
148 def is_response_overdue(self) -> bool:
149 return self.accepted is None and self.date_response_due < date.today()
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 )
159 @property
160 def is_completed(self) -> bool:
161 return self.accepted is False or self.submitted is True
163 @property
164 def is_editable(self) -> bool:
165 return not self.submitted and self.version.review_open
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.")
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
183 self.save()
185 base_message = (
186 "Referee request accepted by" if accept_value else "Referee request declined by"
187 )
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 )
198 @property
199 def is_submittable(self) -> bool:
200 return not self.is_completed and self.is_editable
202 def submit(self, user) -> None:
203 if not self.is_submittable:
204 raise ReviewStateError("Trying to submit a non-editable review.")
206 self._user = user # type:ignore
207 self.state = ReviewState.SUBMITTED.value
208 self.submitted = True
210 self.save()
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 )
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)
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 )
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
262 return cls._default_manager.filter(file=file_path).first()
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)
271 elif right_code in ["delete"]:
272 return role_handler.check_rights("can_edit_review", self.attached_to)
274 return False