Coverage for src/mesh/views/views_review.py: 24%

296 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-04-28 07:45 +0000

1import re 

2from typing import Any 

3 

4from django.contrib import messages 

5from django.db.models import QuerySet 

6from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse 

7from django.shortcuts import get_object_or_404 

8from django.urls import reverse, reverse_lazy 

9from django.utils.translation import gettext_lazy as _ 

10from django.views.generic import FormView, TemplateView, View 

11from django.views.generic.edit import CreateView, UpdateView 

12from ptf.url_utils import format_url_with_params 

13 

14from mesh.model.file_helpers import post_delete_model_file 

15from mesh.model.roles.editor import Editor 

16from mesh.model.roles.journal_manager import JournalManager 

17from mesh.model.roles.reviewer import Reviewer 

18from mesh.views.forms.base_forms import FormAction 

19from mesh.views.forms.review_forms import ( 

20 ReviewAcceptForm, 

21 ReviewConfirmForm, 

22 ReviewCreateForm, 

23 ReviewSubmitForm, 

24) 

25from mesh.views.mixins import RoleMixin 

26 

27from ..models.review_models import Review, ReviewAdditionalFile, ReviewState 

28from ..models.submission_models import SubmissionLog, SubmissionVersion 

29from ..models.user_models import Suggestion, User 

30from .components.breadcrumb import get_submission_breadcrumb 

31from .components.button import Button 

32from .model_proxy import ReviewProxy 

33from .utils import create_new_user, get_review_request_email, send_review_request_email 

34from .views_base import SubmittableModelFormMixin 

35 

36 

37class ReviewCreateView(RoleMixin, CreateView): 

38 """ 

39 View to invite a new reviewer for a given round. 

40 The review form is filled with the submission version from the URL. 

41 """ 

42 

43 model = Review 

44 form_class = ReviewCreateForm 

45 template_name = "mesh/review/review_create.html" 

46 # template_name = "mesh/forms/form_full_page.html" 

47 version: SubmissionVersion 

48 restricted_roles = [JournalManager, Editor] 

49 

50 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs): 

51 round_pk = kwargs["version_pk"] 

52 self.version = get_object_or_404(SubmissionVersion, pk=round_pk) 

53 

54 return not self.role_handler.check_rights("can_invite_reviewer", self.version) 

55 

56 def get_form_kwargs(self) -> dict[str, Any]: 

57 kwargs = super().get_form_kwargs() 

58 suggestions = Suggestion.objects.filter(submission=self.version.submission).order_by("seq") 

59 choices = [(suggestion.pk, "") for suggestion in suggestions] 

60 kwargs["_choices"] = choices 

61 kwargs["_version"] = self.version 

62 # # submission = self.version.submission 

63 # # self.suggestions = Suggestion.objects.filter(submission=submission) 

64 # # self.existing_reviewers = [review.reviewer for review in self.version.reviews.all()] 

65 # # kwargs["_suggestions"] = self.suggestions 

66 # # kwargs["_existing_reviewers"] = self.existing_reviewers 

67 # 

68 # # current_reviewers = list( 

69 # # Review.objects.filter(version=self.version).values_list("reviewer__pk", flat=True) 

70 # # ) 

71 # # kwargs["_reviewers"] = User.objects.exclude(pk__in=current_reviewers) 

72 

73 return kwargs 

74 

75 def get_initial(self) -> dict[str, Any]: 

76 return { 

77 "request_email": get_review_request_email( 

78 self.version.submission, self.request.build_absolute_uri("/") 

79 ), 

80 "request_email_subject": _("New referee request"), 

81 } 

82 

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

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

85 context["page_title"] = "Request review" 

86 

87 submission = self.version.submission 

88 submission_url = reverse_lazy("mesh:submission_details", kwargs={"pk": submission.pk}) 

89 description = "Please fill the form below to request an additional review for " 

90 description += f"round #{self.version.number} of submission " 

91 description += f"<a href='{submission_url}'><i>{submission.name}</i></a>." 

92 

93 shortlist_url = reverse_lazy("mesh:submission_shortlist", kwargs={"pk": submission.pk}) 

94 action = f"<a href='{shortlist_url}'><button>Edit Shortlist</button></a>" 

95 context["submission"] = submission 

96 

