Coverage for src/mesh/models/submission_models.py: 85%
194 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 enum import Enum, unique
6from functools import cached_property
7from typing import TYPE_CHECKING, Self, TypeVar
9from django.conf import settings
10from django.db import models
11from django.db.models import FilteredRelation, Max, Q
12from django.utils import timezone
13from django.utils.translation import gettext_lazy as _
15from mesh.model.exceptions import SubmissionStateError
17from .base_models import BaseChangeTrackingModel, BaseSubmittableModel
18from .file_models import BaseFileWrapperModel, BaseModelWithFiles
19from .log_models import ModelLog
21# Used with annotations to enable correct typing without having circular import
22if TYPE_CHECKING: 22 ↛ 23line 22 didn't jump to line 23 because the condition on line 22 was never true
23 from datetime import datetime
25 from mesh.model.roles.role_handler import RoleHandler
27 from .editorial_models import EditorialDecision
28 from .user_models import User
30_T = TypeVar("_T", bound=models.Model)
33@unique
34class SubmissionState(Enum):
35 """
36 Enum of the submission statees.
37 Warning: the value of each state is used in CSS.
38 """
40 # OPENED Submission created by the author but not submitted for
41 # review yet.
42 # Preferably use the boolean `is_draft` when checking for OPENED state
43 OPENED = "opened"
44 # SUBMITTED Submission submitted for review. Waiting for editor to
45 # take action (accept/reject/open review round)
46 SUBMITTED = "submitted"
47 # ON REVIEW Open round OR no waiting for editor to take action
48 # (accept/reject/open review round)
49 ON_REVIEW = "review"
50 # REVISION REQUESTED Editor requested revisions.
51 # Waiting for author to submit new version
52 REVISION_REQUESTED = "rev_requested"
53 # REVISION SUBMITTED Author submitted revision. Waiting for editor to
54 # take action (accept/reject/open review round)
55 REVISION_SUBMITTED = "rev_submited"
56 # ACCEPTED Editor accepted the submission as it is.
57 # Begin Copyediting process.
58 ACCEPTED = "accepted"
59 # REJECTED Editor rejected the submission. The submission is closed.
60 REJECTED = "rejected"
63SUBMISSION_STATE_CHOICES = [
64 (SubmissionState.OPENED.value, _("Draft")),
65 (SubmissionState.SUBMITTED.value, _("Submitted")),
66 (SubmissionState.ON_REVIEW.value, _("Under review")),
67 (SubmissionState.REVISION_REQUESTED.value, _("Revision requested")),
68 (SubmissionState.REVISION_SUBMITTED.value, _("Revision submitted")),
69 (SubmissionState.ACCEPTED.value, _("Accepted")),
70 (SubmissionState.REJECTED.value, _("Rejected")),
71]
73SUBMISSION_STATE_EDITOR_CHOICES = [
74 (SubmissionState.REVISION_REQUESTED.value, _("Request revisions")),
75 (SubmissionState.ACCEPTED.value, _("Accept submission")),
76 (SubmissionState.REJECTED.value, _("Reject submission")),
77]
80# Typing with the generic here is very important to get correct type hints
81# when using any SubmissionManager methods.
82class SubmissionQuerySet(models.QuerySet[_T]):
83 def annotate_last_activity(self) -> Self:
84 """
85 Annotate the `date_last_activity` to the queryset = date of the last
86 significant log entry.
87 """
88 return self.annotate(
89 log_significant=FilteredRelation(
90 "log_messages", condition=Q(log_messages__significant=True)
91 )
92 ).annotate(date_last_activity=Max("log_significant__date_created"))
94 def prefetch_data(self) -> Self:
95 """
96 Shortcut function to prefetch all related MtoM data.
97 """
98 return self.prefetch_related(
99 "versions",
100 "versions__main_file",
101 "versions__additional_files",
102 "versions__reviews",
103 "versions__reviews__additional_files",
104 "authors",
105 "editors",
106 "editors__user",
107 )
109 def select_data(self) -> Self:
110 """
111 Shortcut function to select all related FK.
112 """
113 return self.select_related("created_by", "last_modified_by", "journal_section")
116class SubmissionManager(models.Manager[_T]):
117 def get_queryset(self) -> SubmissionQuerySet[_T]:
118 return (
119 SubmissionQuerySet(self.model, using=self._db).annotate_last_activity().select_data()
120 )
122 def get_submissions(self, prefetch=True) -> SubmissionQuerySet[_T]:
123 """
124 Return a SubmissionQuerySet with prefetched related data (default).
125 """
126 if not prefetch: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 return self.get_queryset()
128 return self.get_queryset().prefetch_data()
131class Submission(BaseChangeTrackingModel):
132 # Change the default `created_by` with a protected on_delete behavior.
133 created_by = models.ForeignKey(
134 settings.AUTH_USER_MODEL,
135 verbose_name=_("Created by"),
136 on_delete=models.PROTECT,
137 blank=True,
138 null=False,
139 help_text=_("Automatically filled on save."),
140 editable=False,
141 related_name="+",
142 )
143 name = models.TextField(verbose_name=_("Title"))
144 abstract = models.TextField(verbose_name=_("Abstract"))
145 journal_section = models.ForeignKey(
146 "JournalSection",
147 verbose_name=_("Section (Optional)"),
148 on_delete=models.SET_NULL,
149 related_name="submissions",
150 null=True,
151 blank=True,
152 )
153 state = models.CharField(
154 verbose_name=_("state"),
155 max_length=64,
156 choices=SUBMISSION_STATE_CHOICES,
157 default=SubmissionState.OPENED.value,
158 )
159 date_first_version = models.DateTimeField(
160 verbose_name=_("Date of the first submitted version"), null=True, editable=False
161 )
162 author_agreement = models.BooleanField(
163 verbose_name=_("Agreement"),
164 help_text=_("I hereby declare that I have read blablabla and I consent to the terms"),
165 )
166 notes = models.TextField(verbose_name=_("Notes"), default="") # Post-it notes for the editors
168 objects: SubmissionManager[Self] = SubmissionManager()
170 class Meta:
171 constraints = [
172 models.UniqueConstraint(
173 fields=["created_by", "name"], name="unique_submission_name_per_user"
174 )
175 ]
177 def __str__(self) -> str:
178 return f"{self.created_by} - {self.name}"
180 @cached_property
181 def all_versions(self) -> list[SubmissionVersion]:
182 """
183 Return all the versions, ordered by descending `number`.
184 We do the sort manually to prevent an potential additional query (
185 the versions should be already prefetched).
186 """
187 versions = self.versions.all() # type:ignore
188 return sorted(versions, key=lambda version: version.number, reverse=True)
190 @cached_property
191 def current_version(self) -> SubmissionVersion | None:
192 """
193 The current (latest) `SubmissionVersion` of the Submission.
194 """
195 all_versions = self.all_versions
196 if all_versions:
197 return all_versions[0]
198 return None
200 @property
201 def date_submission(self) -> datetime | None:
202 """
203 Submission date of the submission.
204 It is the date of the first version completion or the submisison's creation date
205 if no versions are submitted yet.
206 """
207 return self.date_first_version or self.date_created
209 @property
210 def state_order(self) -> int:
211 """
212 Returns the integer mapped to the submission state for ordering purpose.
213 """
214 return [s[0] for s in SUBMISSION_STATE_CHOICES].index(self.state)
216 @cached_property
217 def all_assigned_editors(self) -> list[User]:
218 return sorted(
219 (e.user for e in self.editors.all()),
220 key=lambda u: u.first_name, # type:ignore
221 )
223 @cached_property
224 def all_authors(self) -> list[SubmissionAuthor]:
225 return sorted(self.authors.all(), key=lambda a: a.first_name) # type:ignore
227 @property
228 def is_submittable(self) -> bool:
229 """
230 Whether the submission is submittable.
231 It checks that the required data is correct.
232 """
233 return (
234 self.author_agreement is True
235 and self.state
236 in [SubmissionState.OPENED.value, SubmissionState.REVISION_REQUESTED.value]
237 and self.current_version is not None
238 and not self.current_version.submitted
239 and hasattr(self.current_version, "main_file")
240 and len(self.all_authors) > 0
241 )
243 @property
244 def is_draft(self) -> bool:
245 return self.state == SubmissionState.OPENED.value
247 def submit(self, user) -> None:
248 """
249 Submit the submission's current version:
250 - Set the submission's current version to `submitted=True`
251 - Change the submission state to "submitted" or "revisions_submitted"
252 according to the current state.
253 - Add an entry to the submission log
255 Raise an SubmissionStateError is the submission is not submittable.
257 Params:
258 - `request` The enclosing HTTP request. It's used to derive the
259 current user and potential impersonate data.
260 """
261 if not self.is_submittable:
262 raise SubmissionStateError(_("Trying to submit an non-submittable submission."))
264 code = self.state
266 self.state = (
267 SubmissionState.SUBMITTED.value
268 if self.is_draft
269 else SubmissionState.REVISION_SUBMITTED.value
270 )
271 self.save()
273 version: SubmissionVersion = self.current_version # type:ignore
274 version.submitted = True
275 version.date_submitted = timezone.now()
276 version._user = user # type:ignore
278 # Start Review Process
279 version = self.current_version
280 version.review_open = True
282 version.save()
284 self.state = SubmissionState.ON_REVIEW.value
285 self.save()
287 SubmissionLog.add_message(
288 self,
289 content=_("Submission of version") + f" #{version.number}",
290 content_en=f"Submission of version #{version.number}",
291 user=user,
292 # impersonate_data=impersonate_data,
293 significant=True,
294 code=code,
295 )
297 @property
298 def is_reviewable(self) -> bool:
299 """
300 Returns whether the submission can be sent to review.
301 """
302 return (
303 self.state
304 in [
305 SubmissionState.SUBMITTED.value,
306 SubmissionState.REVISION_SUBMITTED.value,
307 ]
308 and self.current_version is not None
309 and self.current_version.submitted
310 and self.current_version.review_open is False
311 and hasattr(self.current_version, "main_file")
312 )
314 def start_review_process(self, user) -> None:
315 """
316 Start the review process for the current version of the submission:
317 - Change the submission state to ON_REVIEW
318 - Open the review on the current version
319 - Add an entry to the submission log.
321 Raise an exception is the submission is not reviewable.
323 Params:
324 - `request` The enclosing HTTP request. It's used to derive the
325 current user and potential impersonate data.
326 """
327 if not self.is_reviewable:
328 raise SubmissionStateError(
329 _("Trying to start the review process of an non-reviewable submission.")
330 )
331 version: SubmissionVersion = self.current_version # type:ignore
332 version.review_open = True
333 version.save()
335 code = self.state
336 self.state = SubmissionState.ON_REVIEW.value
337 self.save()
339 SubmissionLog.add_message(
340 self,
341 content=f"Submission version #{version.number} sent to review.",
342 content_en=f"Submission version #{version.number} sent to review.",
343 user=user,
344 # impersonate_data=impersonate_data,
345 significant=True,
346 code=code,
347 )
349 def apply_editorial_decision(self, decision: EditorialDecision, user) -> None:
350 """
351 Apply an editorial decision:
352 - Changes the submission state to the selected state.
353 - Close the review on the current version.
354 - Add an entry to the submission log
356 Raise an exception if the submission's status does not allow editorial decision.
358 Params:
359 - `request` The enclosing HTTP request. It's used to derive the
360 current user and potential impersonate data.
361 """
362 if self.is_draft:
363 raise SubmissionStateError(
364 _("Trying to apply an editorial decision on a draft submission.")
365 )
366 # Update the submission state with the selected one
367 code = self.state
368 self.state = decision.value
369 self.save()
371 # Close the review on the current version if any.
372 version = self.current_version
373 if version: 373 ↛ 378line 373 didn't jump to line 378 because the condition on line 373 was always true
374 version.review_open = False
375 version.save()
377 # Add message and log entry
378 decision_str = decision.get_value_display() # type:ignore
379 SubmissionLog.add_message(
380 self,
381 content=_("Editorial decision") + f": {decision_str}",
382 content_en=f"Editorial decision: {decision_str}",
383 user=user,
384 significant=True,
385 code=code,
386 )
389class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles):
390 """
391 Version of a submission. Only contains files (main + additional files).
392 """
394 file_fields_required = ["main_file"]
395 file_fields_deletable = ["additional_files"]
397 submission = models.ForeignKey(
398 Submission,
399 on_delete=models.CASCADE,
400 blank=True,
401 null=False,
402 editable=False,
403 related_name="versions",
404 )
405 number = models.IntegerField(
406 help_text=_("Automatically filled on save"),
407 blank=True,
408 null=False,
409 editable=False,
410 )
412 # Boolean used to track whether the review process is still open for the submission
413 # version. A new version is not opened for review by default. Changing its value
414 # requires an editorial action.
415 review_open = models.BooleanField(
416 verbose_name=_("Version opened for review"), default=False, editable=False
417 )
419 class Meta:
420 constraints = [
421 models.UniqueConstraint(
422 fields=["submission", "number"], name="unique_submission_version_number"
423 ),
424 models.UniqueConstraint(
425 fields=["submission"],
426 condition=Q(review_open=True),
427 name="unique_review_open_submission_version",
428 ),
429 ]
431 def save(self, *args, **kwargs) -> None:
432 """
433 Fill the version's number for a new instance.
434 """
435 if self._state.adding:
436 current_version = self.submission.current_version
437 if current_version:
438 self.number = current_version.number + 1
439 else:
440 self.number = 1
442 return super().save(*args, **kwargs)
445class SubmissionMainFile(BaseFileWrapperModel):
446 file_extensions = [".pdf"]
448 attached_to: models.OneToOneField[SubmissionVersion] = models.OneToOneField(
449 SubmissionVersion,
450 primary_key=True,
451 on_delete=models.CASCADE,
452 related_name="main_file",
453 )
455 @staticmethod
456 def get_upload_path(instance: SubmissionMainFile, filename: str) -> str:
457 return os.path.join(
458 "submissions",
459 str(instance.attached_to.submission.pk),
460 "versions",
461 str(instance.attached_to.number),
462 filename,
463 )
465 @classmethod
466 def reverse_file_path(cls, file_path: str) -> SubmissionMainFile | None:
467 """
468 Check first with a regex is the file path might match. This is not necessary
469 but it saves us a DB query in case in doesn't match the path structure.
471 WARNING: This is not resilient to path change. A file could be moved to another
472 location, with the path updated in DB and still be an instance of this model.
473 If this becomes an issue, remove the regex check and just make the DB query.
474 """
475 regex = "/".join(
476 [
477 "submissions",
478 r"(?P<submission_pk>[0-9]+)",
479 "versions",
480 r"(?P<version_number>[0-9]+)",
481 r"(?P<file_name>[^\/]+)$",
482 ]
483 )
484 match = re.match(re.compile(regex), file_path)
485 if not match:
486 return None
488 return cls._default_manager.filter(file=file_path).first()
490 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool:
491 """
492 This model is only editable by user role with edit rights on the submission.
493 """
494 if right_code == "read":
495 return role_handler.check_global_rights("can_access_version", self.attached_to)
497 return False
500class SubmissionAdditionalFile(BaseFileWrapperModel):
501 file_extensions = [
502 ".pdf",
503 ".docx",
504 ".odt",
505 ".py",
506 ".jpg",
507 ".png",
508 ".ipynb",
509 ".sql",
510 ".tex",
511 ]
512 attached_to: models.ForeignKey[SubmissionVersion] = models.ForeignKey(
513 SubmissionVersion, on_delete=models.CASCADE, related_name="additional_files"
514 )
516 @staticmethod
517 def get_upload_path(instance: SubmissionAdditionalFile, filename: str) -> str:
518 return os.path.join(
519 "submissions",
520 str(instance.attached_to.submission.pk),
521 "versions",
522 str(instance.attached_to.number),
523 "additional",
524 filename,
525 )
527 @classmethod
528 def reverse_file_path(cls, file_path: str) -> SubmissionAdditionalFile | None:
529 """
530 Check first with a regex is the file path might match. This is not necessary
531 but it saves us a DB query in case in doesn't match the path structure.
532 """
533 regex = "/".join(
534 [
535 "submissions",
536 r"(?P<submission_pk>[0-9]+)",
537 "versions",
538 r"(?P<version_number>[0-9]+)",
539 "additional",
540 r"(?P<file_name>[^\/]+)$",
541 ]
542 )
543 match = re.match(re.compile(regex), file_path)
544 if not match:
545 return None
547 return cls._default_manager.filter(file=file_path).first()
549 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool:
550 """
551 This model is only editable by user role with edit rights on the submission.
552 """
553 if right_code == "read":
554 return role_handler.check_global_rights("can_access_version", self.attached_to)
556 elif right_code in ["delete"]:
557 return role_handler.check_rights("can_edit_version", self.attached_to)
559 return False
562class SubmissionLog(ModelLog):
563 attached_to = models.ForeignKey(
564 Submission, on_delete=models.CASCADE, related_name="log_messages"
565 )
568class SubmissionAuthor(BaseChangeTrackingModel):
569 """
570 Model for a submission's author.
571 1 submission author is linked to 1 submission only.
572 """
574 submission = models.ForeignKey(
575 Submission,
576 on_delete=models.CASCADE,
577 blank=True,
578 null=False,
579 editable=False,
580 related_name="authors",
581 )
582 first_name = models.CharField(
583 verbose_name=_("First name"), max_length=150, blank=False, null=False
584 )
585 last_name = models.CharField(
586 verbose_name=_("Last name"), max_length=150, blank=False, null=False
587 )
588 email = models.EmailField(_("E-mail address"))
589 corresponding = models.BooleanField(
590 verbose_name=_("Corresponding contact"),
591 blank=False,
592 null=False,
593 default=False,
594 help_text=_(
595 "If checked, e-mails will be sent to advise of any progress in the submission process."
596 ),
597 )
599 class Meta:
600 constraints = [
601 models.UniqueConstraint(
602 fields=["submission", "email"],
603 name="unique_author_email_per_submission",
604 )
605 ]
607 def __str__(self) -> str:
608 return f"{self.first_name} {self.last_name} ({self.email})"
610 def full_name(self) -> str:
611 return f"{self.first_name} {self.last_name}"