Coverage for src / mesh / models / orm / submission_models.py: 89%
223 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-22 07:20 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-10-22 07:20 +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 mesh.models.orm.editorial_models import EditorSubmissionRight
25 from mesh.models.orm.review_models import Review
27 from .editorial_models import EditorialDecision
29logger = logging.getLogger(__name__)
32@unique
33class SubmissionState(Enum):
34 """
35 Enum of the submission statees.
36 Warning: the value of each state is used in CSS.
37 """
39 # OPENED Submission created by the author but not submitted for
40 # review yet.
41 # Preferably use the boolean `is_draft` when checking for OPENED state
42 OPENED = "opened"
43 # SUBMITTED Submission submitted for review. Waiting for editor to
44 # take action (accept/reject/open review round)
45 SUBMITTED = "submitted"
46 # ON REVIEW Open round OR no waiting for editor to take action
47 # (accept/reject/open review round)
48 ON_REVIEW = "review"
49 # REVISION REQUESTED Editor requested revisions.
50 # Waiting for author to submit new version
51 REVISION_REQUESTED = "rev_requested"
52 # REVISION SUBMITTED Author submitted revision. Waiting for editor to
53 # take action (accept/reject/open review round)
54 REVISION_SUBMITTED = "rev_submited"
55 # ACCEPTED Editor accepted the submission as it is.
56 # Begin Copyediting process.
57 ACCEPTED = "accepted"
58 # REJECTED Editor rejected the submission. The submission is closed.
59 REJECTED = "rejected"
62SUBMISSION_STATE_CHOICES = [
63 (SubmissionState.OPENED.value, _("Draft")),
64 (SubmissionState.SUBMITTED.value, _("Submitted")),
65 (SubmissionState.ON_REVIEW.value, _("Under review")),
66 (SubmissionState.REVISION_REQUESTED.value, _("Revision requested")),
67 (SubmissionState.REVISION_SUBMITTED.value, _("Revision submitted")),
68 (SubmissionState.ACCEPTED.value, _("Accepted")),
69 (SubmissionState.REJECTED.value, _("Rejected")),
70]
72SUBMISSION_STATE_EDITOR_CHOICES = [
73 (SubmissionState.REVISION_REQUESTED.value, _("Request revisions")),
74 (SubmissionState.ACCEPTED.value, _("Accept submission")),
75 (SubmissionState.REJECTED.value, _("Reject submission")),
76]
79# Typing with the generic here is very important to get correct type hints
80# when using any SubmissionManager methods.
81class SubmissionQuerySet(models.QuerySet["Submission"]):
82 def annotate_last_activity(self) -> Self:
83 """
84 Annotate the `date_last_activity` to the queryset = date of the last
85 significant log entry.
86 """
87 return self.annotate(
88 log_significant=FilteredRelation(
89 "log_messages", condition=Q(log_messages__significant=True)
90 )
91 ).annotate(date_last_activity=Max("log_significant__date_created"))
93 def prefetch_data(self) -> Self:
94 """
95 Shortcut function to prefetch all related MtoM data.
96 """
97 return self.prefetch_related(
98 "editors",
99 "editors__user",
100 )
102 def select_data(self) -> Self:
103 """
104 Shortcut function to select all related FK.
105 """
106 return self.select_related("created_by", "last_modified_by", "journal_section")
109class PrefetchedSubmissionManager(models.Manager["Submission"]):
110 def get_queryset(self):
111 return (
112 SubmissionQuerySet(self.model, using=self._db)
113 .annotate_last_activity()
114 .prefetch_data()
115 .select_data()
116 )
119class Submission(BaseChangeTrackingModel):
120 # Change the default `created_by` with a protected on_delete behavior.
121 created_by = models.ForeignKey(
122 User,
123 verbose_name=_("Created by"),
124 on_delete=models.PROTECT,
125 null=False,
126 help_text=_("Automatically filled on save."),
127 editable=False,
128 related_name="+",
129 )
130 name = models.TextField(verbose_name=_("Title"))
131 abstract = models.TextField(verbose_name=_("Abstract"))
132 journal_section = models.ForeignKey["JournalSection"](
133 "JournalSection",
134 verbose_name=_("Section (Optional)"),
135 on_delete=models.SET_NULL,
136 related_name="submissions",
137 null=True,
138 )
139 state = models.CharField(
140 verbose_name=_("state"),
141 max_length=64,
142 choices=SUBMISSION_STATE_CHOICES,
143 default=SubmissionState.OPENED.value,
144 )
145 date_first_version = models.DateTimeField(
146 verbose_name=_("Date of the first submitted version"), null=True, editable=False
147 )
148 author_agreement = models.BooleanField(
149 verbose_name=_("Agreement"),
150 help_text=_("I hereby declare that I have read blablabla and I consent to the terms"),
151 )
152 notes = models.TextField(verbose_name=_("Notes"), default="") # Post-it notes for the editors
154 ojs_id = models.PositiveIntegerField(null=True)
156 objects = PrefetchedSubmissionManager()
158 # RelatedObjects
159 versions: Manager[SubmissionVersion]
160 editors: Manager[EditorSubmissionRight]
161 authors: Manager[SubmissionAuthor]
162 log_messages: Manager[SubmissionLog]
164 # Annotated properties
165 user_is_editor: bool
166 "Annotated True when the current user has editing rights for this submission"
167 authors_string: str
168 "Annotated"
169 created_by_censored: str
170 "Annotated"
171 authors_censored: list[SubmissionAuthor]
172 "Annotated"
173 versions_censored: list[SubmissionVersion]
174 "Annotated"
175 log_messages_censored: list[SubmissionLog]
176 "Annotated"
178 # class Meta:
179 # constraints = [
180 # models.UniqueConstraint(
181 # fields=["created_by", "name"], name="unique_submission_name_per_user"
182 # )
183 # ]
185 def __str__(self) -> str:
186 return f"{self.created_by} - {self.name}"
188 def get_current_version(self):
189 """
190 The current (latest) `SubmissionVersion` of the Submission.
191 """
192 if not self.versions_censored:
193 return None
194 return self.versions_censored[0]
196 @property
197 def date_submission(self) -> datetime | None:
198 """
199 Submission date of the submission.
200 It is the date of the first version completion or the submisison's creation date
201 if no versions are submitted yet.
202 """
203 return self.date_first_version or self.date_created
205 @property
206 def state_order(self) -> int:
207 """
208 Returns the integer mapped to the submission state for ordering purpose.
209 """
210 return [s[0] for s in SUBMISSION_STATE_CHOICES].index(self.state)
212 @property
213 def all_assigned_editors(self) -> list[User]:
214 return sorted(
215 (e.user for e in self.editors.all()),
216 key=lambda u: u.first_name,
217 )
219 def is_submittable(self) -> bool:
220 """
221 Whether the submission is submittable.
222 It checks that the required data is correct.
223 """
224 if self.author_agreement is False:
225 logger.debug(f"Submission {self.pk} not submittable : author_agreement is False")
226 return False
227 if self.state not in [
228 SubmissionState.OPENED.value,
229 SubmissionState.REVISION_REQUESTED.value,
230 ]:
231 logger.debug(f"Submission {self.pk} not submittable : state is {self.state}")
232 return False
233 if len(self.versions.all()) == 0:
234 logger.debug(
235 f"Submission {self.pk} not submittable : submission has no versions is None"
236 )
237 return False
238 if self.versions.all()[0].submitted:
239 logger.debug(
240 f"Submission {self.pk} not submittable : current_version is already submitted"
241 )
242 return False
243 if not hasattr(self.versions.all()[0], "main_file"):
244 logger.debug(
245 f"Submission {self.pk} not submittable : current_version does not have a main file"
246 )
247 return False
248 if self.authors.count() == 0:
249 logger.debug(f"Submission {self.pk} not submittable : no authors found")
250 return False
251 return True
253 @property
254 def is_draft(self) -> bool:
255 return self.state == SubmissionState.OPENED.value
257 def submit(self, user, *args, date_submitted=None) -> None:
258 """
259 Submit the submission's current version:
260 - Set the submission's current version to `submitted=True`
261 - Change the submission state to "submitted" or "revisions_submitted"
262 according to the current state.
263 - Add an entry to the submission log
265 Raise an SubmissionStateError is the submission is not submittable.
267 Params:
268 - `request` The enclosing HTTP request. It's used to derive the
269 current user and potential impersonate data.
270 """
271 if not self.is_submittable():
272 raise SubmissionStateError(_("Trying to submit an non-submittable submission."))
274 if date_submitted is None:
275 date_submitted = timezone.now()
277 code = self.state
279 self.state = (
280 SubmissionState.SUBMITTED.value
281 if self.is_draft
282 else SubmissionState.REVISION_SUBMITTED.value
283 )
284 self.save()
286 version = self.versions.first()
287 if version is None:
288 raise ValueError("Cannot submit: submission does not have a current version")
289 version.submitted = True
291 version.date_submitted = date_submitted
292 version._user = user
293 version.review_open = True
294 version.save()
296 self.state = SubmissionState.ON_REVIEW.value
297 self.save()
299 SubmissionLog.add_message(
300 self,
301 content=_("Submission of version") + f" #{version.number}",
302 content_en=f"Submission of version #{version.number}",
303 user=user,
304 significant=True,
305 code=code,
306 date=date_submitted,
307 html_class="success-log",
308 round_number=version.number,
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 html_class="info-log",
410 # date=date,
411 )
413 def get_absolute_url(self):
414 return reverse("mesh:submission_details", kwargs={"submission_pk": self.pk})
417class SubmissionVersion(BaseSubmittableModel, BaseModelWithFiles):
418 """
419 Version of a submission. Only contains files (main + additional files).
420 """
422 file_fields_required = ["main_file"]
423 file_fields_deletable = ["additional_files"]
425 submission = models.ForeignKey(
426 Submission,
427 on_delete=models.CASCADE,
428 null=False,
429 editable=False,
430 related_name="versions",
431 )
432 number = models.IntegerField(
433 help_text=_("Automatically filled on save"),
434 null=False,
435 editable=False,
436 )
437 # reviews: "Manager[Review]"
438 # main_file: "SubmissionMainFile"
439 # additional_files: "Manager[SubmissionAdditionalFile]"
440 reviews: Manager[Review]
441 main_file: SubmissionMainFile
442 additional_files: Manager[SubmissionAdditionalFile]
443 # Boolean used to track whether the review process is still open for the submission
444 # version. A new version is not opened for review by default. Changing its value
445 # requires an editorial action.
446 review_open = models.BooleanField(
447 verbose_name=_("Version opened for review"), default=False, editable=False
448 )
450 # RelatedManagers
451 editorial_decision: EditorialDecision | None
452 # Annotated properties
453 created_by_censored: str | User
454 reviews_censored: list[Review]
456 class Meta: # type: ignore
457 constraints = [
458 models.UniqueConstraint(
459 fields=["submission", "number"], name="unique_submission_version_number"
460 ),
461 models.UniqueConstraint(
462 fields=["submission"],
463 condition=Q(review_open=True),
464 name="unique_review_open_submission_version",
465 ),
466 ]
467 ordering = ["-number"]
469 def save(self, *args, **kwargs) -> None:
470 """
471 Fill the version's number for a new instance.
472 """
473 if self._state.adding:
474 current_version = self.submission.versions.first()
475 if current_version:
476 self.number = current_version.number + 1
477 else:
478 self.number = 1
480 return super().save(*args, **kwargs)
483class SubmissionMainFile(BaseFileWrapperModel[SubmissionVersion]):
484 file_extensions = [".pdf"]
486 attached_to = models.OneToOneField(
487 SubmissionVersion,
488 primary_key=True,
489 on_delete=models.CASCADE,
490 related_name="main_file",
491 )
493 def get_upload_path(self, filename: str) -> str:
494 return os.path.join(
495 "submissions",
496 str(self.attached_to.submission.pk),
497 "versions",
498 str(self.attached_to.number),
499 filename,
500 )
502 def check_access_right(self, role, right_code: str) -> bool:
503 """
504 This model is only editable by user role with edit rights on the submission.
505 """
506 if right_code == "read":
507 return role.can_access_version(self.attached_to)
509 return False
511 def get_absolute_url(self) -> str:
512 """
513 Returns the URL to the model's file.
514 """
515 if not self.file.url:
516 return ""
517 file_identifier = self.get_file_identifier()
518 version_pk = self.attached_to.pk
519 submission_pk = self.attached_to.submission.pk
520 return reverse(
521 "mesh:serve_submission_version_main_file",
522 kwargs={
523 "file_identifier": file_identifier,
524 "version_pk": version_pk,
525 "submission_pk": submission_pk,
526 },
527 )
530class SubmissionAdditionalFile(BaseFileWrapperModel[SubmissionVersion]):
531 file_extensions = [
532 ".pdf",
533 ".docx",
534 ".odt",
535 ".py",
536 ".jpg",
537 ".png",
538 ".ipynb",
539 ".sql",
540 ".tex",
541 ]
542 attached_to = models.ForeignKey(
543 SubmissionVersion, on_delete=models.CASCADE, related_name="additional_files"
544 )
546 def get_upload_path(self, filename: str) -> str:
547 return os.path.join(
548 "submissions",
549 str(self.attached_to.submission.pk),
550 "versions",
551 str(self.attached_to.number),
552 "additional",
553 filename,
554 )
556 def check_access_right(self, role, right_code: str) -> bool:
557 """
558 This model is only editable by user role with edit rights on the submission.
559 """
560 if right_code == "read":
561 return role.can_access_version(self.attached_to)
563 elif right_code in ["delete"]:
564 return role.can_edit_version(self.attached_to)
566 return False
568 def get_absolute_url(self) -> str:
569 """
570 Returns the URL to the model's file.
571 """
572 if not self.file.url:
573 return ""
574 file_identifier = self.get_file_identifier()
575 version_pk = self.attached_to.pk
576 submission_pk = self.attached_to.submission.pk
577 return reverse(
578 "mesh:serve_submission_version_additional_file",
579 kwargs={
580 "file_identifier": file_identifier,
581 "version_pk": version_pk,
582 "submission_pk": submission_pk,
583 },
584 )
587class SubmissionLog(ModelLog):
588 attached_to = models.ForeignKey(
589 Submission, on_delete=models.CASCADE, related_name="log_messages"
590 )
591 round_number = models.TextField(blank=True, null=True)
594class SubmissionAuthor(BaseChangeTrackingModel):
595 """
596 Model for a submission's author.
597 1 submission author is linked to 1 submission only.
598 """
600 submission = models.ForeignKey(
601 Submission,
602 on_delete=models.CASCADE,
603 null=False,
604 editable=False,
605 related_name="authors",
606 )
607 first_name = models.CharField(
608 verbose_name=_("First name"), max_length=150, blank=False, null=False
609 )
610 last_name = models.CharField(
611 verbose_name=_("Last name"), max_length=150, blank=False, null=False
612 )
613 email = models.EmailField(_("E-mail address"))
614 corresponding = models.BooleanField(
615 verbose_name=_("Corresponding contact"),
616 blank=False,
617 null=False,
618 default=False,
619 help_text=_(
620 "If checked, e-mails will be sent to advise of any progress in the submission process."
621 ),
622 )
624 # class Meta:
625 # constraints = [
626 # models.UniqueConstraint(
627 # fields=["submission", "email"],
628 # name="unique_author_email_per_submission",
629 # )
630 # ]
632 class Meta:
633 ordering = ["last_name"]
635 def __str__(self) -> str:
636 return f"{self.first_name} {self.last_name} ({self.email})"
638 def full_name(self) -> str:
639 return f"{self.first_name} {self.last_name}"