Coverage for src / mesh / models / editorial_models.py: 61%
59 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-23 15:44 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-23 15:44 +0000
1from __future__ import annotations
3import os
4import re
5from typing import TYPE_CHECKING
7from django.conf import settings
8from django.db import models
9from django.utils.translation import gettext_lazy as _
11from mesh.model.file_helpers import FILE_DELETE_DEFAULT_ERROR
13from .base_models import BaseChangeTrackingModel
14from .file_models import BaseFileWrapperModel, BaseModelWithFiles
15from .journal_models import JournalSection
16from .submission_models import (
17 SUBMISSION_STATE_EDITOR_CHOICES,
18 Submission,
19 SubmissionState,
20 SubmissionVersion,
21)
23if TYPE_CHECKING:
24 from typing import Callable
26 from mesh.model.roles.role_handler import RoleHandler
29class EditorSectionRight(BaseChangeTrackingModel):
30 """
31 ManyToMany table between User & JournalSection.
32 Used to track Editor access/right to a submission journal_section (section).
33 """
35 user = models.ForeignKey(
36 settings.AUTH_USER_MODEL,
37 on_delete=models.CASCADE,
38 related_name="editor_sections",
39 )
40 journal_section = models.ForeignKey(
41 JournalSection,
42 verbose_name=_("Journal Section"),
43 on_delete=models.CASCADE,
44 related_name="editors",
45 )
48class EditorSubmissionRight(BaseModelWithFiles):
49 """
50 ManyToMany table representing an editor right to a submission, ie. an editor
51 "assigned" to a submission.
52 """
54 user = models.ForeignKey(
55 settings.AUTH_USER_MODEL,
56 on_delete=models.CASCADE,
57 related_name="editor_submissions",
58 )
59 submission = models.ForeignKey(
60 Submission,
61 verbose_name=_("Submission"),
62 on_delete=models.CASCADE,
63 related_name="editors",
64 )
66 class Meta:
67 constraints = [
68 models.UniqueConstraint(
69 fields=["user", "submission"], name="unique_submission_per_editor"
70 )
71 ]
74class EditorialDecision(BaseModelWithFiles):
75 """
76 Model representing an editorial decision. Edit restricted to editors and
77 above rights a priori.
78 """
80 file_fields_deletable = ["additional_files"]
81 version = models.OneToOneField(
82 SubmissionVersion,
83 on_delete=models.CASCADE,
84 editable=False, # the field will not be displayed in the admin or any other ModelForm
85 related_name="editorial_decision",
86 )
87 value = models.CharField(
88 verbose_name=_("Decision"),
89 max_length=64,
90 choices=SUBMISSION_STATE_EDITOR_CHOICES,
91 null=False,
92 blank=False,
93 )
94 comment = models.TextField(verbose_name=_("Description"), null=True)
96 get_value_display: "Callable[[], str]"
97 "Generated by django https://docs.djangoproject.com/en/4.2/ref/models/instances/#django.db.models.Model.get_FOO_display"
99 def can_delete_file(self, file_field: str) -> tuple[bool, str]:
100 """
101 An additional file cannot be deleted if there's only one and the comment
102 is empty.
103 """
104 if not super().can_delete_file(file_field):
105 return False, FILE_DELETE_DEFAULT_ERROR
107 if (
108 file_field == "additional_files"
109 and not self.comment
110 and len(self.additional_files.all()) < 2
111 ):
112 return False, _(
113 "The decision requires either a file or a description. If you want to change the file, add the new one then delete the old one."
114 )
116 return True, ""
118 def get_decision_display(self):
119 if self.value == SubmissionState.ACCEPTED.value:
120 return _("Accepted")
121 elif self.value == SubmissionState.REJECTED.value:
122 return _("Rejected")
123 elif self.value == SubmissionState.REVISION_REQUESTED.value:
124 return _("Revision requested")
125 else:
126 return "Uknown"
129class EditorialDecisionFile(BaseFileWrapperModel[EditorialDecision]):
130 file_extensions = [".pdf"]
131 attached_to = models.ForeignKey(
132 EditorialDecision, on_delete=models.CASCADE, related_name="additional_files"
133 )
135 def get_upload_path(self, filename: str) -> str:
136 return os.path.join(
137 "submissions",
138 str(self.attached_to.version.submission.pk),
139 "editor_decisions",
140 str(self.attached_to.pk),
141 filename,
142 )
144 @classmethod
145 def reverse_file_path(cls, file_path: str) -> EditorialDecisionFile | None:
146 regex = "/".join(
147 [
148 "submissions",
149 r"(?P<submission_pk>[0-9]+)",
150 "editor_decisions",
151 r"(?P<decision_pk>[0-9]+)",
152 r"(?P<file_name>[^\/]+)$",
153 ]
154 )
155 match = re.match(re.compile(regex), file_path)
156 if not match:
157 return None
159 return cls._default_manager.filter(file=file_path).first()
161 def check_access_right(self, role_handler: RoleHandler, right_code: str) -> bool:
162 if right_code == "read":
163 return role_handler.check_global_rights(
164 "can_access_submission", self.attached_to.version.submission
165 )
167 elif right_code in ["delete"]:
168 return role_handler.check_rights("can_edit_editorial_decision", self.attached_to)
170 return False