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