97 self.suggestions = Suggestion.objects.filter(submission=submission).order_by("seq") 

98 context["suggestions"] = self.suggestions 

99 

100 self.existing_reviewers = [review.reviewer for review in self.version.reviews.all()] 

101 context["existing_reviewers"] = self.existing_reviewers 

102 

103 choices = [] 

104 has_selected_choice = False 

105 for suggestion in self.suggestions: 

106 suggested_user = ( 

107 suggestion.suggested_user 

108 if suggestion.suggested_user is not None 

109 else suggestion.suggested_reviewer 

110 ) 

111 if suggested_user in self.existing_reviewers: 

112 choices.append( 

113 {"value": suggestion.pk, "label": str(suggested_user), "disabled": True} 

114 ) 

115 else: 

116 choice = {"value": suggestion.pk, "label": str(suggested_user)} 

117 if not has_selected_choice: 

118 has_selected_choice = True 

119 choice["selected"] = True 

120 choices.append(choice) 

121 context["suggested_users"] = choices 

122 

123 context["form_description"] = [description, action] 

124 

125 # Generate breadcrumb data 

126 breadcrumb = get_submission_breadcrumb(submission) 

127 breadcrumb.add_item( 

128 title=_("Request review"), 

129 url=reverse_lazy("mesh:review_create", kwargs={"version_pk": self.version.pk}), 

130 ) 

131 context["breadcrumb"] = breadcrumb 

132 

133 return context 

134 

135 def form_valid(self, form: ReviewCreateForm) -> HttpResponse: 

136 """ 

137 Inject required data to the form instance before saving model. 

138 """ 

139 form.instance.version = self.version 

140 form.instance._user = self.request.user 

141 

142 # Check if the review is attached to an existing user or if we should create a new one. 

143 reviewer = None 

144 suggestion = None 

145 old_suggested_user = None 

146 email = first_name = last_name = "" 

147 

148 # TODO: check if the reviewer has already been assigned 

149 

150 reviewer_select = form.cleaned_data["reviewer_select"] 

151 if reviewer_select == "shortlist": 

152 # someone from the shortlist was selected. 

153 # Get the associated suggestion 

154 suggestion_pk = form.cleaned_data["suggested_user"] 

155 suggestion = Suggestion.objects.get(pk=int(suggestion_pk)) 

156 

157 # A suggestion was made either on an existing user or with first name/last name/email 

158 if suggestion.suggested_user is not None: 

159 # An existing User was selected. No need to create a user 

160 reviewer = suggestion.suggested_user 

161 else: 

162 old_suggested_user = suggestion.suggested_reviewer 

163 first_name = suggestion.suggested_reviewer.first_name 

164 last_name = suggestion.suggested_reviewer.last_name 

165 email = suggestion.suggested_reviewer.email 

166 else: 

167 # First name, last name and email were typed in the form 

168 first_name = form.cleaned_data["reviewer_first_name"] 

169 last_name = form.cleaned_data["reviewer_last_name"] 

170 email = form.cleaned_data["reviewer_email"] 

171 

172 if reviewer is None: 

173 # Even if first/last/email were typed (some time ago in the case of a suggestion) 

174 # There might now exists a User with the same email 

175 qs = User.objects.filter(email=email) 

176 if qs.exists(): 

177 reviewer = qs.first() 

178 else: 

179 reviewer = create_new_user(email, first_name, last_name) 

180 

181 suggestions_to_update = Suggestion.objects.filter(suggested_reviewer__email=email) 

182 for suggestion_to_update in suggestions_to_update: 

183 suggestion_to_update.suggested_reviewer = None 

184 suggestion_to_update.suggested_user = reviewer 

185 suggestion_to_update.save() 

186 

187 if old_suggested_user is not None: 

188 # reviewer is now a User. We need to merge data from the suggested_reviewer 

189 reviewer.keywords = old_suggested_user.keywords 

190 reviewer.save() 

191 

192 old_suggested_user.delete() 

193 

194 # We now know the reviewer. Set the form.instance so that Django can create a Review 

195 form.instance.reviewer = reviewer 

196 

197 response = super().form_valid(form) 

198 

199 review: Review = self.object # type:ignore 

200 send_review_request_email( 

201 review, 

202 form.cleaned_data["request_email"], 

203 form.cleaned_data["request_email_subject"], 

204 self.request.build_absolute_uri("/"), 

205 ) 

