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