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