206 

207 messages.success( 

208 self.request, 

209 _(f"Referee request successfully submitted to {review.reviewer}"), 

210 ) 

211 

212 # SubmissionLog.add_message( 

213 # self.version.submission, 

214 # content=_("Referee request sent to") + f" {review.reviewer}", 

215 # content_en=f"Referee request sent to {review.reviewer}", 

216 # user=self.request.user, 

217 # significant=True, 

218 # ) 

219 

220 return response 

221 

222 def get_success_url(self) -> str: 

223 return reverse_lazy("mesh:submission_details", kwargs={"pk": self.version.submission.pk}) 

224 

225 

226class ReviewEditBaseView(RoleMixin, UpdateView): 

227 """ 

228 Base view for a reviewer to edit a review. 

229 """ 

230 

231 model = Review 

232 template_name = "mesh/forms/form_full_page.html" 

233 review: Review 

234 restricted_roles = [Reviewer] 

235 

236 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs): 

237 pk = kwargs["pk"] 

238 self.review = get_object_or_404(Review, pk=pk) 

239 

240 return not self.role_handler.check_rights("can_edit_review", self.review) 

241 

242 def get_object(self, *args, **kwargs) -> Review: 

243 """ 

244 Override `get_object` built-in method to avoid an additional look-up when 

245 we already have the object loaded. 

246 """ 

247 return self.review 

248 

249 def get_initial(self) -> dict[str, Any]: 

250 return { 

251 "date_response_due_display": self.review.date_response_due, 

252 "date_review_due_display": self.review.date_review_due, 

253 } 

254 

255 

256class ReviewAcceptView(ReviewEditBaseView): 

257 """ 

258 Preliminary view for a reviewer to accept/decline a requested review. 

259 """ 

260 

261 form_class = ReviewAcceptForm 

262 

263 def get_initial(self) -> dict[str, Any]: 

264 initial = super().get_initial() 

265 accept_initial = self.request.GET.get("accepted", None) 

266 if accept_initial == "true": 

267 initial["accepted"] = True 

268 elif accept_initial == "false": 

269 initial["accepted"] = False 

270 return initial 

271 

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

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

274 context["page_title"] = "Accept/decline review request" 

275 

276 submission = self.review.version.submission 

277 submission_url = reverse_lazy("mesh:submission_details", kwargs={"pk": submission.pk}) 

278 # TODO: Create a custom template instead of passing HTML in variable ? 

279 description_1 = f"{self.review.created_by} requested your review for the submission " 

280 description_1 += f"<a href='{submission_url}'><i>{submission.name}</i></a>." 

281 description_2 = "Please accept or decline the review request by filling the below form." 

282 description_3 = "You must accept the request before proceeding with the actual review." 

283 context["form_description"] = [description_1, description_2, description_3] 

284 

285 # Generate breadcrumb data 

286 breadcrumb = get_submission_breadcrumb(submission) 

287 breadcrumb.add_item( 

288 title=_("Accept review"), 

289 url=reverse_lazy("mesh:review_accept", kwargs={"pk": self.review.pk}), 

290 ) 

291 context["breadcrumb"] = breadcrumb 

292 

293 return context 

294 

295 def get_success_url(self) -> str: 

296 if self.review.state == ReviewState.PENDING.value: 

297 return reverse_lazy("mesh:review_update", kwargs={"pk": self.review.pk}) 

298 

299 return reverse_lazy( 

300 "mesh:submission_details", kwargs={"pk": self.review.version.submission.pk} 

301 ) 

302 

303 def form_valid(self, form: ReviewAcceptForm) -> HttpResponse: 

304 accept_value = form.cleaned_data["accepted"] 

305 accept_comment = form.cleaned_data["accept_comment"] 

306 

307 self.review.accept(accept_value, accept_comment, user=self.request.user) 

308 

309 messages.success( 

310 self.request, 

311 ( 

312 _("Review request successfully accepted") 

313 if accept_value 

314 else _("Review request successfully declined") 

315 ), 

316 ) 

317 

318 return HttpResponseRedirect(self.get_success_url()) 

319 

320 

321class ReviewSubmitView(SubmittableModelFormMixin, ReviewEditBaseView): 

