Coverage for src / mesh / views / views_submission.py: 61%

171 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-05-04 12:41 +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, JsonResponse 

7from django.shortcuts import render 

8from django.urls import reverse 

9from django.utils.decorators import method_decorator 

10from django.utils.translation import gettext_lazy as _ 

11from django.views.decorators.csrf import csrf_exempt 

12from django.views.generic import DetailView, TemplateView 

13from opentelemetry import trace 

14from ptf.url_utils import format_url_with_params 

15 

16from mesh.app_settings import app_settings 

17from mesh.models.filters import FieldGetter, Filter, FilterSet 

18from mesh.models.orm.journal_models import JournalSection 

19from mesh.models.orm.submission_models import Submission, SubmissionState 

20from mesh.models.roles.editor import Editor 

21from mesh.models.roles.journal_manager import JournalManager 

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_all_submission_list_config, 

28 get_done_submission_list_config, 

29 get_submission_by_state_config, 

30 get_submission_list_config, 

31) 

32from mesh.views.utils import group_by 

33from mesh.views.viewmodel.submission_proxy import BuildSubmissionProxyVisitor 

34from mesh.views.views_base import MeshObjectMixin 

35 

36if TYPE_CHECKING: 

37 from django.http import HttpRequest 

38 

39 from mesh.models.roles.base_role import Role 

40 from mesh.views.components.submission_list import SubmissionListConfig 

41 

42tracer = trace.get_tracer(__name__) 

43 

44 

45def get_submission_message_if_no_actions(submission, role: "Role"): 

46 """ 

47 An author can not do anything if the submission is under review. 

48 Instead, we display a "thank you" message 

49 """ 

50 

51 html_message = "" 

52 if not role.can_edit_submission(submission): 

53 contact_email = app_settings.JOURNAL_EMAIL_CONTACT 

54 html_message = f""" 

55<p>Thank you for submitting your article.</p> 

56We will review your submission and get back to you. You can contact us at {contact_email}""" 

57 

58 return html_message 

59 

60 

61class SubmissionDetailsView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

62 """ 

63 View for a single submission. 

64 

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

66 regarding the submission. 

67 """ 

68 

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

70 

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

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

73 

74 @tracer.start_as_current_span("SubmissionDetailsView.get_object") 

75 def get_object(self, queryset=None): 

76 ojsid = self.kwargs.get("ojsid", None) 

77 if ojsid: 

78 if queryset is None: 

79 queryset = self.get_queryset() 

80 try: 

81 return queryset.get(ojs_id=ojsid) 

82 except queryset.model.DoesNotExist: 

83 raise Http404( 

84 _("No %(verbose_name)s found matching the query") 

85 % {"verbose_name": queryset.model._meta.verbose_name} 

86 ) 

87 return self.get_submission() 

88 

89 # def restrict_dispatch(self, request: "HttpRequest", *args, **kwargs): 

90 # # TODO: Should we prefetch all data for an unique instance ? 

91 # # It would result in the same amount of queries but, with prefetch, they would 

92 # # all be made at the same time. 

93 # self.submission = get_object_or_404(Submission, pk=kwargs["pk"]) 

94 # return not request.current_role.can_access_submission(self.submission) 

95 

96 def get_context_data(self, object: Submission, **kwargs) -> dict[str, Any]: 

97 context = super().get_context_data(**kwargs) 

98 # BaseDetailView already calls get_object in the get method 

99 submission = object 

100 builder = BuildSubmissionProxyVisitor(self.request.current_role) 

101 

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

103 submission, 

104 display_as_btn=False, 

105 reload_page=True, 

106 shortlist=False, 

107 ) 

108 

109 # editorial_decisions = ( 

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

111 # "-date_created" 

112 # ) 

113 # ) 

114 # 

115 # context["editorial_decisions"] = editorial_decisions 

116 

117 # review_form = StartReviewProcessForm(initial={"process": True}) 

118 # context["action_lists"] = build_submission_actions( 

119 # self.submission, self.role_handler, review_form 

120 # ) 

121 context["message_if_no_actions"] = get_submission_message_if_no_actions( 

122 submission, self.request.current_role 

123 ) 

124 

125 # # Generate breadcrumb data 

126 # context["breadcrumb"] = get_submission_breadcrumb(self.submission) 

127 return context 

128 

129 

130def submission_list_filters() -> FilterSet: 

131 """ 

132 Instantiate a fresh FilterSet for the submission list view. 

133 """ 

134 return FilterSet( 

135 id="submission-filters", 

136 name="Filters", 

137 filters=[ 

138 Filter(id="created_by", name=_("Author"), type="model"), 

139 Filter( 

140 id="state", 

141 name=_("State"), 

142 type="string", 

143 name_getter=FieldGetter(attr="get_state_display", callable=True), 

144 ), 

145 Filter(id="journal_section", name=_("Section"), type="model"), 

146 Filter(id="all_assigned_editors", name=_("Assigned editors"), type="model"), 

147 ], 

148 ) 

149 

