Coverage for src / mesh / models / roles / editor.py: 85%
123 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 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 can_create_discussion(self, submission: Submission) -> bool:
168 return self.can_access_submission(submission=submission)
170 def get_submission_status(self, submission: Submission) -> SubmissionStatusData:
171 """
172 For an editor+ role, the status highly depends on the submission state.
173 - Submission/Revisions submitted -> The editor must take action
174 - Revisions required -> Waiting for author to submit
175 - Submission accepted/declined -> Nothing to do
176 - Under review -> Tricky. Depends on the existing reviews (or not) state.
177 """
178 current_version = submission.get_current_version()
179 if not current_version:
180 return SubmissionStatusData(
181 submission=submission,
182 status=SubmissionStatus.ERROR,
183 description="Submission does not have a current version",
184 )
185 if not submission.user_is_editor or submission.is_draft:
186 return SubmissionStatusData(
187 submission=submission, status=SubmissionStatus.ERROR, description=""
188 )
190 if submission.state in [
191 SubmissionState.SUBMITTED.value,
192 SubmissionState.REVISION_SUBMITTED.value,
193 ]:
194 return SubmissionStatusData(
195 submission=submission,
196 status=SubmissionStatus.TODO,
197 description=_("Requires an editorial action."),
198 )
200 elif submission.state in [
201 SubmissionState.OPENED.value,
202 SubmissionState.REVISION_REQUESTED.value,
203 ]:
204 return SubmissionStatusData(
205 submission=submission,
206 status=SubmissionStatus.WAITING,
207 description=_("Waiting for a new version"),
208 )
210 elif submission.state in [
211 SubmissionState.ACCEPTED.value,
212 SubmissionState.REJECTED.value,
213 ]:
214 return SubmissionStatusData(
215 submission=submission,
216 status=SubmissionStatus.DONE,
217 description=_("Submission") + " " + submission.get_state_display(),
218 )
220 # Case under review
221 reviews = current_version.reviews.all()
222 if not reviews:
223 return SubmissionStatusData(
224 submission=submission,
225 status=SubmissionStatus.TODO,
226 description=_("No reviewers for the current round."),
227 # shortcut_actions=buttons,
228 )
230 # If some review requests are not answered and answer date is overdued
231 if any(r.is_response_overdue for r in reviews):
232 return SubmissionStatusData(
233 submission=submission,
234 status=SubmissionStatus.TODO,
235 description=_("At least 1 request answer is overdue."),
236 # shortcut_actions=buttons,
237 )
239 # If some reports are not submitted and report date is overdued
240 elif any(r.is_report_overdue for r in reviews):
241 return SubmissionStatusData(
242 submission=submission,
243 status=SubmissionStatus.TODO,
244 description=_("At least 1 report is overdue."),
245 # shortcut_actions=buttons,
246 )
248 # All reviews submitted
249 elif all(r.is_completed for r in reviews):
250 return SubmissionStatusData(
251 submission=submission,
252 status=SubmissionStatus.TODO,
253 description=_("All reviews have been declined or submitted."),
254 # shortcut_actions=buttons,
255 )
257 # Pending reviews, nothing to do ?
258 return SubmissionStatusData(
259 submission=submission,
260 status=SubmissionStatus.WAITING,
261 description=_("Waiting for review(s)."),
262 # shortcut_actions=buttons,
263 )
265 def can_filter_submissions(self) -> bool:
266 return True
268 def can_access_journal_sections(self) -> bool:
269 return True
271 def can_edit_review_file_right(self, review: Review, submission=None) -> bool:
272 # annotation is lost when accessing review.version.submission
273 # We either need to pass the annotated object as argument
274 # or re-fetch it...
275 if submission:
276 if review.version.submission != submission:
277 raise ValueError("Provided submission is not related to this review")
278 else:
279 submission = self.get_submissions().get(pk=review.version.submission.pk)
280 return submission.user_is_editor
282 def annotate_submission_status(self, qs):
283 now = timezone.now()
285 current_version = SubmissionVersion.objects.filter(
286 submission=OuterRef(OuterRef("pk"))
287 ).values("pk")[:1]
289 reviews = Review.objects.filter(version=Subquery(current_version))
291 qs = qs.annotate(
292 _has_reviews=Exists(reviews),
293 _has_response_overdue=Exists(
294 reviews.filter(accepted__isnull=True, date_response_due__lt=now)
295 ),
296 _has_report_overdue=Exists(
297 reviews.filter(accepted=True, submitted=False, date_review_due__lt=now)
298 ),
299 _has_pending_review=Exists(reviews.filter(submitted=False).exclude(accepted=False)),
300 )
301 return qs.annotate(
302 computed_status=Case(
303 When(
304 state__in=[
305 SubmissionState.SUBMITTED.value,
306 SubmissionState.REVISION_SUBMITTED.value,
307 ],
308 then=Value(SubmissionStatus.TODO.value),
309 ),
310 When(
311 state__in=[
312 SubmissionState.OPENED.value,
313 SubmissionState.REVISION_REQUESTED.value,
314 ],
315 then=Value(SubmissionStatus.WAITING.value),
316 ),
317 When(
318 state__in=[
319 SubmissionState.ACCEPTED.value,
320 SubmissionState.REJECTED.value,
321 ],
322 then=Value(SubmissionStatus.DONE.value),
323 ),
324 When(
325 Q(_has_reviews=True)
326 & Q(_has_response_overdue=False)
327 & Q(_has_report_overdue=False)
328 & Q(_has_pending_review=True),
329 then=Value(SubmissionStatus.WAITING.value),
330 ),
331 default=Value(SubmissionStatus.TODO.value),
332 output_field=CharField(),
333 )
334 )
337def get_section_editors(submission: Submission) -> Collection[User]:
338 """
339 Get the editors with access to the given submission from the EditorSectionRights.
340 """
341 # 1 - Get all journal_sections above the given submission
342 if not submission.journal_section:
343 return []
345 journal_sections = [
346 submission.journal_section,
347 *JournalSection.objects.get_parents_recursive(submission.journal_section),
348 ]
350 # 2 - Get all users with rights over one of the above journal_sections
351 return User.objects.filter(editor_sections__journal_section__in=journal_sections)