Coverage for src / mesh / models / orm / submission_models.py: 90%
225 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-05-04 12:41 +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 # impersonate_data=impersonate_data,
307 significant=True,
308 code=code,
309 date=date_submitted,
310 )
312 def is_reviewable(self) -> bool:
313 """
314 Returns whether the submission can be sent to review.
315 """
316 current_version = (
317 self.versions_censored[0]
318 if hasattr(self, "versions_censored") and self.versions_censored
319 else None
320 )
321 return (
322 self.state
323 in [
324 SubmissionState.SUBMITTED.value,
325 SubmissionState.REVISION_SUBMITTED.value,
326 ]
327 and current_version is not None
328 and current_version.submitted
329 and current_version.review_open is False
330 and hasattr(current_version, "main_file")
331 )
333 def start_review_process(self, user, date=None) -> None:
334 """
335 Start the review process for the current version of the submission:
336 - Change the submission state to ON_REVIEW
337 - Open the review on the current version
338 - Add an entry to the submission log.
340 Raise an exception is the submission is not reviewable.
342 Params:
343 - `request` The enclosing HTTP request. It's used to derive the
344 current user and potential impersonate data.
345 """
346 if not self.is_reviewable():
347 raise SubmissionStateError(
348 _("Trying to start the review process of an non-reviewable submission.")
349 )
350 version = self.versions.first()
351 if version is None:
352 raise ValueError("Cannot submit: submission does not have a current version")
353 version.review_open = True
354 version.save()
356 code = self.state
357 self.state = SubmissionState.ON_REVIEW.value
358 self.save()
360 SubmissionLog.add_message(
361 self,
362 content=f"Submission version #{version.number} sent to review.",
363 content_en=f"Submission version #{version.number} sent to review.",
364 user=user,
365 # impersonate_data=impersonate_data,
366 significant=True,
367 code=code,
368 date=date,
369 )
371 # def apply_editorial_decision(self, decision: EditorialDecision, user) -> None:
372 def apply_editorial_decision(self, decision: EditorialDecision, user, date=None) -> None:
373 """
374 Apply an editorial decision:
375 - Changes the submission state to the selected state.
376 - Close the review on the current version.
377 - Add an entry to the submission log
379 Raise an exception if the submission's status does not allow editorial decision.
381 Params:
382 - `request` The enclosing HTTP request. It's used to derive the
383 current user and potential impersonate data.
384 """
385 if self.is_draft:
386 raise SubmissionStateError(
387 _("Trying to apply an editorial decision on a draft submission.")
388 )
389 # Update the submission state with the selected one
390 code = self.state
391 self.state = decision.value
392 self.override_saved_date(date_last_modified=date, last_modified_by_user=user)
393 self.save()
395 # Close the review on the current version if any.
396 version = self.versions.first()
397 if version:
398 version.review_open = False
399 version.override_saved_date(date_last_modified=date, last_modified_by_user=user)
400 version.save()
402 # Add message and log entry
403 decision_str = decision.get_value_display()
404 SubmissionLog.add_message(
405 self,
406 content=_("Editorial decision") + f": {decision_str}",
407 content_en=f"Editorial decision: {decision_str}",
408 user=user,
409 significant=True,
410 code=code,
411 # date=date,
412 )
414 def get_absolute_url(self):
415 return reverse("mesh:submission_details", kwargs={"submission_pk": self.pk})
418class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles):
419 """
420 Version of a submission. Only contains files (main + additional files).
421 """
423 file_fields_required = ["main_file"]
424 file_fields_deletable = ["additional_files"]
426 submission = models.ForeignKey(
427 Submission,
428 on_delete=models.CASCADE,
429 null=False,
430 editable=False,
431 related_name="versions",
432 )
433 number = models.IntegerField(
434 help_text=_("Automatically filled on save"),
435 null=False,
436 editable=False,
437 )
438 # reviews: "Manager[Review]"
439 # main_file: "SubmissionMainFile"
440 # additional_files: "Manager[SubmissionAdditionalFile]"
441 reviews: Manager[Review]
442 main_file: SubmissionMainFile
443 additional_files: Manager[SubmissionAdditionalFile]
444 # Boolean used to track whether the review process is still open for the submission
445 # version. A new version is not opened for review by default. Changing its value
446 # requires an editorial action.
447 review_open = models.BooleanField(
448 verbose_name=_("Version opened for review"), default=False, editable=False
449 )
451 # RelatedManagers
452 additional_files: "RelatedManager[SubmissionAdditionalFile]"
453 main_file: "SubmissionMainFile"
454 editorial_decision: "EditorialDecision|None"
455 reviews: "RelatedManager[Review]"
456 # Annotated properties
457 created_by_censored: str | User
458 reviews_censored: "list[Review]"
460 class Meta: # type: ignore
461 constraints = [
462 models.UniqueConstraint(
463 fields=["submission", "number"], name="unique_submission_version_number"
464 ),
465 models.UniqueConstraint(
466 fields=["submission"],
467 condition=Q(review_open=True),
468 name="unique_review_open_submission_version",
469 ),
470 ]
471 ordering = ["-number"]
473 def save(self, *args, **kwargs) -> None:
474 """
475 Fill the version's number for a new instance.
476 """
477 if self._state.adding:
478 current_version = self.submission.versions.first()
479 if current_version:
480 self.number = current_version.number + 1
481 else:
482 self.number = 1
484 return super().save(*args, **kwargs)
487class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]):
488 file_extensions = [".pdf"]
490 attached_to = models.OneToOneField(
491 SubmissionVersion,
492 primary_key=True,
493 on_delete=models.CASCADE,
494 related_name="main_file",
495 )
497 def get_upload_path(self, filename: str) -> str:
498 return os.path.join(
499 "submissions",
500 str(self.attached_to.submission.pk),
501 "versions",
502 str(self.attached_to.number),
503 filename,
504 )
506 def check_access_right(self, role, right_code: str) -> bool:
507 """
508 This model is only editable by user role with edit rights on the submission.
509 """
510 if right_code == "read":
511 return role.can_access_version(self.attached_to)
513 return False
515 def get_absolute_url(self) -> str:
516 """
517 Returns the URL to the model's file.
518 """
519 if not self.file.url:
520 return ""
521 file_identifier = self.get_file_identifier()
522 version_pk = self.attached_to.pk
523 submission_pk = self.attached_to.submission.pk
524 return reverse(
525 "mesh:serve_submission_version_main_file",
526 kwargs={
527 "file_identifier": file_identifier,
528 "version_pk": version_pk,
529 "submission_pk": submission_pk,
530 },
531 )
534class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]):
535 file_extensions = [
536 ".pdf",
537 ".docx",
538 ".odt",
539 ".py",
540 ".jpg",
541 ".png",
542 ".ipynb",
543 ".sql",
544 ".tex",
545 ]
546 attached_to = models.ForeignKey(
547 SubmissionVersion, on_delete=models.CASCADE, related_name="additional_files"
548 )
550 def get_upload_path(self, filename: str) -> str:
551 return os.path.join(
552 "submissions",
553 str(self.attached_to.submission.pk),
554 "versions",
555 str(self.attached_to.number),
556 "additional",
557 filename,
558 )
560 def check_access_right(self, role, right_code: str) -> bool:
561 """
562 This model is only editable by user role with edit rights on the submission.
563 """
564 if right_code == "read":
565 return role.can_access_version(self.attached_to)
567 elif right_code in ["delete"]:
568 return role.can_edit_version(self.attached_to)
570 return False
572 def get_absolute_url(self) -> str:
573 """
574 Returns the URL to the model's file.
575 """
576 if not self.file.url:
577 return ""
578 file_identifier = self.get_file_identifier()
579 version_pk = self.attached_to.pk
580 submission_pk = self.attached_to.submission.pk
581 return reverse(
582 "mesh:serve_submission_version_additional_file",
583 kwargs={
584 "file_identifier": file_identifier,
585 "version_pk": version_pk,
586 "submission_pk": submission_pk,
587 },
588 )
591class SubmissionLog(ModelLog):
592 attached_to = models.ForeignKey(
593 Submission, on_delete=models.CASCADE, related_name="log_messages"
594 )
597class SubmissionAuthor(BaseChangeTrackingModel):
598 """
599 Model for a submission's author.
600 1 submission author is linked to 1 submission only.
601 """
603 submission = models.ForeignKey(
604 Submission,
605 on_delete=models.CASCADE,
606 null=False,
607 editable=False,
608 related_name="authors",
609 )
610 first_name = models.CharField(
611 verbose_name=_("First name"), max_length=150, blank=False, null=False
612 )
613 last_name = models.CharField(
614 verbose_name=_("Last name"), max_length=150, blank=False, null=False
615 )
616 email = models.EmailField(_("E-mail address"))
617 corresponding = models.BooleanField(
618 verbose_name=_("Corresponding contact"),
619 blank=False,
620 null=False,
621 default=False,
622 help_text=_(
623 "If checked, e-mails will be sent to advise of any progress in the submission process."
624 ),
625 )
627 # class Meta:
628 # constraints = [
629 # models.UniqueConstraint(
630 # fields=["submission", "email"],
631 # name="unique_author_email_per_submission",
632 # )
633 # ]
635 class Meta:
636 ordering = ["last_name"]
638 def __str__(self) -> str:
639 return f"{self.first_name} {self.last_name} ({self.email})"
641 def full_name(self) -> str:
642 return f"{self.first_name} {self.last_name}"