322 """ 

323 View for performing a review on a submission version. 

324 """ 

325 

326 form_class = ReviewSubmitForm 

327 add_confirm_message = False 

328 

329 def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: 

330 """ 

331 Redirect to the accept form if the review has not been accepted yet. 

332 """ 

333 if self.review.state in [ 

334 ReviewState.AWAITING_ACCEPTANCE.value, 

335 ReviewState.DECLINED.value, 

336 ]: 

337 # Forward all query parameters 

338 return HttpResponseRedirect( 

339 format_url_with_params( 

340 reverse("mesh:review_accept", kwargs={"pk": self.review.pk}), 

341 {k: v for k, v in request.GET.lists()}, 

342 ) 

343 ) 

344 return super().get(request, *args, **kwargs) 

345 

346 def get_success_url(self) -> str: 

347 if FormAction.SUBMIT.value in self.request.POST: 

348 return self.submit_url() 

349 return reverse_lazy( 

350 "mesh:submission_details", kwargs={"pk": self.review.version.submission.pk} 

351 ) 

352 

353 def submit_url(self) -> str: 

354 return reverse_lazy("mesh:review_confirm", kwargs={"pk": self.review.pk}) 

355 

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

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

358 context["page_title"] = "Submit review" 

359 

360 submission = self.review.version.submission 

361 submission_url = reverse_lazy("mesh:submission_details", kwargs={"pk": submission.pk}) 

362 # TODO: Create a custom template instead of passing HTML in variable 

363 description = f"Please submit your review for round #{self.review.version.number} " 

364 description += "of submission " 

365 description += f"<a href='{submission_url}'><i>{submission.name}</i></a> " 

366 description += "by filling the below form." 

367 context["form_description"] = [description] 

368 

369 # Generate breadcrumb data 

370 breadcrumb = get_submission_breadcrumb(submission) 

371 breadcrumb.add_item( 

372 title=_("Submit review"), 

373 url=reverse_lazy("mesh:review_update", kwargs={"pk": self.review.pk}), 

374 ) 

375 context["breadcrumb"] = breadcrumb 

376 

377 return context 

378 

379 def form_pre_save(self, form: ReviewSubmitForm) -> None: 

380 form.instance._user = self.request.user 

381 

382 def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: 

383 """ 

384 View for updating the review or deleting one of the associated files. 

385 """ 

386 deletion_requested, _ = post_delete_model_file(self.review, request, self.role_handler) 

387 

388 if deletion_requested: 

389 self.review = get_object_or_404(Review, pk=self.review.pk) 

390 return self.get(request, *args, **kwargs) 

391 

392 return super().post(request, *args, **kwargs) 

393 

394 

395class ReviewConfirmView(RoleMixin, FormView): 

396 """ 

397 View to confirm the submission of a review. 

398 """ 

399 

400 review: Review 

401 template_name = "mesh/review/review_confirm.html" 

402 form_class = ReviewConfirmForm 

403 

404 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs): 

405 pk = kwargs["pk"] 

406 self.review = get_object_or_404(Review, pk=pk) 

407 

408 return not self.role_handler.check_rights("can_submit_review", self.review) 

409 

410 def get_success_url(self): 

411 return reverse_lazy( 

412 "mesh:submission_details", kwargs={"pk": self.review.version.submission.pk} 

413 ) 

414 

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

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

417 

418 context["form"].buttons = [ 

419 Button( 

420 id="form_save", 

421 title=_("Submit"), 

422 icon_class="fa-check", 

423 attrs={"type": ["submit"], "class": ["save-button"]}, 

424 ) 

425 ] 

426 

427 context["review_proxy"] = ReviewProxy(self.review, self.role_handler) 

428 

429 files = [] 

430 for file_wrapper in self.review.additional_files.all(): # type:ignore 

431 files.append({"file_wrapper": file_wrapper, "type": "Review file"}) 

432 context["review_files"] = files 

433 

434 # Breadcrumb 

435 breadcrumb = get_submission_breadcrumb(self.review.version.submission) 

436 breadcrumb.add_item( 

437 title=_("Submit review"), 

438 url=reverse_lazy("mesh:review_update", kwargs={"pk": self.review.pk}), 

439 ) 

440 context["breadcrumb"] = breadcrumb 

441 

442 return context 

443 