150 

151def group_submissions_per_status(submissions: Iterable[Submission], role: "Role"): 

152 return group_by( 

153 submissions, 

154 lambda s: role.get_submission_status(s).status, 

155 ) 

156 

157 

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

159 return group_by( 

160 submissions, 

161 lambda s: SubmissionState(s.state), 

162 ) 

163 

164 

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

166 return {SubmissionListEnum.ALL: submissions} 

167 

168 

169@tracer.start_as_current_span("prepare_submissions_lists") 

170def prepare_submissions_lists( 

171 role: "Role", 

172 request: "HttpRequest", 

173 list_config_ftor=get_submission_list_config, 

174 group_submissions_ftor=group_submissions_per_status, 

175) -> dict[str, Any]: 

176 """ 

177 Retrieve, group and filter the user's submissions to show according to: 

178 - the user's role submission list config 

179 - the applied filters (contained in the HTTP request) 

180 

181 Params: 

182 - `role_handler` The role handler 

183 - `request` The underlying HTTP request 

184 - `config_key` The right function to be called to get the SubmissionListConfig(s) 

185 to be used. This config defines which submissions to display along 

186 with filtering behavior, etc. 

187 """ 

188 context: dict[str, Any] = {} 

189 with tracer.start_as_current_span("prepare_submissions_lists.evaluate_queryset"): 

190 submissions_qs = list(role.get_submissions()) 

191 

192 all_submissions = [] 

193 for s in submissions_qs: 

194 s.status = role.get_submission_status(s) 

195 all_submissions.append(s) 

196 

197 # The submissions are displayed in different tables grouped by status. Precise 

198 # behavior is configured per role with SubmissionListConfig objects. 

199 grouped_submissions = group_submissions_ftor(all_submissions, role) 

200 

201 submission_list_configs: list[SubmissionListConfig] = list_config_ftor() 

202 for config in submission_list_configs: 

203 config.all_submissions = grouped_submissions.get(config.key, []) 

204 

205 builder = BuildSubmissionProxyVisitor(role) 

206 

207 # Handle the filters per submission list 

208 if role.can_filter_submissions(): 

209 # Init filters 

210 filters = submission_list_filters() 

211 filters.init_filters( 

212 request.GET, 

213 *(config.all_submissions for config in submission_list_configs if config.in_filters), 

214 ) 

215 context["filters"] = filters 

216 # Filter all submission lists 

217 for config in submission_list_configs: 

218 if not config.display: 

219 continue 

220 if not config.in_filters: 

221 config.submissions = [ 

222 builder.visit(s, row_id=f"{config.id}-row-{counter}") 

223 for counter, s in enumerate(config.all_submissions) 

224 ] 

225 continue 

226 config.submissions = [ 

227 builder.visit(s, row_id=f"{config.id}-row-{counter}") 

228 for counter, s in enumerate(filters.filter(config.all_submissions)) 

229 ] 

230 

231 else: 

232 for config in submission_list_configs: 

233 if not config.display: 

234 continue 

235 config.submissions = [ 

236 role.accept(builder, s, row_id=f"{config.id}-row-{counter}") 

237 for counter, s in enumerate(config.all_submissions) 

238 ] 

239 

240 context["submissions_config"] = submission_list_configs 

241 context["all_submissions_count"] = sum( 

242 len(config.all_submissions) for config in submission_list_configs 

243 ) 

244 context["submission_count"] = CountWithTotal( 

245 value=sum( 

246 len(config.submissions) 

247 for config in submission_list_configs 

248 if config.display and config.in_filters 

249 ), 

250 total=sum( 

251 len(config.all_submissions) 

252 for config in submission_list_configs 

253 if config.display and config.in_filters 

254 ), 

255 ) 

256 

257 return context 

258 

259 

260# def all_role_submissions_count(role: Role) -> list[dict]: 

261# """ 

262# Returns the total number of submissions per active role. 

263# """ 

264# all_role_submissions = [] 

265 

266# for role in role.role_data.get_active_roles(): 

267# role_submissions = { 

268# "role": role.summary().serialize(), 

269# "submissions": len(role.get_submissions()), 

270# } 

271# if role == role_handler.current_role: 

272# role_submissions["active"] = True 

273# all_role_submissions.append(role_submissions) 

274 

275# return all_role_submissions 

276 

277 

278class SubmissionListView(LoginRequiredMixin, TemplateView): 

279 """ 

280 View for the list of all submissions currently accessible to the user role, grouped by Status 

281 """ 

282 

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

284 

285 def get_context_data(self, **kwargs) -> dict[str, Any]: 

286 context = super().get_context_data(**kwargs) 

287 

288 context.update(prepare_submissions_lists(self.request.current_role, self.request)) 

289 # all_role_submissions = all_role_submissions_count(self.request.current_role) 

290 # if len(all_role_submissions) > 1: 

291 # context["all_role_submissions"] = all_role_submissions 

292 

293 context["with_journal_sections"] = JournalSection.objects.all_journal_sections().exists() 

