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
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +0000
1import re
2from typing import Any
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
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
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
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 """
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]
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)
54 return not self.role_handler.check_rights("can_invite_reviewer", self.version)
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)
73 return kwargs
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 }
83 def get_context_data(self, *args, **kwargs):
84 context = super().get_context_data(*args, **kwargs)
85 context["page_title"] = "Request review"
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>."
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
97 self.suggestions = Suggestion.objects.filter(submission=submission).order_by("seq")
98 context["suggestions"] = self.suggestions
100 self.existing_reviewers = [review.reviewer for review in self.version.reviews.all()]
101 context["existing_reviewers"] = self.existing_reviewers
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
123 context["form_description"] = [description, action]
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
133 return context
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
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 = ""
148 # TODO: check if the reviewer has already been assigned
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))
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"]
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)
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()
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()
192 old_suggested_user.delete()
194 # We now know the reviewer. Set the form.instance so that Django can create a Review
195 form.instance.reviewer = reviewer
197 response = super().form_valid(form)
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 )
207 messages.success(
208 self.request,
209 _(f"Referee request successfully submitted to {review.reviewer}"),
210 )
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 # )
220 return response
222 def get_success_url(self) -> str:
223 return reverse_lazy("mesh:submission_details", kwargs={"pk": self.version.submission.pk})
226class ReviewEditBaseView(RoleMixin, UpdateView):
227 """
228 Base view for a reviewer to edit a review.
229 """
231 model = Review
232 template_name = "mesh/forms/form_full_page.html"
233 review: Review
234 restricted_roles = [Reviewer]
236 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs):
237 pk = kwargs["pk"]
238 self.review = get_object_or_404(Review, pk=pk)
240 return not self.role_handler.check_rights("can_edit_review", self.review)
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
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 }
256class ReviewAcceptView(ReviewEditBaseView):
257 """
258 Preliminary view for a reviewer to accept/decline a requested review.
259 """
261 form_class = ReviewAcceptForm
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
272 def get_context_data(self, *args, **kwargs):
273 context = super().get_context_data(*args, **kwargs)
274 context["page_title"] = "Accept/decline review request"
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]
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
293 return context
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})
299 return reverse_lazy(
300 "mesh:submission_details", kwargs={"pk": self.review.version.submission.pk}
301 )
303 def form_valid(self, form: ReviewAcceptForm) -> HttpResponse:
304 accept_value = form.cleaned_data["accepted"]
305 accept_comment = form.cleaned_data["accept_comment"]
307 self.review.accept(accept_value, accept_comment, user=self.request.user)
309 messages.success(
310 self.request,
311 (
312 _("Review request successfully accepted")
313 if accept_value
314 else _("Review request successfully declined")
315 ),
316 )
318 return HttpResponseRedirect(self.get_success_url())
321class ReviewSubmitView(SubmittableModelFormMixin, ReviewEditBaseView):
322 """
323 View for performing a review on a submission version.
324 """
326 form_class = ReviewSubmitForm
327 add_confirm_message = False
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)
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 )
353 def submit_url(self) -> str:
354 return reverse_lazy("mesh:review_confirm", kwargs={"pk": self.review.pk})
356 def get_context_data(self, *args, **kwargs):
357 context = super().get_context_data(*args, **kwargs)
358 context["page_title"] = "Submit review"
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]
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
377 return context
379 def form_pre_save(self, form: ReviewSubmitForm) -> None:
380 form.instance._user = self.request.user
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)
388 if deletion_requested:
389 self.review = get_object_or_404(Review, pk=self.review.pk)
390 return self.get(request, *args, **kwargs)
392 return super().post(request, *args, **kwargs)
395class ReviewConfirmView(RoleMixin, FormView):
396 """
397 View to confirm the submission of a review.
398 """
400 review: Review
401 template_name = "mesh/review/review_confirm.html"
402 form_class = ReviewConfirmForm
404 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs):
405 pk = kwargs["pk"]
406 self.review = get_object_or_404(Review, pk=pk)
408 return not self.role_handler.check_rights("can_submit_review", self.review)
410 def get_success_url(self):
411 return reverse_lazy(
412 "mesh:submission_details", kwargs={"pk": self.review.version.submission.pk}
413 )
415 def get_context_data(self, *args, **kwargs):
416 context = super().get_context_data(*args, **kwargs)
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 ]
427 context["review_proxy"] = ReviewProxy(self.review, self.role_handler)
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
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
442 return context
444 def form_valid(self, form: ReviewConfirmForm) -> HttpResponse:
445 self.review.submit(self.request.user)
447 messages.success(self.request, _("Your review has been successfully submitted."))
449 return HttpResponseRedirect(self.get_success_url())
452REVIEW_FILE_INPUT_PREFIX = "review_file_"
453REVIEW_FILE_INPUT_VALUE = "true"
456class ReviewDetails(RoleMixin, TemplateView):
457 """
458 Details view for a review.
459 """
461 template_name = "mesh/review/review_details.html"
462 review: Review
464 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs) -> bool:
465 self.review = get_object_or_404(Review, pk=kwargs["pk"])
467 return not self.role_handler.check_rights("can_access_review", self.review)
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
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
484class ReviewFileAccessUpdate(RoleMixin, View):
485 """
486 View to edit the author access to a review's files.
487 """
489 review: Review
490 form_cache: str
492 def restrict_dispatch(self, request: HttpRequest, *args, **kwargs) -> bool:
493 self.review = get_object_or_404(Review, pk=kwargs["pk"])
495 return not self.role_handler.check_rights("can_edit_review_file_right", self.review)
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
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)
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)
538 messages.success(request, _("Successfully updated the author access right."))
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 )
552 return response
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()
561 return JsonResponse({})