Coverage for src / mesh / models / roles / editor.py: 85%
121 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 collections.abc import Collection
2from itertools import chain
3from typing import ClassVar
5from django.db.models import Case, CharField, Exists, OuterRef, Q, QuerySet, Subquery, Value, When
6from django.utils import timezone
7from django.utils.translation import gettext as _
8from opentelemetry import trace
10from mesh.app_settings import BlindMode, app_settings
11from mesh.models.orm.editorial_models import EditorialDecision
12from mesh.models.orm.journal_models import JournalSection
13from mesh.models.orm.review_models import Review
14from mesh.models.orm.submission_models import (
15 Submission,
16 SubmissionLog,
17 SubmissionState,
18 SubmissionVersion,
19)
20from mesh.models.orm.user_models import User
22from ..submission_status import SubmissionStatus, SubmissionStatusData
23from .base_role import Role
25tracer = trace.get_tracer(__name__)
28class Editor(Role):
29 """
30 Editor role.
31 """
33 _CODE: ClassVar[str] = "editor"
34 _NAME: ClassVar[str] = _("Editor")
35 _ICON_CLASS: ClassVar[str] = "fa-user-tie"
36 _SUBMISSION_LIST_TITLE: ClassVar[str] = _("My assignments")
38 @tracer.start_as_current_span("Editor.__init__")
39 def __init__(self, user: User):
40 super().__init__(user)
41 if app_settings.BLIND_MODE == BlindMode.DOUBLE_BLIND:
42 # hide reviews when current user also has created the submission
43 self.reviews_queryset = Review.objects.exclude(
44 version__submission__created_by=self.user
45 ).all()
46 else:
47 self.reviews_queryset = Review.objects.all()
48 self.versions_queryset = SubmissionVersion.objects.filter(submitted=True)
50 all_children = self._get_all_journal_sections()
51 self.submissions_queryset = (
52 Submission.objects.all()
53 .annotate(
54 user_is_editor=Case(
55 When(
56 Q(editors__user=self.user) | Q(journal_section__in=all_children),
57 then=Value(True),
58 ),
59 default=Value(False),
60 ),
61 )
62 .filter(user_is_editor=True)
63 .exclude(created_by=self.user)
64 .exclude(state=SubmissionState.OPENED.value)
65 )
66 self.log_messages_queryset = SubmissionLog.objects.all().select_related("created_by")
68 def _get_is_active(self):
69 return (
70 JournalSection.objects.filter(editors__user=self.user).exists()
71 or Submission.objects.filter(editors__user=self.user).exists()
72 )
74 def _get_all_journal_sections(self):
75 journalsections = [perm.journal_section for perm in self.user.editor_sections.all()]
76 all_children = set[JournalSection](journalsections)
77 for section in journalsections:
78 section_children = section.all_children()
79 all_children.update(section_children)
80 return all_children
82 def get_journal_sections(self) -> list[JournalSection]:
83 """
84 Recursively gets ALL the submission journal_sections the user has access to.
85 That means all directly assigned journal_sections and all their children.
86 """
87 direct_journal_sections = JournalSection.objects.filter(editors__user=self.user)
88 if not direct_journal_sections.exists():
89 return []
91 base_journal_sections = chain.from_iterable(
92 JournalSection.objects.get_children_recursive(c) for c in direct_journal_sections
93 )
95 return [*direct_journal_sections, *base_journal_sections]
97 def get_submissions(self):
98 """
99 Editors have access to all submissions and associated data (TBD)
100 """
101 return self._annotate_submission_query(self.submissions_queryset)
103 def can_submit_submission(self, submission):
104 return False
106 def can_access_submission(self, submission) -> bool:
107 return submission.user_is_editor
109 def can_access_reviews(self, version) -> bool:
110 return self.can_access_submission(version.submission)
112 def can_access_review(self, submission, review) -> bool:
113 return self.can_access_submission(submission)
115 def can_access_review_file(self, file) -> bool:
116 return self.can_access_review(file.attached_to)
118 def can_access_review_details(self, submission, review) -> bool:
119 return submission.user_is_editor
121 def can_invite_reviewer(self, version) -> bool:
122 """
123 A reviewer can be invited only if the version is submitted by the author and
124 opened for review.
125 """
127 return version.submitted and version.review_open and version.submission.user_is_editor
129 def can_access_version(self, version: SubmissionVersion) -> bool:
130 """
131 Non-submitted versions are not available to anyone except the author.
132 """
133 return version.submitted and self.can_access_submission(version.submission)
135 def get_managed_users(self) -> QuerySet[User]:
136 """
137 Restrict to non-editor users.
139 TODO: Restrict actions to manageable_submissions ?
140 """
141 return (
142 User.objects.exclude(pk=self.user.pk)
143 .exclude(journal_manager=True)
144 .exclude(editor_sections__isnull=False)
145 .exclude(editor_submissions__isnull=False)
146 .exclude(is_superuser=True)
147 )
149 def can_impersonate(self) -> bool:
150 return True
152 def can_access_submission_log(self, submission: Submission) -> bool:
153 return self.can_access_submission(submission)
155 def can_create_editorial_decision(self, submission: Submission) -> bool:
156 return submission.user_is_editor
158 def can_edit_editorial_decision(self, decision: EditorialDecision) -> bool:
159 return decision.version.submission.user_is_editor
161 def can_start_review_process(self, submission: Submission) -> bool:
162 return submission.user_is_editor and submission.is_reviewable()
164 def can_assign_editor(self, submission: Submission) -> bool:
165 return submission.user_is_editor
167 def get_submission_status(self, submission: Submission) -> SubmissionStatusData:
168 """
169 For an editor+ role, the status highly depends on the submission state.
170 - Submission/Revisions submitted -> The editor must take action
171 - Revisions required -> Waiting for author to submit
172 - Submission accepted/declined -> Nothing to do
173 - Under review -> Tricky. Depends on the existing reviews (or not) state.
174 """
175 current_version = submission.get_current_version()
176 if not current_version:
177 return SubmissionStatusData(
178 submission=submission,
179 status=SubmissionStatus.ERROR,
180 description="Submission does not have a current version",
181 )
182 if not submission.user_is_editor or submission.is_draft:
183 return SubmissionStatusData(
184 submission=submission, status=SubmissionStatus.ERROR, description=""
185 )
187 if submission.state in [
188 SubmissionState.SUBMITTED.value,
189 SubmissionState.REVISION_SUBMITTED.value,
190 ]:
191 return SubmissionStatusData(
192 submission=submission,
193 status=SubmissionStatus.TODO,
194 description=_("Requires an editorial action."),
195 )
197 elif submission.state in [
198 SubmissionState.OPENED.value,
199 SubmissionState.REVISION_REQUESTED.value,
200 ]:
201 return SubmissionStatusData(
202 submission=submission,
203 status=SubmissionStatus.WAITING,
204 description=_("Waiting for a new version"),
205 )
207 elif submission.state in [
208 SubmissionState.ACCEPTED.value,
209 SubmissionState.REJECTED.value,
210 ]:
211 return SubmissionStatusData(
212 submission=submission,
213 status=SubmissionStatus.DONE,
214 description=_("Submission") + " " + submission.get_state_display(),
215 )
217 # Case under review
218 reviews = current_version.reviews.all()
219 if not reviews:
220 return SubmissionStatusData(
221 submission=submission,
222 status=SubmissionStatus.TODO,
223 description=_("No reviewers for the current round."),
224 # shortcut_actions=buttons,
225 )
227 # If some review requests are not answered and answer date is overdued
228 if any(r.is_response_overdue for r in reviews):
229 return SubmissionStatusData(
230 submission=submission,
231 status=SubmissionStatus.TODO,
232 description=_("At least 1 request answer is overdue."),
233 # shortcut_actions=buttons,
234 )
236 # If some reports are not submitted and report date is overdued
237 elif any(r.is_report_overdue for r in reviews):
238 return SubmissionStatusData(
239 submission=submission,
240 status=SubmissionStatus.TODO,
241 description=_("At least 1 report is overdue."),
242 # shortcut_actions=buttons,
243 )
245 # All reviews submitted
246 elif all(r.is_completed for r in reviews):
247 return SubmissionStatusData(
248 submission=submission,
249 status=SubmissionStatus.TODO,
250 description=_("All reviews have been declined or submitted."),
251 # shortcut_actions=buttons,
252 )
254 # Pending reviews, nothing to do ?
255 return SubmissionStatusData(
256 submission=submission,
257 status=SubmissionStatus.WAITING,
258 description=_("Waiting for review(s)."),
259 # shortcut_actions=buttons,
260 )
262 def can_filter_submissions(self) -> bool:
263 return True
265 def can_access_journal_sections(self) -> bool:
266 return True
268 def can_edit_review_file_right(self, review: Review, submission=None) -> bool:
269 # annotation is lost when accessing review.version.submission
270 # We either need to pass the annotated object as argument
271 # or re-fetch it...
272 if submission:
273 if review.version.submission != submission:
274 raise ValueError("Provided submission is not related to this review")
275 else:
276 submission = self.get_submissions().get(pk=review.version.submission.pk)
277 return submission.user_is_editor
279 def annotate_submission_status(self, qs):
280 now = timezone.now()
282 current_version = SubmissionVersion.objects.filter(
283 submission=OuterRef(OuterRef("pk"))
284 ).values("pk")[:1]
286 reviews = Review.objects.filter(version=Subquery(current_version))
288 qs = qs.annotate(
289 _has_reviews=Exists(reviews),
290 _has_response_overdue=Exists(
291 reviews.filter(accepted__isnull=True, date_response_due__lt=now)
292 ),
293 _has_report_overdue=Exists(
294 reviews.filter(accepted=True, submitted=False, date_review_due__lt=now)
295 ),
296 _has_pending_review=Exists(reviews.filter(submitted=False).exclude(accepted=False)),
297 )
298 return qs.annotate(
299 computed_status=Case(
300 When(
301 state__in=[
302 SubmissionState.SUBMITTED.value,
303 SubmissionState.REVISION_SUBMITTED.value,
304 ],
305 then=Value(SubmissionStatus.TODO.value),
306 ),
307 When(
308 state__in=[
309 SubmissionState.OPENED.value,
310 SubmissionState.REVISION_REQUESTED.value,
311 ],
312 then=Value(SubmissionStatus.WAITING.value),
313 ),
314 When(
315 state__in=[
316 SubmissionState.ACCEPTED.value,
317 SubmissionState.REJECTED.value,
318 ],
319 then=Value(SubmissionStatus.DONE.value),
320 ),
321 When(
322 Q(_has_reviews=True)
323 & Q(_has_response_overdue=False)
324 & Q(_has_report_overdue=False)
325 & Q(_has_pending_review=True),
326 then=Value(SubmissionStatus.WAITING.value),
327 ),
328 default=Value(SubmissionStatus.TODO.value),
329 output_field=CharField(),
330 )
331 )
334def get_section_editors(submission: Submission) -> Collection[User]:
335 """
336 Get the editors with access to the given submission from the EditorSectionRights.
337 """
338 # 1 - Get all journal_sections above the given submission
339 if not submission.journal_section:
340 return []
342 journal_sections = [
343 submission.journal_section,
344 *JournalSection.objects.get_parents_recursive(submission.journal_section),
345 ]
347 # 2 - Get all users with rights over one of the above journal_sections
348 return User.objects.filter(editor_sections__journal_section__in=journal_sections)