294 

295 context["active_tab"] = "status" 

296 context["role"] = self.request.current_role 

297 # # Generate breadcrumb data 

298 # breadcrumb = get_base_breadcrumb() 

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

300 # context["breadcrumb"] = breadcrumb 

301 return context 

302 

303 

304class AllSubmissionsListView(LoginRequiredMixin, TemplateView): 

305 """ 

306 View for the list of all submissions currently accessible to the user role, in 1 flat list 

307 """ 

308 

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

310 

311 def get_context_data(self, **kwargs) -> dict[str, Any]: 

312 context = super().get_context_data(**kwargs) 

313 

314 context.update( 

315 prepare_submissions_lists( 

316 self.request.current_role, 

317 self.request, 

318 list_config_ftor=get_all_submission_list_config, 

319 group_submissions_ftor=one_group_submissions, 

320 ) 

321 ) 

322 

323 context["with_journal_sections"] = JournalSection.objects.all_journal_sections().exists() 

324 

325 context["active_tab"] = "all" 

326 

327 # # Generate breadcrumb data 

328 # breadcrumb = get_base_breadcrumb() 

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

330 # context["breadcrumb"] = breadcrumb 

331 return context 

332 

333 

334class SubmissionsByStateListView(LoginRequiredMixin, TemplateView): 

335 """ 

336 View for the list of all submissions currently accessible to the user role, in 1 flat list 

337 """ 

338 

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

340 

341 def get_context_data(self, **kwargs) -> dict[str, Any]: 

342 context = super().get_context_data(**kwargs) 

343 

344 context.update( 

345 prepare_submissions_lists( 

346 self.request.current_role, 

347 self.request, 

348 list_config_ftor=get_submission_by_state_config, 

349 group_submissions_ftor=group_submissions_per_state, 

350 ) 

351 ) 

352 

353 context["with_journal_sections"] = JournalSection.objects.all_journal_sections().exists() 

354 

355 context["active_tab"] = "state" 

356 

357 # # Generate breadcrumb data 

358 # breadcrumb = get_base_breadcrumb() 

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

360 # context["breadcrumb"] = breadcrumb 

361 return context 

362 

363 

364class DoneSubmissionListView(LoginRequiredMixin, TemplateView): 

365 restricted_roles = [JournalManager, Editor] 

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

367 

368 def get_context_data(self, *args, **kwargs) -> dict[str, Any]: 

369 context = super().get_context_data(*args, **kwargs) 

370 context.update( 

371 prepare_submissions_lists( 

372 self.request.current_role, 

373 self.request, 

374 list_config_ftor=get_done_submission_list_config, 

375 ) 

376 ) 

377 context["done_page"] = True 

378 

379 # Generate breadcrumb data 

380 breadcrumb = get_base_breadcrumb() 

381 breadcrumb.add_item( 

382 title=_("Done submissions"), 

383 url=reverse("mesh:submission_list_done"), 

384 ) 

385 context["breadcrumb"] = breadcrumb 

386 return context 

387 

388 

389class SubmissionLogView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

390 """ 

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

392 """ 

393 

394 template_name = "mesh/log.html" 

395 

396 def get_object(self, queryset=None): 

397 return self.get_submission() 

398 

399 def get_context_data(self, *args, **kwargs): 

400 context = super().get_context_data(*args, **kwargs) 

401 submission: Submission = self.get_object() 

402 logs = submission.log_messages_censored 

403 

404 order = self.request.GET.get("order") 

405 context["order"] = order 

406 # TODO : Investigate this : This may be inverted 

407 if order == "asc": 

408 logs.reverse() 

409 context["logs"] = logs 

410 

411 query_params = {"order": "desc" if order == "asc" else "asc"} 

412 sort_url = reverse("mesh:submission_log", kwargs={"pk": submission.pk}) 

413 context["sort_url"] = format_url_with_params(sort_url, query_params) 

414 

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

416 

417 # Generate breadcrumb data 

418 breadcrumb = get_submission_breadcrumb(submission) 

419 breadcrumb.add_item( 

420 title=_("Submission history"), 

421 url=reverse("mesh:submission_log", kwargs={"pk": submission.pk}), 

422 ) 

423 context["breadcrumb"] = breadcrumb 

424 return context 

425 

426 

427class SubmissionInListAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

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

429 submission = self.get_submission() 

430 

431 builder = BuildSubmissionProxyVisitor(self.request.current_role) 

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

433 

434 context = { 

435 "submission_proxy": submission_proxy, 

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

437 } 

438 

439 return render( 

440 request, 

441 "mesh/submission/submission_table_row.html", 

442 context, 

443 ) 

444 

445 

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

447class SubmissionNotesAPIView(LoginRequiredMixin, MeshObjectMixin, DetailView): 

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

449 submission = self.get_submission() 

450 

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

452 raise PermissionDenied 

453 

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

455 sanitized_notes = sanitize_html_input(notes) 

456 

457 submission.notes = sanitized_notes 

458 submission.save() 

459 

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