Coverage for src/comments_views/journal/views.py: 86%
215 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-09 14:49 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-09 14:49 +0000
1import re
2from typing import Any
4from django.contrib import messages
5from django.contrib.auth import REDIRECT_FIELD_NAME, logout
6from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
7from django.urls import reverse
8from django.utils import timezone
9from django.utils.translation import get_language
10from django.utils.translation import gettext_lazy as _
11from django.views.decorators.http import require_http_methods
12from django.views.generic import TemplateView, View
13from ptf.model_helpers import get_article_by_doi
14from ptf.url_utils import add_fragment_to_url, format_url_with_params, validate_url
15from ptf.utils import ckeditor_input_sanitizer, send_email_from_template
17from comments_api.constants import (
18 PARAM_BOOLEAN_VALUE,
19 PARAM_DOI,
20 PARAM_PREVIEW,
21 PARAM_STATUS,
22 PARAM_WEBSITE,
23 STATUS_SOFT_DELETED,
24 STATUS_SUBMITTED,
25 STATUS_VALIDATED,
26)
27from comments_api.utils import date_to_str
29from ..core.forms import CommentForm, CommentFormAutogrow
30from ..core.mixins import AbstractCommentRightsMixin
31from ..core.rights import AbstractUserRights
32from ..core.utils import (
33 comments_credentials,
34 comments_server_url,
35 format_comment,
36 get_comment,
37 make_api_request,
38)
39from ..core.views import CommentChangeStatusView as BaseCommentChangeStatusView
40from ..core.views import CommentDashboardDetailsView as BaseCommentDashboardDetailsView
41from ..core.views import CommentDashboardListView as BaseCommentDashboardListView
42from .app_settings import app_settings
43from .mixins import OIDCCommentRightsMixin, SSOLoginRequiredMixin
44from .models import OIDCUser
45from .utils import add_pending_comment, delete_pending_comment, get_pending_comment
48@require_http_methods(["GET"])
49def reset_session(request: HttpRequest) -> HttpResponse:
50 """
51 Temporary function to reset the current user session data.
52 """
53 request.session.flush()
54 return HttpResponse("<p>Current session has been reseted</p>")
57@require_http_methods(["POST"])
58def logout_view(request: HttpRequest) -> HttpResponseRedirect:
59 """
60 Base view to logout an OIDC authenticated user.
61 We simply call auth.logout method. We could imagine logging out of the SSO
62 server too if required.
63 """
64 logout(request)
65 return HttpResponseRedirect(reverse("home"))
68class SSOLoginView(TemplateView):
69 """
70 Base login view when SSO authentication is required.
71 If the user is already SSO authenticated, he/she is redirected to the provided
72 redirect URL, else home.
73 """
75 template_name = "dashboard/comment_login.html"
77 def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
78 context = super().get_context_data(**kwargs)
79 auth_url = reverse("oidc_authentication_init")
80 next_request = self.request.GET.get("next")
81 if next_request: 81 ↛ 83line 81 didn't jump to line 83 because the condition on line 81 was always true
82 auth_url = format_url_with_params(auth_url, {"next": next_request})
83 context["authentication_url"] = auth_url
85 return context
87 def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
88 if isinstance(request.user, OIDCUser):
89 redirect_uri = request.GET.get(REDIRECT_FIELD_NAME, "/")
90 return HttpResponseRedirect(redirect_uri)
91 return super().dispatch(request, *args, **kwargs)
94class CommentSectionView(OIDCCommentRightsMixin, AbstractCommentRightsMixin, TemplateView):
95 template_name = "common/article_comments.html"
97 def get_context_data(self, **kwargs):
98 context = super().get_context_data(**kwargs)
100 redirect_url = self.request.GET.get("redirect_url")
101 if not redirect_url: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 raise Exception("Bad request: `redirect_url` parameter is missing")
103 if not validate_url(redirect_url): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true
104 raise Exception(
105 f"Bad request: `redirect_url` parameter is malformed. You passed {redirect_url}"
106 )
108 doi = kwargs["doi"]
109 preview_id = self.request.GET.get(PARAM_PREVIEW)
110 rights = self.get_rights(self.request.user)
111 context["ordered_comments"] = get_resource_comments(doi, rights, preview_id)
113 # Add comment form for SSO authenticated users.
114 # The authentication is provided by an external (custom) OIDC server.
115 is_user_oidc_authenticated = False
117 current_language = get_language()
118 consent_text_list = [
119 item["content"]
120 for item in app_settings.CONSENT_TEXT
121 if item["lang"] == current_language
122 ]
123 consent_text_list = (
124 consent_text_list[0] if consent_text_list else app_settings.CONSENT_TEXT[0]["content"]
125 )
126 context["consent_text_list"] = consent_text_list
128 if isinstance(self.request.user, OIDCUser):
129 is_user_oidc_authenticated = True
130 default_comment_form = CommentForm(prefix="default", initial={"doi": doi})
131 reply_comment_form = CommentForm(prefix="reply", initial={"doi": doi})
133 default_parent = ":~:init:~:"
134 default_parent_author_name = ":~:init:~:"
136 # Check if we should display the reply form
137 comment_reply_to = self.request.GET.get("commentReplyTo")
138 if comment_reply_to: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 try:
140 parent, parent_author_name = comment_reply_to.split(":~:")
141 parent = int(parent)
142 reply_comment_form = CommentForm(
143 prefix="reply",
144 initial={
145 "doi": doi,
146 "parent": parent,
147 "parent_author_name": parent_author_name,
148 },
149 )
150 default_parent = parent
151 default_parent_author_name = parent_author_name
152 context["display_reply_form"] = True
153 except Exception:
154 pass
156 # Check if there's a pending comment for that resource.
157 # In that case we need to prefill it.
158 pending_comment = get_pending_comment(self.request, doi)
160 if pending_comment:
161 prefix = pending_comment["prefix"]
162 comment = pending_comment["comment"]
163 comment_form = CommentForm(comment, prefix=prefix)
164 if not comment_form.is_valid(): 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true
165 delete_pending_comment(self.request, doi)
166 else:
167 # Replace the initial form with the pending data
168 if prefix == "reply": 168 ↛ 177line 168 didn't jump to line 177 because the condition on line 168 was always true
169 # For the reply form, we need to retrieve the parent data
170 default_parent = comment_form.cleaned_data["parent"]
171 default_parent_author_name = comment_form.cleaned_data[
172 "parent_author_name"
173 ]
174 context["display_reply_form"] = True
175 reply_comment_form = comment_form
176 else:
177 default_comment_form = comment_form
179 submit_base_url = reverse("submit_comment")
180 params = {"redirect_url": redirect_url, "doi": doi, "form_prefix": "default"}
181 context["default_comment_form"] = {
182 "author_name": self.request.user.name,
183 "date_submitted": timezone.now(),
184 "submit_url": format_url_with_params(submit_base_url, params),
185 "form_prefix": "default",
186 "form": default_comment_form,
187 }
188 params["form_prefix"] = "reply"
189 context["reply_comment_form"] = {
190 "author_name": self.request.user.name,
191 "date_submitted": timezone.now(),
192 "parent": default_parent,
193 "parent_author_name": default_parent_author_name,
194 "submit_url": format_url_with_params(submit_base_url, params),
195 "form_prefix": "reply",
196 "form": reply_comment_form,
197 }
199 else:
200 redirect_url = add_fragment_to_url(redirect_url, "add-comment-default")
201 context["authentication_url"] = format_url_with_params(
202 self.request.build_absolute_uri(reverse("oidc_authentication_init")),
203 {"next": redirect_url},
204 )
206 context["is_user_oidc_authenticated"] = is_user_oidc_authenticated
207 return context
210class SubmitCommentView(OIDCCommentRightsMixin, AbstractCommentRightsMixin, View):
211 """
212 This view does not use SSOLoginRequiredMixin, it's checked manually instead.
213 This enables us to cache the comment in the session in case the user was disconnected
214 so we can pre-populate the form the next time he/she visits the page.
215 """
217 def post(
218 self, request: HttpRequest, *args, **kwargs
219 ) -> HttpResponseRedirect | HttpResponseBadRequest:
220 """
221 Posts the provided comment data to the comment server if the data is valid
222 and the user is authenticated.
224 We store the comment data in the session if the form is valid but something
225 goes wrong (user not authenticated, HTTP error from comment server, ...).
226 The user is redirected to the remote authentication page if he's not logged
227 in.
228 """
229 # Check that the required query parameters are present and valid
230 redirect_url = request.GET.get("redirect_url")
231 if not redirect_url:
232 return HttpResponseBadRequest("Bad request: `redirect_url` parameter is missing")
233 if not validate_url(redirect_url):
234 return HttpResponseBadRequest(
235 f"Bad request: `redirect_url` parameter is malformed. You passed `{redirect_url}`"
236 )
237 response = HttpResponseRedirect(redirect_url)
239 form_prefix = request.GET.get("form_prefix")
240 if form_prefix and form_prefix not in ["default", "reply"]:
241 messages.error(
242 request,
243 f"Bad request: `form_prefix` parameter is malformed. You passed `{form_prefix}`",
244 )
245 return response
247 # For an update, id is present and the form is slightly different
248 comment_id = request.POST.get("id")
249 if comment_id:
250 comment_form = CommentFormAutogrow(request.POST, prefix=form_prefix)
251 else:
252 comment_form = CommentForm(request.POST, prefix=form_prefix)
254 if not comment_form.is_valid():
255 messages.error(
256 request,
257 _(
258 "Une erreur s'est glissée dans le formulaire, votre commentaire n'a "
259 "pas été enregistré. Veuillez réessayer."
260 ),
261 )
262 return response
264 comment_data = comment_form.cleaned_data
265 doi = comment_data["doi"]
266 # Check that the DOI is attached to an article of the current site
267 article = get_article_by_doi(doi)
268 if not article:
269 messages.error(request, _("L'article sélectionné n'existe pas."))
270 return response
272 # Temporary stores the request data in session
273 add_pending_comment(request, doi, {"comment": request.POST, "prefix": form_prefix})
274 content = comment_data["content"]
275 now = date_to_str(timezone.now())
276 date_submitted = comment_data.get("date_submitted")
277 comment = {
278 "doi": doi,
279 "date_submitted": date_submitted if date_submitted else now,
280 "date_last_modified": now,
281 "raw_html": content,
282 "sanitized_html": ckeditor_input_sanitizer(content),
283 "parent": comment_data.get("parent"),
284 }
286 redirect_url = add_fragment_to_url(
287 redirect_url,
288 "add-comment-default" if form_prefix == "default" else "add-comment-reply",
289 )
291 # Check if the user is authenticated with SSO.
292 if not isinstance(request.user, OIDCUser):
293 # Redirect to the remote server authentication page.
294 authentication_url = format_url_with_params(
295 reverse("oidc_authentication_init"), {"next": redirect_url}
296 )
297 messages.warning(
298 request,
299 _(
300 "Vous avez été déconnecté. Votre commentaire a été sauvegardé."
301 "Veuillez le soumettre à nouveau."
302 ),
303 )
304 return HttpResponseRedirect(authentication_url)
306 content_no_tag = re.sub(r"<.*?>|\n|\r|\t", "", content)
308 if not content or len(content_no_tag) <= 15:
309 messages.error(
310 request, _("Un commentaire doit avoir au moins 15 caractères pour être valide.")
311 )
312 return response
314 # Add author data
315 author_data = request.user.claims
316 comment.update(
317 {
318 "author_id": request.user.get_user_id(),
319 "author_email": author_data["email"],
320 "author_first_name": author_data.get("first_name"),
321 "author_last_name": author_data.get("last_name"),
322 "author_provider": author_data.get("provider"),
323 "author_provider_uid": author_data.get("provider_uid"),
324 }
325 )
327 # If the ID is present, it's an update
328 update = comment_data["id"] is not None
329 if update:
330 rights = self.get_rights(request.user)
331 query_params = rights.comment_rights_query_params()
332 error, initial_comment = get_comment(
333 query_params, comment_data["id"], request_for_message=request
334 )
335 if error: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 return response
338 # Make sure the user has rights to edit the comment
339 if not rights.comment_can_edit(initial_comment):
340 messages.error(request, "Error: You can't edit the comment.")
341 return response
343 error, result_data = make_api_request(
344 "PUT",
345 comments_server_url(query_params, item_id=comment_data["id"]),
346 request_for_message=request,
347 auth=comments_credentials(),
348 json=comment,
349 )
350 # Else, post a new comment
351 else:
352 error, result_data = make_api_request(
353 "POST",
354 comments_server_url(),
355 request_for_message=request,
356 auth=comments_credentials(),
357 json=comment,
358 )
360 if error:
361 return response
363 messages.success(
364 request,
365 _(
366 "Votre commentaire a été enregistré. Il sera publié après validation par un modérateur."
367 ),
368 )
369 delete_pending_comment(request, doi)
370 redirect_url = add_fragment_to_url(redirect_url, "")
371 # Send a confirmation mail to the author of the comment
372 if not update:
373 context_data = {
374 "full_name": f"{author_data.get('first_name')} {author_data.get('last_name')}",
375 "article_url": request.build_absolute_uri(reverse("article", kwargs={"aid": doi})),
376 "article_title": article.title_tex,
377 "comment_dashboard_url": request.build_absolute_uri(reverse("comment_list")),
378 "email_signature": "The editorial team",
379 }
380 subject = f"[{result_data['site_name'].upper()}] Confirmation of a new comment"
381 send_email_from_template(
382 "mail/comment_new.html",
383 context_data,
384 subject,
385 to=[author_data["email"]],
386 from_collection=result_data["site_name"],
387 )
389 return HttpResponseRedirect(redirect_url)
392def get_resource_comments(doi: str, rights: AbstractUserRights, preview_id: Any) -> list:
393 """
394 Return all the comments of a resource, ordered by validated date, in a object
395 structured as follow.
396 A comment's children are added to the `children` list if `COMMENTS_NESTING`
397 is set to `True`, otherwise they are appended to the list as regular comments.
399 Returns:
400 [
401 {
402 'content': top_level_comment,
403 'children': [child_comment_1, child_comment_2, ...]
404 }
405 ]
406 """
407 # Retrieve the comments from the remote server
408 # We assume this returns the comments ordered by `date_submitted`
409 query_params = rights.comment_rights_query_params()
410 query_params.update(
411 {
412 PARAM_WEBSITE: PARAM_BOOLEAN_VALUE,
413 PARAM_DOI: doi,
414 PARAM_STATUS: f"{STATUS_VALIDATED},{STATUS_SOFT_DELETED}",
415 }
416 )
418 if preview_id: 418 ↛ 419line 418 didn't jump to line 419 because the condition on line 418 was never true
419 try:
420 preview_id = int(preview_id)
421 query_params[PARAM_PREVIEW] = preview_id
422 except ValueError:
423 preview_id = None
425 url = comments_server_url(query_params)
426 error, comments = make_api_request("GET", url, auth=comments_credentials())
427 if error: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 raise Exception(f"Something went wrong while performing API call to {url}")
430 # Contains the ordered comments, ready for display
431 ordered_comments = []
433 #### Only used when nesting is True
434 # Contains the index of the top level parent in the ordered_comments list
435 top_level_comment_order = {}
436 # Stores the top level parent for each child comment
437 # This enable immediate lookup of the top level parent of a multi-level child comment
438 child_top_level_parent = {}
439 ####
441 for comment in comments:
442 # Format data
443 format_comment(comment)
444 if comment.get("status") != STATUS_SUBMITTED:
445 comment["show_buttons"] = True
447 if app_settings.COMMENTS_NESTING:
448 # Place the comment at the correct position
449 id = comment["id"]
450 parent = comment.get("parent")
451 parent_id = parent.get("id") if parent else None
452 # Whether it's a child comment
453 if parent_id:
454 # Whether the parent comment is a top level comment
455 if parent_id in top_level_comment_order: 455 ↛ 462line 455 didn't jump to line 462 because the condition on line 455 was always true
456 child_top_level_parent[id] = parent_id
457 top_level_parent_index = top_level_comment_order[parent_id]
458 if "children" not in ordered_comments[top_level_parent_index]: 458 ↛ 460line 458 didn't jump to line 460 because the condition on line 458 was always true
459 ordered_comments[top_level_parent_index]["children"] = []
460 ordered_comments[top_level_parent_index]["children"].append(comment)
461 else:
462 top_level_parent_id = child_top_level_parent[parent_id]
463 child_top_level_parent[id] = top_level_parent_id
464 top_level_parent_index = top_level_comment_order[top_level_parent_id]
465 ordered_comments[top_level_parent_index]["children"].append(comment)
466 # Top level comment
467 else:
468 top_level_comment_order[id] = len(ordered_comments)
469 ordered_comments.append({"content": comment})
470 else:
471 ordered_comments.append({"content": comment})
473 return ordered_comments
476class CommentDashboardListView(
477 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardListView
478):
479 pass
482class CommentDashboardDetailsView(
483 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardDetailsView
484):
485 pass
488class CommentChangeStatusView(
489 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentChangeStatusView
490):
491 pass