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

1from collections.abc import Iterable 

2from typing import TYPE_CHECKING, Any 

3 

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 

17 

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 

33 

34if TYPE_CHECKING: 

35 from django.http import HttpRequest 

36 

37 from mesh.models.roles.base_role import Role 

38 from mesh.views.components.submission_list import SubmissionListConfig 

39 

40tracer = trace.get_tracer(__name__) 

41 

42 

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 """ 

48 

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}""" 

55 

56 return html_message 

57 

58 

59class SubmissionDetailsView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

60 """ 

61 View for a single submission. 

62 

63 The context data is populated with all the available actions to the user 

64 regarding the submission. 

65 """ 

66 

67 template_name = "mesh/submission/submission_details.html" 

68 

69 def setup(self, request, *args, **kwargs): 

70 super().setup(request, *args, **kwargs) 

71 

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() 

86 

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) 

93 

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) 

99 

100 context["submission_proxy"] = builder.visit( 

101 submission, 

102 display_as_btn=False, 

103 reload_page=True, 

104 shortlist=False, 

105 ) 

106 

107 # editorial_decisions = ( 

108 # self.submission.editorial_decisions.all().order_by( 

109 # "-date_created" 

110 # ) 

111 # ) 

112 # 

113 # context["editorial_decisions"] = editorial_decisions 

114 

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 ) 

122 

123 # Generate breadcrumb data 

124 context["breadcrumb"] = get_submission_breadcrumb(submission) 

125 return context 

126 

127 

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 ) 

147 

148 

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 ) 

154 

155 

156def group_submissions_per_state(submissions: Iterable[Submission], role: "Role"): 

157 return group_by( 

158 submissions, 

159 lambda s: SubmissionState(s.state), 

160 ) 

161 

162 

163def one_group_submissions(submissions: Iterable[Submission], role: "Role"): 

164 return {SubmissionListEnum.ALL: submissions} 

165 

166 

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) 

178 

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()) 

189 

190 all_submissions = [] 

191 for s in submissions_qs: 

192 s.status = role.get_submission_status(s) 

193 all_submissions.append(s) 

194 

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) 

198 

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, []) 

202 

203 builder = BuildSubmissionProxyVisitor(role) 

204 

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 ] 

228 

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 ] 

237 

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 ) 

254 

255 return context 

256 

257 

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} 

275 

276 

277class SubmissionListView(LoginRequiredMixin, TemplateView): 

278 template_name = "mesh/submission/submission_list_page.html" 

279 

280 def get_active_tab(self): 

281 return self.kwargs.get("tab") or self.request.GET.get("tab", "status") 

282 

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() 

300 

301 breadcrumb = get_base_breadcrumb() 

302 breadcrumb.add_item(title=_("My submissions"), url=reverse("mesh:submission_list")) 

303 context["breadcrumb"] = breadcrumb 

304 

305 return context 

306 

307 def _count_submissions(self, role): 

308 qs = role.get_submissions() 

309 submissions_count = qs.count() 

310 return submissions_count 

311 

312 

313class SubmissionTableAPIView(LoginRequiredMixin, View): 

314 """ 

315 Server-side DataBles endpoint 

316 

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 """ 

324 

325 ORDERABLE_COLUMNS = { 

326 1: "name", 

327 2: "state", 

328 3: "date_last_activity", 

329 4: "date_created", 

330 } 

331 

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") 

339 

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() 

344 

345 qs = self._apply_ordering(qs, request.GET) 

346 

347 submission_list = list(qs[start : start + length]) 

348 

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) 

387 

388 return JsonResponse( 

389 { 

390 "draw": draw, 

391 "recordsTotal": submissions_count, 

392 "recordsFiltered": submissions_count, 

393 "data": rows_data, 

394 } 

395 ) 

396 

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 

405 

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]) 

420 

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) 

433 

434 # filter with annotate 

435 return role.annotate_submission_status(qs).filter(computed_status=table_key) 

436 return qs 

437 

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 

445 

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 } 

452 

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 

459 

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 

468 

469 qs = qs.filter(**{orm_field: typed_values}) 

470 

471 return qs 

472 

473 

474class SubmissionTableTestView(LoginRequiredMixin, TemplateView): 

475 template_name = "mesh/submission/submission_table_test.html" 

476 

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 

482 

483 

484class SubmissionTooltipView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

485 model = Submission 

486 pk_url_kwarg = "submission_pk" 

487 

488 def get(self, request, *args, **kwargs): 

489 submission = self.get_object() 

490 role = request.current_role 

491 

492 builder = BuildSubmissionProxyVisitor(role) 

493 proxy = builder.visit(submission, row_id=f"table-row-{submission.pk}") 

494 

495 tooltip = build_submission_tooltip(submission) 

496 

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) 

508 

509 

510class SubmissionFiltersAPIView(LoginRequiredMixin, View): 

511 """ 

512 Filter HTML panel, initialised with current role values. First click lazy load. 

513 """ 

514 

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("") 

520 

521 qs = role.get_submissions() 

522 filters = submission_list_filters() 

523 filters.init_filters(request.GET, qs) 

524 

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) 

531 

532 

533class SubmissionLogView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

534 """ 

535 View to display the `SubmissionLog` of a submission. 

536 """ 

537 

538 template_name = "mesh/log.html" 

539 

540 def get_object(self, queryset=None): 

541 return self.get_submission() 

542 

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 

547 

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 

554 

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) 

558 

559 context["page_title"] = _("Submission history") 

560 

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 

569 

570 

571class SubmissionInListAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

572 def get(self, request, *args, **kwargs): 

573 submission = self.get_submission() 

574 

575 builder = BuildSubmissionProxyVisitor(self.request.current_role) 

576 submission_proxy = builder.visit(submission, row_id=kwargs["row_id"]) 

577 

578 context = { 

579 "submission_proxy": submission_proxy, 

580 "with_journal_sections": JournalSection.objects.all_journal_sections().exists(), 

581 } 

582 

583 return render( 

584 request, 

585 "mesh/submission/submission_table_row.html", 

586 context, 

587 ) 

588 

589 

590@method_decorator([csrf_exempt], name="dispatch") 

591class SubmissionNotesAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

592 def post(self, request, *args, **kwargs): 

593 submission = self.get_submission() 

594 

595 if not self.request.current_role.can_edit_submission(submission): 

596 raise PermissionDenied 

597 

598 notes = request.POST.get("editabledata", "") 

599 sanitized_notes = sanitize_html_input(notes) 

600 

601 submission.notes = sanitized_notes 

602 submission.save() 

603 

604 return JsonResponse({"message": "OK"})