444 def form_valid(self, form: ReviewConfirmForm) -> HttpResponse: 

445 self.review.submit(self.request.user) 

446 

447 messages.success(self.request, _("Your review has been successfully submitted.")) 

448 

449 return HttpResponseRedirect(self.get_success_url()) 

450 

451 

452REVIEW_FILE_INPUT_PREFIX = "review_file_" 

453REVIEW_FILE_INPUT_VALUE = "true" 

454 

455 

456class ReviewDetails(RoleMixin, TemplateView): 

457 """ 

458 Details view for a review. 

459 """ 

460 

461 template_name = "mesh/review/review_details.html" 

462 review: Review 

463 

464 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs) -> bool: 

465 self.review = get_object_or_404(Review, pk=kwargs["pk"]) 

466 

467 return not self.role_handler.check_rights("can_access_review", self.review) 

468 

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

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

471 context["review_proxy"] = ReviewProxy(self.review, self.role_handler) 

472 context["input_prefix"] = REVIEW_FILE_INPUT_PREFIX 

473 context["input_value"] = REVIEW_FILE_INPUT_VALUE 

474 

475 breadcrumb = get_submission_breadcrumb(self.review.version.submission) 

476 breadcrumb.add_item( 

477 title=_("Review details") + f" - {self.review.reviewer}", 

478 url=reverse_lazy("mesh:review_details", kwargs={"pk": self.review.pk}), 

479 ) 

480 context["breadcrumb"] = breadcrumb 

481 return context 

482 

483 

484class ReviewFileAccessUpdate(RoleMixin, View): 

485 """ 

486 View to edit the author access to a review's files. 

487 """ 

488 

489 review: Review 

490 form_cache: str 

491 

492 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs) -> bool: 

493 self.review = get_object_or_404(Review, pk=kwargs["pk"]) 

494 

495 return not self.role_handler.check_rights("can_edit_review_file_right", self.review) 

496 

497 def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: 

498 response = HttpResponseRedirect( 

499 reverse_lazy("mesh:review_details", kwargs={"pk": self.review.pk}) 

500 ) 

501 files: QuerySet[ReviewAdditionalFile] = self.review.additional_files.all() # type:ignore 

502 if not files: 

503 return response 

504 files: dict[int, ReviewAdditionalFile] = {f.pk: f for f in files} # type:ignore 

505 

506 # Check the POST data and look for the file inputs. Update data accordingly 

507 field_regex = re.compile(f"{REVIEW_FILE_INPUT_PREFIX}(?P<pk>[0-9]+)$") 

508 files_handled: list[int] = [] 

509 files_changed: list[ReviewAdditionalFile] = [] 

510 for field, values in request.POST.lists(): 

511 field_match = field_regex.match(field) 

512 if not field_match: 

513 continue 

514 if values[0] != REVIEW_FILE_INPUT_VALUE: 

515 continue 

516 file_pk = int(field_match.group("pk")) 

517 file = files.get(file_pk) 

518 if file is None: 

519 continue 

520 # The boolean already has the correct value 

521 if file.author_access: 

522 files_handled.append(file_pk) 

523 continue 

524 file.author_access = True 

525 file.save() 

526 files_handled.append(file_pk) 

527 files_changed.append(file) 

528 

529 # There is no input in the POST data for unchecked checkboxes 

530 for file_pk, file in files.items(): 

531 if file_pk not in files_handled: 

532 if not file.author_access: 

533 continue 

534 file.author_access = False 

535 file.save() 

536 files_changed.append(file) 

537 

538 messages.success(request, _("Successfully updated the author access right.")) 

539 

540 changed_files_str = ", ".join([f.name for f in files_changed]) 

541 if changed_files_str: 

542 message = f"Changed author access right to Round #{self.review.version.number}" 

543 message += f" files: {changed_files_str}" 

544 SubmissionLog.add_message( 

545 self.review.version.submission, 

546 content=message, 

547 content_en=message, 

548 user=self.request.user, 

549 significant=True, 

550 ) 

551 

552 return response 

553 

554 

555class ReviewFileAccessAPIView(RoleMixin, View): 

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

557 file = get_object_or_404(ReviewAdditionalFile, pk=kwargs["pk"]) 

558 file.author_access = not file.author_access 

559 file.save() 

560 

561 return JsonResponse({})