Coverage for src / mesh / models / orm / submission_models.py: 89%
225 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-11 09:47 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-11 09:47 +0000
1from __future__ import annotations
3import logging
4import os
5from enum import Enum, unique
6from typing import TYPE_CHECKING, Self
8from django.db import models
9from django.db.models import FilteredRelation, Manager, Max, Q
10from django.urls import reverse
11from django.utils import timezone
12from django.utils.translation import gettext_lazy as _
14from mesh.models.exceptions import SubmissionStateError
15from mesh.models.orm.base_models import BaseChangeTrackingModel, BaseSubmittableModel
16from mesh.models.orm.file_models import BaseFileWrapperModel, BaseModelWithFiles
17from mesh.models.orm.log_models import ModelLog
18from mesh.models.orm.user_models import User
20# Used with annotations to enable correct typing without having circular import
21if TYPE_CHECKING:
22 from datetime import datetime
24 from django.db.models.manager import RelatedManager
26 from mesh.models.orm.editorial_models import EditorSubmissionRight
27 from mesh.models.orm.review_models import Review
29 from .editorial_models import EditorialDecision
31logger = logging.getLogger(__name__)
34@unique
35class SubmissionState(Enum):
36 """
37 Enum of the submission statees.
38 Warning: the value of each state is used in CSS.
39 """
41 # OPENED Submission created by the author but not submitted for
42 # review yet.
43 # Preferably use the boolean `is_draft` when checking for OPENED state
44 OPENED = "opened"
45 # SUBMITTED Submission submitted for review. Waiting for editor to
46 # take action (accept/reject/open review round)
47 SUBMITTED = "submitted"
48 # ON REVIEW Open round OR no waiting for editor to take action
49 # (accept/reject/open review round)
50 ON_REVIEW = "review"
51 # REVISION REQUESTED Editor requested revisions.
52 # Waiting for author to submit new version
53 REVISION_REQUESTED = "rev_requested"
54 # REVISION SUBMITTED Author submitted revision. Waiting for editor to
55 # take action (accept/reject/open review round)
56 REVISION_SUBMITTED = "rev_submited"
57 # ACCEPTED Editor accepted the submission as it is.
58 # Begin Copyediting process.
59 ACCEPTED = "accepted"
60 # REJECTED Editor rejected the submission. The submission is closed.
61 REJECTED = "rejected"
64SUBMISSION_STATE_CHOICES = [
65 (SubmissionState.OPENED.value, _("Draft")),
66 (SubmissionState.SUBMITTED.value, _("Submitted")),
67 (SubmissionState.ON_REVIEW.value, _("Under review")),
68 (SubmissionState.REVISION_REQUESTED.value, _("Revision requested")),
69 (SubmissionState.REVISION_SUBMITTED.value, _("Revision submitted")),
70 (SubmissionState.ACCEPTED.value, _("Accepted")),
71 (SubmissionState.REJECTED.value, _("Rejected")),
72]
74SUBMISSION_STATE_EDITOR_CHOICES = [
75 (SubmissionState.REVISION_REQUESTED.value, _("Request revisions")),
76 (SubmissionState.ACCEPTED.value, _("Accept submission")),
77 (SubmissionState.REJECTED.value, _("Reject submission")),
78]
81# Typing with the generic here is very important to get correct type hints
82# when using any SubmissionManager methods.
83class SubmissionQuerySet(models.QuerySet["Submission"]):
84 def annotate_last_activity(self) -> Self:
85 """
86 Annotate the `date_last_activity` to the queryset = date of the last
87 significant log entry.
88 """
89 return self.annotate(
90 log_significant=FilteredRelation(
91 "log_messages", condition=Q(log_messages__significant=True)
92 )
93 ).annotate(date_last_activity=Max("log_significant__date_created"))
95 def prefetch_data(self) -> Self:
96 """
97 Shortcut function to prefetch all related MtoM data.
98 """
99 return self.prefetch_related(
100 "editors",
101 "editors__user",
102 )
104 def select_data(self) -> Self:
105 """
106 Shortcut function to select all related FK.
107 """
108 return self.select_related("created_by", "last_modified_by", "journal_section")
111class PrefetchedSubmissionManager(models.Manager["Submission"]):
112 def get_queryset(self):
113 return (
114 SubmissionQuerySet(self.model, using=self._db)
115 .annotate_last_activity()
116 .prefetch_data()
117 .select_data()
118 )
121class Submission(BaseChangeTrackingModel):
122 # Change the default `created_by` with a protected on_delete behavior.
123 created_by = models.ForeignKey(
124 User,
125 verbose_name=_("Created by"),
126 on_delete=models.PROTECT,
127 null=False,
128 help_text=_("Automatically filled on save."),
129 editable=False,
130 related_name="+",
131 )
132 name = models.TextField(verbose_name=_("Title"))
133 abstract = models.TextField(verbose_name=_("Abstract"))
134 journal_section = models.ForeignKey["JournalSection"](
135 "JournalSection",
136 verbose_name=_("Section (Optional)"),
137 on_delete=models.SET_NULL,
138 related_name="submissions",
139 null=True,
140 )
141 state = models.CharField(
142 verbose_name=_("state"),
143 max_length=64,
144 choices=SUBMISSION_STATE_CHOICES,
145 default=SubmissionState.OPENED.value,
146 )
147 date_first_version = models.DateTimeField(
148 verbose_name=_("Date of the first submitted version"), null=True, editable=False
149 )
150 author_agreement = models.BooleanField(
151 verbose_name=_("Agreement"),
152 help_text=_("I hereby declare that I have read blablabla and I consent to the terms"),
153 )
154 notes = models.TextField(verbose_name=_("Notes"), default="") # Post-it notes for the editors
156 ojs_id = models.PositiveIntegerField(null=True)
158 objects = PrefetchedSubmissionManager()
160 # RelatedObjects
161 versions: Manager[SubmissionVersion]
162 editors: Manager[EditorSubmissionRight]
163 authors: Manager[SubmissionAuthor]
164 log_messages: Manager[SubmissionLog]
166 # Annotated properties
167 user_is_editor: bool
168 "Annotated True when the current user has editing rights for this submission"
169 authors_string: str
170 "Annotated"
171 created_by_censored: str
172 "Annotated"
173 authors_censored: list[SubmissionAuthor]
174 "Annotated"
175 versions_censored: list[SubmissionVersion]
176 "Annotated"
177 log_messages_censored: list[SubmissionLog]
178 "Annotated"
180 # class Meta:
181 # constraints = [
182 # models.UniqueConstraint(
183 # fields=["created_by", "name"], name="unique_submission_name_per_user"
184 # )
185 # ]
187 def __str__(self) -> str:
188 return f"{self.created_by} - {self.name}"
190 def get_current_version(self):
191 """
192 The current (latest) `SubmissionVersion` of the Submission.
193 """
194 if not self.versions_censored:
195 return None
196 return self.versions_censored[0]
198 @property
199 def date_submission(self) -> datetime | None:
200 """
201 Submission date of the submission.
202 It is the date of the first version completion or the submisison's creation date
203 if no versions are submitted yet.
204 """
205 return self.date_first_version or self.date_created
207 @property
208 def state_order(self) -> int:
209 """
210 Returns the integer mapped to the submission state for ordering purpose.
211 """
212 return [s[0] for s in SUBMISSION_STATE_CHOICES].index(self.state)
214 @property
215 def all_assigned_editors(self) -> list[User]:
216 return sorted(
217 (e.user for e in self.editors.all()),
218 key=lambda u: u.first_name,
219 )
221 def is_submittable(self) -> bool:
222 """
223 Whether the submission is submittable.
224 It checks that the required data is correct.
225 """
226 if self.author_agreement is False:
227 logger.debug(f"Submission {self.pk} not submittable : author_agreement is False")
228 return False
229 if self.state not in [
230 SubmissionState.OPENED.value,
231 SubmissionState.REVISION_REQUESTED.value,
232 ]:
233 logger.debug(f"Submission {self.pk} not submittable : state is {self.state}")
234 return False
235 if len(self.versions.all()) == 0:
236 logger.debug(
237 f"Submission {self.pk} not submittable : submission has no versions is None"
238 )
239 return False
240 if self.versions.all()[0].submitted:
241 logger.debug(
242 f"Submission {self.pk} not submittable : current_version is already submitted"
243 )
244 return False
245 if not hasattr(self.versions.all()[0], "main_file"):
246 logger.debug(
247 f"Submission {self.pk} not submittable : current_version does not have a main file"
248 )
249 return False
250 if self.authors.count() == 0:
251 logger.debug(f"Submission {self.pk} not submittable : no authors found")
252 return False
253 return True
255 @property
256 def is_draft(self) -> bool:
257 return self.state == SubmissionState.OPENED.value
259 def submit(self, user, *args, date_submitted=None) -> None:
260 """
261 Submit the submission's current version:
262 - Set the submission's current version to `submitted=True`
263 - Change the submission state to "submitted" or "revisions_submitted"
264 according to the current state.
265 - Add an entry to the submission log
267 Raise an SubmissionStateError is the submission is not submittable.
269 Params:
270 - `request` The enclosing HTTP request. It's used to derive the
271 current user and potential impersonate data.
272 """
273 if not self.is_submittable():
274 raise SubmissionStateError(_("Trying to submit an non-submittable submission."))
276 if date_submitted is None:
277 date_submitted = timezone.now()
279 code = self.state
281 self.state = (
282 SubmissionState.SUBMITTED.value
283 if self.is_draft
284 else SubmissionState.REVISION_SUBMITTED.value
285 )
286 self.save()
288 version = self.versions.first()
289 if version is None:
290 raise ValueError("Cannot submit: submission does not have a current version")
291 version.submitted = True
293 version.date_submitted = date_submitted
294 version._user = user
295 version.review_open = True
296 version.save()
298 self.state = SubmissionState.ON_REVIEW.value
299 self.save()
301 SubmissionLog.add_message(
302 self,
303 content=_("Submission of version") + f" #{version.number}",
304 content_en=f"Submission of version #{version.number}",
305 user=user,
306 significant=True,
307 code=code,
308 date=date_submitted,
309 )
311 def is_reviewable(self) -> bool:
312 """
313 Returns whether the submission can be sent to review.
314 """
315 current_version = (
316 self.versions_censored[0]
317 if hasattr(self, "versions_censored") and self.versions_censored
318 else None
319 )
320 return (
321 self.state
322 in [
323 SubmissionState.SUBMITTED.value,
324 SubmissionState.REVISION_SUBMITTED.value,
325 ]
326 and current_version is not None
327 and current_version.submitted
328 and current_version.review_open is False
329 and hasattr(current_version, "main_file")
330 )
332 def start_review_process(self, user, date=None) -> None:
333 """
334 Start the review process for the current version of the submission:
335 - Change the submission state to ON_REVIEW
336 - Open the review on the current version
337 - Add an entry to the submission log.
339 Raise an exception is the submission is not reviewable.
341 Params:
342 - `request` The enclosing HTTP request. It's used to derive the
343 current user and potential impersonate data.
344 """
345 if not self.is_reviewable():
346 raise SubmissionStateError(
347 _("Trying to start the review process of an non-reviewable submission.")
348 )
349 version = self.versions.first()
350 if version is None:
351 raise ValueError("Cannot submit: submission does not have a current version")
352 version.review_open = True
353 version.save()
355 code = self.state
356 self.state = SubmissionState.ON_REVIEW.value
357 self.save()
359 SubmissionLog.add_message(
360 self,
361 content=f"Submission version #{version.number} sent to review.",
362 content_en=f"Submission version #{version.number} sent to review.",
363 user=user,
364 significant=True,
365 code=code,
366 date=date,
367 )
369 # def apply_editorial_decision(self, decision: EditorialDecision, user) -> None:
370 def apply_editorial_decision(self, decision: EditorialDecision, user, date=None) -> None:
371 """
372 Apply an editorial decision:
373 - Changes the submission state to the selected state.
374 - Close the review on the current version.
375 - Add an entry to the submission log
377 Raise an exception if the submission's status does not allow editorial decision.
379 Params:
380 - `request` The enclosing HTTP request. It's used to derive the
381 current user and potential impersonate data.
382 """
383 if self.is_draft:
384 raise SubmissionStateError(
385 _("Trying to apply an editorial decision on a draft submission.")
386 )
387 # Update the submission state with the selected one
388 code = self.state
389 self.state = decision.value
390 self.override_saved_date(date_last_modified=date, last_modified_by_user=user)
391 self.save()
393 # Close the review on the current version if any.
394 version = self.versions.first()
395 if version:
396 version.review_open = False
397 version.override_saved_date(date_last_modified=date, last_modified_by_user=user)
398 version.save()
400 # Add message and log entry
401 decision_str = decision.get_value_display()
402 SubmissionLog.add_message(
403 self,
404 content=_("Editorial decision") + f": {decision_str}",
405 content_en=f"Editorial decision: {decision_str}",
406 user=user,
407 significant=True,
408 code=code,
409 # date=date,
410 )
412 def get_absolute_url(self):
413 return reverse("mesh:submission_details", kwargs={"submission_pk": self.pk})
416class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles):
417 """
418 Version of a submission. Only contains files (main + additional files).
419 """
421 file_fields_required = ["main_file"]
422 file_fields_deletable = ["additional_files"]
424 submission = models.ForeignKey(
425 Submission,
426 on_delete=models.CASCADE,
427 null=False,
428 editable=False,
429 related_name="versions",
430 )
431 number = models.IntegerField(
432 help_text=_("Automatically filled on save"),
433 null=False,
434 editable=False,
435 )
436 # reviews: "Manager[Review]"
437 # main_file: "SubmissionMainFile"
438 # additional_files: "Manager[SubmissionAdditionalFile]"
439 reviews: Manager[Review]
440 main_file: SubmissionMainFile
441 additional_files: Manager[SubmissionAdditionalFile]
442 # Boolean used to track whether the review process is still open for the submission
443 # version. A new version is not opened for review by default. Changing its value
444 # requires an editorial action.
445 review_open = models.BooleanField(
446 verbose_name=_("Version opened for review"), default=False, editable=False
447 )
449 # RelatedManagers
450 additional_files: RelatedManager[SubmissionAdditionalFile]
451 main_file: SubmissionMainFile
452 editorial_decision: EditorialDecision | None
453 reviews: RelatedManager[Review]
454 # Annotated properties
455 created_by_censored: str | User
456 reviews_censored: list[Review]
458 class Meta: # type: ignore
459 constraints = [
460 models.UniqueConstraint(
461 fields=["submission", "number"], name="unique_submission_version_number"
462 ),
463 models.UniqueConstraint(
464 fields=["submission"],
465 condition=Q(review_open=True),
466 name="unique_review_open_submission_version",
467 ),
468 ]
469 ordering = ["-number"]
471 def save(self, *args, **kwargs) -> None:
472 """
473 Fill the version's number for a new instance.
474 """
475 if self._state.adding:
476 current_version = self.submission.versions.first()
477 if current_version:
478 self.number = current_version.number + 1
479 else:
480 self.number = 1
482 return super().save(*args, **kwargs)
485class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]):
486 file_extensions = [".pdf"]
488 attached_to = models.OneToOneField(
489 SubmissionVersion,
490 primary_key=True,
491 on_delete=models.CASCADE,
492 related_name="main_file",
493 )
495 def get_upload_path(self, filename: str) -> str:
496 return os.path.join(
497 "submissions",
498 str(self.attached_to.submission.pk),
499 "versions",
500 str(self.attached_to.number),
501 filename,
502 )
504 def check_access_right(self, role, right_code: str) -> bool:
505 """
506 This model is only editable by user role with edit rights on the submission.
507 """
508 if right_code == "read":
509 return role.can_access_version(self.attached_to)
511 return False
513 def get_absolute_url(self) -> str:
514 """
515 Returns the URL to the model's file.
516 """
517 if not self.file.url:
518 return ""
519 file_identifier = self.get_file_identifier()
520 version_pk = self.attached_to.pk
521 submission_pk = self.attached_to.submission.pk
522 return reverse(
523 "mesh:serve_submission_version_main_file",
524 kwargs={
525 "file_identifier": file_identifier,
526 "version_pk": version_pk,
527 "submission_pk": submission_pk,
528 },
529 )
532class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]):
533 file_extensions = [
534 ".pdf",
535 ".docx",
536 ".odt",
537 ".py",
538 ".jpg",
539 ".png",
540 ".ipynb",
541 ".sql",
542 ".tex",
543 ]
544 attached_to = models.ForeignKey(
545 SubmissionVersion, on_delete=models.CASCADE, related_name="additional_files"
546 )
548 def get_upload_path(self, filename: str) -> str:
549 return os.path.join(
550 "submissions",
551 str(self.attached_to.submission.pk),
552 "versions",
553 str(self.attached_to.number),
554 "additional",
555 filename,
556 )
558 def check_access_right(self, role, right_code: str) -> bool:
559 """
560 This model is only editable by user role with edit rights on the submission.
561 """
562 if right_code == "read":
563 return role.can_access_version(self.attached_to)
565 elif right_code in ["delete"]:
566 return role.can_edit_version(self.attached_to)
568 return False
570 def get_absolute_url(self) -> str:
571 """
572 Returns the URL to the model's file.
573 """
574 if not self.file.url:
575 return ""
576 file_identifier = self.get_file_identifier()
577 version_pk = self.attached_to.pk
578 submission_pk = self.attached_to.submission.pk
579 return reverse(
580 "mesh:serve_submission_version_additional_file",
581 kwargs={
582 "file_identifier": file_identifier,
583 "version_pk": version_pk,
584 "submission_pk": submission_pk,
585 },
586 )
589class SubmissionLog(ModelLog):
590 attached_to = models.ForeignKey(
591 Submission, on_delete=models.CASCADE, related_name="log_messages"
592 )
595class SubmissionAuthor(BaseChangeTrackingModel):
596 """
597 Model for a submission's author.
598 1 submission author is linked to 1 submission only.
599 """
601 submission = models.ForeignKey(
602 Submission,
603 on_delete=models.CASCADE,
604 null=False,
605 editable=False,
606 related_name="authors",
607 )
608 first_name = models.CharField(
609 verbose_name=_("First name"), max_length=150, blank=False, null=False
610 )
611 last_name = models.CharField(
612 verbose_name=_("Last name"), max_length=150, blank=False, null=False
613 )
614 email = models.EmailField(_("E-mail address"))
615 corresponding = models.BooleanField(
616 verbose_name=_("Corresponding contact"),
617 blank=False,
618 null=False,
619 default=False,
620 help_text=_(
621 "If checked, e-mails will be sent to advise of any progress in the submission process."
622 ),
623 )
625 # class Meta:
626 # constraints = [
627 # models.UniqueConstraint(
628 # fields=["submission", "email"],
629 # name="unique_author_email_per_submission",
630 # )
631 # ]
633 class Meta:
634 ordering = ["last_name"]
636 def __str__(self) -> str:
637 return f"{self.first_name} {self.last_name} ({self.email})"
639 def full_name(self) -> str:
640 return f"{self.first_name} {self.last_name}"