Coverage for src / mesh / views / views_submission.py: 51%
262 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-27 14:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-08-27 14:14 +0000
1from collections.abc import Iterable
2from typing import TYPE_CHECKING, Any
4from django.contrib.auth.mixins import LoginRequiredMixin
5from django.core.exceptions import PermissionDenied
6from django.db.models import Max
7from django.db.models.functions import Coalesce
8from django.http import Http404, HttpResponse, JsonResponse
9from django.shortcuts import render
10from django.template.loader import render_to_string
11from django.urls import reverse
12from django.utils.decorators import method_decorator
13from django.utils.translation import gettext_lazy as _
14from django.views import View
15from django.views.decorators.csrf import csrf_exempt
16from django.views.generic import DetailView, TemplateView
17from opentelemetry import trace
18from ptf.url_utils import format_url_with_params
20from mesh.app_settings import app_settings
21from mesh.crud.comments import submission_roles
22from mesh.crud.discussion import role_icon
23from mesh.models.filters import FieldGetter, Filter, FilterSet
24from mesh.models.orm.inbox_model import Inbox
25from mesh.models.orm.journal_models import JournalSection
26from mesh.models.orm.submission_models import Submission, SubmissionState
27from mesh.views.components.breadcrumb import get_base_breadcrumb, get_submission_breadcrumb
28from mesh.views.components.ckeditor_config import sanitize_html_input
29from mesh.views.components.icon import Icon
30from mesh.views.components.review_summary import CountWithTotal
31from mesh.views.components.submission_list import (
32 SubmissionListEnum,
33 get_submission_list_config,
34)
35from mesh.views.components.submission_tooltip import build_submission_tooltip
36from mesh.views.forms.discussion_forms import DiscussionForm
37from mesh.views.utils import group_by
38from mesh.views.viewmodel.submission_proxy import BuildSubmissionProxyVisitor
39from mesh.views.views_base import MeshObjectMixin
41if TYPE_CHECKING:
42 from django.http import HttpRequest
44 from mesh.models.roles.base_role import Role
45 from mesh.views.components.submission_list import SubmissionListConfig
47tracer = trace.get_tracer(__name__)
50def get_submission_message_if_no_actions(submission, role: "Role"):
51 """
52 An author can not do anything if the submission is under review.
53 Instead, we display a "thank you" message
54 """
56 html_message = ""
57 if role.show_no_actions_message(submission):
58 contact_email = app_settings.JOURNAL_EMAIL_CONTACT
59 html_message = f"""
60<p>Thank you for submitting your article.</p>
61We will review your submission and get back to you. You can contact us at {contact_email}"""
63 return html_message
66class SubmissionDetailsView(LoginRequiredMixin, MeshObjectMixin, DetailView):
67 """
68 View for a single submission.
70 The context data is populated with all the available actions to the user
71 regarding the submission.
72 """
74 template_name = "mesh/submission/submission_details.html"
76 def setup(self, request, *args, **kwargs):
77 super().setup(request, *args, **kwargs)
79 @tracer.start_as_current_span("SubmissionDetailsView.get_object")
80 def get_object(self, queryset=None):
81 ojsid = self.kwargs.get("ojsid", None)
82 if ojsid:
83 if queryset is None:
84 queryset = self.get_queryset()
85 try:
86 return queryset.get(ojs_id=ojsid)
87 except queryset.model.DoesNotExist:
88 raise Http404(
89 _("No %(verbose_name)s found matching the query")
90 % {"verbose_name": queryset.model._meta.verbose_name}
91 )
92 return self.get_submission()
94 # def restrict_dispatch(self, request: "HttpRequest", *args, **kwargs):
95 # # TODO: Should we prefetch all data for an unique instance ?
96 # # It would result in the same amount of queries but, with prefetch, they would
97 # # all be made at the same time.
98 # self.submission = get_object_or_404(Submission, pk=kwargs["pk"])
99 # return not request.current_role.can_access_submission(self.submission)
101 def get_context_data(self, object: Submission, **kwargs) -> dict[str, Any]:
102 context = super().get_context_data(**kwargs)
103 # BaseDetailView already calls get_object in the get method
104 submission = object
105 builder = BuildSubmissionProxyVisitor(self.request.current_role)
107 context["submission_proxy"] = builder.visit(
108 submission,
109 display_as_btn=False,
110 reload_page=True,
111 shortlist=False,
112 )
114 # editorial_decisions = (
115 # self.submission.editorial_decisions.all().order_by(
116 # "-date_created"
117 # )
118 # )
119 #
120 # context["editorial_decisions"] = editorial_decisions
122 # review_form = StartReviewProcessForm(initial={"process": True})
123 # context["action_lists"] = build_submission_actions(
124 # self.submission, self.role_handler, review_form
125 # )
126 context["message_if_no_actions"] = get_submission_message_if_no_actions(
127 submission, self.request.current_role
128 )
129 context["COMMENTS_VIEWS_ARTICLE_COMMENTS"] = True
131 # discussions = list(submission.discussions.filter(members=self.request.user))
132 discussions = list(
133 submission.discussions.filter(members=self.request.user)
134 .annotate(
135 last_comment_date=Coalesce(Max("comments__date_last_modified"), "created_at")
136 )
137 .order_by("-last_comment_date")
138 )
139 user_inbox, _ = Inbox.objects.get_or_create(user=self.request.user)
140 context["discussions"] = [
141 {
142 "discussion": discussion,
143 "in_inbox": user_inbox.comments.filter(discussion=discussion).count(),
144 "role": discussion.type,
145 "icon": Icon(
146 icon_class=role_icon(discussion.type),
147 icon_attrs={"data-tooltip": [f"{discussion.type} discussion"]},
148 ),
149 }
150 for discussion in discussions
151 ]
152 user_submission_roles = submission_roles(self.request.user, submission)
153 if "editor" in user_submission_roles:
154 context["user_can_create_discussion"] = True
155 context["discussion_form"] = DiscussionForm(submission=submission)
156 return context
159def submission_list_filters() -> FilterSet:
160 """
161 Instantiate a fresh FilterSet for the submission list view.
162 """
163 return FilterSet(
164 id="submission-filters",
165 name="Filters",
166 filters=[
167 Filter(id="created_by", name=_("Author"), type="model"),
168 Filter(
169 id="state",
170 name=_("State"),
171 type="string",
172 name_getter=FieldGetter(attr="get_state_display", callable=True),
173 ),
174 Filter(id="journal_section", name=_("Section"), type="model"),
175 Filter(id="all_assigned_editors", name=_("Assigned editors"), type="model"),
176 ],
177 )
180def group_submissions_per_status(submissions: Iterable[Submission], role: "Role"):
181 return group_by(
182 submissions,
183 lambda s: role.get_submission_status(s).status,
184 )
187def group_submissions_per_state(submissions: Iterable[Submission], role: "Role"):
188 return group_by(
189 submissions,
190 lambda s: SubmissionState(s.state),
191 )
194def one_group_submissions(submissions: Iterable[Submission], role: "Role"):
195 return {SubmissionListEnum.ALL: submissions}
198@tracer.start_as_current_span("prepare_submissions_lists")
199def prepare_submissions_lists(
200 role: "Role",
201 request: "HttpRequest",
202 list_config_ftor=get_submission_list_config,
203 group_submissions_ftor=group_submissions_per_status,
204) -> dict[str, Any]:
205 """
206 Retrieve, group and filter the user's submissions to show according to:
207 - the user's role submission list config
208 - the applied filters (contained in the HTTP request)
210 Params:
211 - `role_handler` The role handler
212 - `request` The underlying HTTP request
213 - `config_key` The right function to be called to get the SubmissionListConfig(s)
214 to be used. This config defines which submissions to display along
215 with filtering behavior, etc.
216 """
217 context: dict[str, Any] = {}
218 with tracer.start_as_current_span("prepare_submissions_lists.evaluate_queryset"):
219 submissions_qs = list(role.get_submissions())
221 all_submissions = []
222 for s in submissions_qs:
223 s.status = role.get_submission_status(s)
224 all_submissions.append(s)
226 # The submissions are displayed in different tables grouped by status. Precise
227 # behavior is configured per role with SubmissionListConfig objects.
228 grouped_submissions = group_submissions_ftor(all_submissions, role)
230 submission_list_configs: list[SubmissionListConfig] = list_config_ftor()
231 for config in submission_list_configs:
232 config.all_submissions = grouped_submissions.get(config.key, [])
234 builder = BuildSubmissionProxyVisitor(role)
236 # Handle the filters per submission list
237 if role.can_filter_submissions():
238 # Init filters
239 filters = submission_list_filters()
240 filters.init_filters(
241 request.GET,
242 *(config.all_submissions for config in submission_list_configs if config.in_filters),
243 )
244 context["filters"] = filters
245 # Filter all submission lists
246 for config in submission_list_configs:
247 if not config.display:
248 continue
249 if not config.in_filters:
250 config.submissions = [
251 builder.visit(s, row_id=f"{config.id}-row-{counter}")
252 for counter, s in enumerate(config.all_submissions)
253 ]
254 continue
255 config.submissions = [
256 builder.visit(s, row_id=f"{config.id}-row-{counter}")
257 for counter, s in enumerate(filters.filter(config.all_submissions))
258 ]
260 else:
261 for config in submission_list_configs:
262 if not config.display:
263 continue
264 config.submissions = [
265 role.accept(builder, s, row_id=f"{config.id}-row-{counter}")
266 for counter, s in enumerate(config.all_submissions)
267 ]
269 context["submissions_config"] = submission_list_configs
270 context["all_submissions_count"] = sum(
271 len(config.all_submissions) for config in submission_list_configs
272 )
273 context["submission_count"] = CountWithTotal(
274 value=sum(
275 len(config.submissions)
276 for config in submission_list_configs
277 if config.display and config.in_filters
278 ),
279 total=sum(
280 len(config.all_submissions)
281 for config in submission_list_configs
282 if config.display and config.in_filters
283 ),
284 )
286 return context
289TAB_TABLES = {
290 "status": [
291 ("todo", _("Requires action")),
292 ("waiting", _("Waiting")),
293 ("done", _("Closed")),
294 ],
295 "state": [
296 ("submitted", _("Submitted")),
297 ("on_review", _("Under review")),
298 ("revision_requested", _("Revision requested")),
299 ("accepted", _("Accepted")),
300 ("rejected", _("Rejected")),
301 ],
302 "all": [
303 ("all", _("All submissions")),
304 ],
305}
308class SubmissionListView(LoginRequiredMixin, TemplateView):
309 template_name = "mesh/submission/submission_list_page.html"
311 def get_active_tab(self):
312 return self.kwargs.get("tab") or self.request.GET.get("tab", "status")
314 @tracer.start_as_current_span("SubmissionListView.get_context_data")
315 def get_context_data(self, **kwargs):
316 context = super().get_context_data(**kwargs)
317 active_tab = self.get_active_tab()
318 tables = TAB_TABLES.get(active_tab, TAB_TABLES["status"])
319 submissions_count = self._count_submissions(self.request.current_role)
320 context["submissions_count"] = submissions_count
321 context["active_tab"] = active_tab
322 context["tables"] = [
323 {
324 "key": key,
325 "title": title,
326 "api_url": reverse("mesh:submission_table_api"),
327 }
328 for key, title in tables
329 ]
330 context["with_journal_sections"] = JournalSection.objects.all_journal_sections().exists()
332 breadcrumb = get_base_breadcrumb()
333 breadcrumb.add_item(title=_("My submissions"), url=reverse("mesh:submission_list"))
334 context["breadcrumb"] = breadcrumb
336 return context
338 def _count_submissions(self, role):
339 qs = role.get_submissions()
340 submissions_count = qs.count()
341 return submissions_count
344class SubmissionTableAPIView(LoginRequiredMixin, View):
345 """
346 Server-side DataBles endpoint
348 Query params Datables :
349 draw :
350 start
351 length
352 order[0][column] : to order column index
353 order[0][dir] : "asc" or "desc"
354 """
356 ORDERABLE_COLUMNS = {
357 1: "name",
358 2: "state",
359 3: "date_last_activity",
360 4: "date_created",
361 }
363 @tracer.start_as_current_span("SubmissionTableAPIView.get")
364 def get(self, request):
365 role = request.current_role
366 draw = request.GET.get("draw", 1)
367 start = int(request.GET.get("start", 0))
368 length = int(request.GET.get("length", 10))
369 table_key = request.GET.get("table_key", "all")
371 qs = role.get_submissions()
372 qs = self._apply_table_key_filter(qs, table_key, role)
373 qs = self._apply_filters(qs, request.GET)
374 submissions_count = qs.count()
376 qs = self._apply_ordering(qs, request.GET)
378 submission_list = list(qs[start : start + length])
380 builder = BuildSubmissionProxyVisitor(role)
381 rows_data = []
382 for counter, submission in enumerate(submission_list):
383 row_id = f"table-row-{submission.pk}"
384 submission_proxy = builder.visit(
385 submission, row_id=row_id
386 ) # todo : check if row_id needed
387 submission_context = {
388 "submission_proxy": submission_proxy,
389 "request": request,
390 "with_journal_sections": False,
391 "active_tab": "all",
392 }
393 data = {
394 "DT_RowId": row_id,
395 "DT_RowAttr": {
396 "class": "mesh-dt-row",
397 },
398 "view": render_to_string(
399 "mesh/submission/row/col_view.html", submission_context, request=request
400 ),
401 "title": render_to_string(
402 "mesh/submission/row/col_title.html", submission_context, request=request
403 ),
404 "state": render_to_string(
405 "mesh/submission/row/col_state.html", submission_context, request=request
406 ),
407 "last_activity": str(int(submission_proxy.date_last_activity.timestamp()))
408 if submission_proxy.date_last_activity
409 else "0",
410 "date_submission": str(int(submission_proxy.date_submission.timestamp()))
411 if submission_proxy.date_submission
412 else "0",
413 "actions": render_to_string(
414 "mesh/submission/row/col_actions.html", submission_context, request=request
415 ),
416 }
417 rows_data.append(data)
419 return JsonResponse(
420 {
421 "draw": draw,
422 "recordsTotal": submissions_count,
423 "recordsFiltered": submissions_count,
424 "data": rows_data,
425 }
426 )
428 @tracer.start_as_current_span("SubmissionTableAPIView._apply_ordering")
429 def _apply_ordering(self, qs, params):
430 col_index = int(params.get("order[0][column]", 3))
431 direction = params.get("order[0][dir]", "asc")
432 field = self.ORDERABLE_COLUMNS.get(col_index)
433 if field:
434 qs = qs.order_by(f"{'-' if direction == 'desc' else ''}{field}")
435 return qs
437 @tracer.start_as_current_span("SubmissionTableAPIView._apply_table_key_filter")
438 def _apply_table_key_filter(self, qs, table_key, role):
439 state_map = {
440 "submitted": [
441 SubmissionState.SUBMITTED.value,
442 SubmissionState.REVISION_SUBMITTED.value,
443 ],
444 "on_review": [SubmissionState.ON_REVIEW.value],
445 "revision_requested": [SubmissionState.REVISION_REQUESTED.value],
446 "accepted": [SubmissionState.ACCEPTED.value],
447 "rejected": [SubmissionState.REJECTED.value],
448 }
449 if table_key in state_map:
450 return qs.filter(state__in=state_map[table_key])
452 if table_key in ("todo", "waiting", "done"):
453 # # filter with python
454 # status_map = {
455 # "todo": SubmissionStatus.TODO,
456 # "waiting": SubmissionStatus.WAITING,
457 # "done": SubmissionStatus.DONE,
458 # }
459 # target_status = status_map[table_key]
460 # matching_pks = [
461 # s.pk for s in qs if role.get_submission_status(s).status == target_status
462 # ]
463 # return qs.filter(pk__in=matching_pks)
465 # filter with annotate
466 return role.annotate_submission_status(qs).filter(computed_status=table_key)
467 return qs
469 @tracer.start_as_current_span("SubmissionTableAPIView._apply_filters")
470 def _apply_filters(self, qs, params):
471 """
472 Translate active filters (query params _filter_xxx) to Django ORM filter.
473 Direct corresponding to submission_list_filters().
474 """
475 from mesh.models.filters import FILTER_QUERY_PARAMETER_PREFIX
477 filter_map = {
478 "created_by": ("created_by__pk__in", "int"),
479 "state": ("state__in", "str"),
480 "journal_section": ("journal_section__pk__in", "int"),
481 "all_assigned_editors": ("editors__user__pk__in", "int"),
482 }
484 for param_key, values in params.lists():
485 if not param_key.startswith(FILTER_QUERY_PARAMETER_PREFIX):
486 continue
487 filter_id = param_key.removeprefix(FILTER_QUERY_PARAMETER_PREFIX).removesuffix("[]")
488 if filter_id not in filter_map:
489 continue
491 orm_field, cast = filter_map[filter_id]
492 try:
493 if cast == "int":
494 typed_values = [int(v) for v in values]
495 else:
496 typed_values = values
497 except (ValueError, TypeError):
498 continue
500 qs = qs.filter(**{orm_field: typed_values})
502 return qs
505class SubmissionTableTestView(LoginRequiredMixin, TemplateView):
506 template_name = "mesh/submission/submission_table_test.html"
508 def get_context_data(self, **kwargs):
509 context = super().get_context_data(**kwargs)
510 context["with_journal_sections"] = JournalSection.objects.all_journal_sections().exists()
511 context["api_url"] = reverse("mesh:submission_table_api")
512 return context
515class SubmissionTooltipView(LoginRequiredMixin, MeshObjectMixin, DetailView):
516 model = Submission
517 pk_url_kwarg = "submission_pk"
519 def get(self, request, *args, **kwargs):
520 submission = self.get_object()
521 role = request.current_role
523 builder = BuildSubmissionProxyVisitor(role)
524 proxy = builder.visit(submission, row_id=f"table-row-{submission.pk}")
526 tooltip = build_submission_tooltip(submission)
528 html = render_to_string(
529 "mesh/components/submission_tooltip.html",
530 {
531 "submission_proxy": proxy,
532 "tooltip": tooltip,
533 "ignore_wrapper": True,
534 "request": request,
535 },
536 request=request,
537 )
538 return HttpResponse(html)
541class SubmissionFiltersAPIView(LoginRequiredMixin, View):
542 """
543 Filter HTML panel, initialised with current role values. First click lazy load.
544 """
546 @tracer.start_as_current_span("SubmissionFiltersAPIView.get")
547 def get(self, request):
548 role = request.current_role
549 if not role.can_filter_submissions():
550 return HttpResponse("")
552 qs = role.get_submissions()
553 filters = submission_list_filters()
554 filters.init_filters(request.GET, qs)
556 html = render_to_string(
557 "mesh/components/filter_set.html",
558 {"filter_set": filters, "request": request},
559 request=request,
560 )
561 return HttpResponse(html)
564class SubmissionLogView(LoginRequiredMixin, MeshObjectMixin, DetailView):
565 """
566 View to display the `SubmissionLog` of a submission.
567 """
569 template_name = "mesh/log.html"
571 def get_object(self, queryset=None):
572 return self.get_submission()
574 def get_context_data(self, *args, **kwargs):
575 context = super().get_context_data(*args, **kwargs)
576 submission: Submission = self.get_object()
577 logs = submission.log_messages_censored
579 order = self.request.GET.get("order")
580 context["order"] = order
581 # TODO : Investigate this : This may be inverted
582 if order == "asc":
583 logs.reverse()
584 context["logs"] = logs
586 query_params = {"order": "desc" if order == "asc" else "asc"}
587 sort_url = reverse("mesh:submission_log", kwargs={"submission_pk": submission.pk})
588 context["sort_url"] = format_url_with_params(sort_url, query_params)
590 context["page_title"] = _("Submission history")
592 # Generate breadcrumb data
593 breadcrumb = get_submission_breadcrumb(submission)
594 breadcrumb.add_item(
595 title=_("Submission history"),
596 url=reverse("mesh:submission_log", kwargs={"submission_pk": submission.pk}),
597 )
598 context["breadcrumb"] = breadcrumb
599 return context
602class SubmissionInListAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView):
603 def get(self, request, *args, **kwargs):
604 submission = self.get_submission()
606 builder = BuildSubmissionProxyVisitor(self.request.current_role)
607 submission_proxy = builder.visit(submission, row_id=kwargs["row_id"])
609 context = {
610 "submission_proxy": submission_proxy,
611 "with_journal_sections": JournalSection.objects.all_journal_sections().exists(),
612 }
614 return render(
615 request,
616 "mesh/submission/submission_table_row.html",
617 context,
618 )
621@method_decorator([csrf_exempt], name="dispatch")
622class SubmissionNotesAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView):
623 def post(self, request, *args, **kwargs):
624 submission = self.get_submission()
626 if not self.request.current_role.can_edit_submission(submission):
627 raise PermissionDenied
629 notes = request.POST.get("editabledata", "")
630 sanitized_notes = sanitize_html_input(notes)
632 submission.notes = sanitized_notes
633 submission.save()
635 return JsonResponse({"message": "OK"})