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

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

19 

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 

40 

41if TYPE_CHECKING: 

42 from django.http import HttpRequest 

43 

44 from mesh.models.roles.base_role import Role 

45 from mesh.views.components.submission_list import SubmissionListConfig 

46 

47tracer = trace.get_tracer(__name__) 

48 

49 

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

55 

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

62 

63 return html_message 

64 

65 

66class SubmissionDetailsView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

67 """ 

68 View for a single submission. 

69 

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

71 regarding the submission. 

72 """ 

73 

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

75 

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

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

78 

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

93 

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) 

100 

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) 

106 

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

108 submission, 

109 display_as_btn=False, 

110 reload_page=True, 

111 shortlist=False, 

112 ) 

113 

114 # editorial_decisions = ( 

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

116 # "-date_created" 

117 # ) 

118 # ) 

119 # 

120 # context["editorial_decisions"] = editorial_decisions 

121 

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 

130 

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 

157 

158 

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 ) 

178 

179 

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 ) 

185 

186 

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

188 return group_by( 

189 submissions, 

190 lambda s: SubmissionState(s.state), 

191 ) 

192 

193 

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

195 return {SubmissionListEnum.ALL: submissions} 

196 

197 

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) 

209 

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

220 

221 all_submissions = [] 

222 for s in submissions_qs: 

223 s.status = role.get_submission_status(s) 

224 all_submissions.append(s) 

225 

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) 

229 

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

233 

234 builder = BuildSubmissionProxyVisitor(role) 

235 

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 ] 

259 

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 ] 

268 

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 ) 

285 

286 return context 

287 

288 

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} 

306 

307 

308class SubmissionListView(LoginRequiredMixin, TemplateView): 

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

310 

311 def get_active_tab(self): 

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

313 

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

331 

332 breadcrumb = get_base_breadcrumb() 

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

334 context["breadcrumb"] = breadcrumb 

335 

336 return context 

337 

338 def _count_submissions(self, role): 

339 qs = role.get_submissions() 

340 submissions_count = qs.count() 

341 return submissions_count 

342 

343 

344class SubmissionTableAPIView(LoginRequiredMixin, View): 

345 """ 

346 Server-side DataBles endpoint 

347 

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

355 

356 ORDERABLE_COLUMNS = { 

357 1: "name", 

358 2: "state", 

359 3: "date_last_activity", 

360 4: "date_created", 

361 } 

362 

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

370 

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

375 

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

377 

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

379 

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) 

418 

419 return JsonResponse( 

420 { 

421 "draw": draw, 

422 "recordsTotal": submissions_count, 

423 "recordsFiltered": submissions_count, 

424 "data": rows_data, 

425 } 

426 ) 

427 

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 

436 

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

451 

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) 

464 

465 # filter with annotate 

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

467 return qs 

468 

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 

476 

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 } 

483 

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 

490 

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 

499 

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

501 

502 return qs 

503 

504 

505class SubmissionTableTestView(LoginRequiredMixin, TemplateView): 

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

507 

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 

513 

514 

515class SubmissionTooltipView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

516 model = Submission 

517 pk_url_kwarg = "submission_pk" 

518 

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

520 submission = self.get_object() 

521 role = request.current_role 

522 

523 builder = BuildSubmissionProxyVisitor(role) 

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

525 

526 tooltip = build_submission_tooltip(submission) 

527 

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) 

539 

540 

541class SubmissionFiltersAPIView(LoginRequiredMixin, View): 

542 """ 

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

544 """ 

545 

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

551 

552 qs = role.get_submissions() 

553 filters = submission_list_filters() 

554 filters.init_filters(request.GET, qs) 

555 

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) 

562 

563 

564class SubmissionLogView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

565 """ 

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

567 """ 

568 

569 template_name = "mesh/log.html" 

570 

571 def get_object(self, queryset=None): 

572 return self.get_submission() 

573 

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 

578 

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 

585 

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) 

589 

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

591 

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 

600 

601 

602class SubmissionInListAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

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

604 submission = self.get_submission() 

605 

606 builder = BuildSubmissionProxyVisitor(self.request.current_role) 

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

608 

609 context = { 

610 "submission_proxy": submission_proxy, 

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

612 } 

613 

614 return render( 

615 request, 

616 "mesh/submission/submission_table_row.html", 

617 context, 

618 ) 

619 

620 

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

622class SubmissionNotesAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

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

624 submission = self.get_submission() 

625 

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

627 raise PermissionDenied 

628 

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

630 sanitized_notes = sanitize_html_input(notes) 

631 

632 submission.notes = sanitized_notes 

633 submission.save() 

634 

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