Coverage for sites/comments_site/comments_database/views.py: 94%
295 statements
« prev ^ index » next coverage.py v7.3.2, created at 2024-11-04 17:46 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2024-11-04 17:46 +0000
1from typing import Any
3from rest_framework.exceptions import APIException
4from rest_framework.request import Request
5from rest_framework.response import Response
6from rest_framework.status import HTTP_200_OK as HTTP_200
7from rest_framework.status import HTTP_201_CREATED as HTTP_201
8from rest_framework.status import HTTP_401_UNAUTHORIZED as HTTP_401
9from rest_framework.views import APIView
11from django.db import transaction
12from django.db.models import Count
13from django.db.models import F
14from django.db.models import Q
15from django.http import QueryDict
16from django.utils import timezone
18from comments_api.constants import PARAM_ACTION
19from comments_api.constants import PARAM_ACTION_CREATE
20from comments_api.constants import PARAM_ACTION_DELETE
21from comments_api.constants import PARAM_ADMIN
22from comments_api.constants import PARAM_BOOLEAN_VALUE
23from comments_api.constants import PARAM_COLLECTION
24from comments_api.constants import PARAM_COMMENT
25from comments_api.constants import PARAM_DASHBOARD
26from comments_api.constants import PARAM_DOI
27from comments_api.constants import PARAM_MODERATOR
28from comments_api.constants import PARAM_PREVIEW
29from comments_api.constants import PARAM_STATUS
30from comments_api.constants import PARAM_USER
31from comments_api.constants import PARAM_WEBSITE
32from comments_api.constants import STATUS_CAN_DELETE
33from comments_api.constants import STATUS_CAN_EDIT
34from comments_api.constants import STATUS_LIST
35from comments_api.constants import STATUS_PARENT_VALIDATED
36from comments_api.constants import STATUS_REJECTED
37from comments_api.constants import STATUS_SOFT_DELETED
38from comments_api.constants import STATUS_SUBMITTED
39from comments_api.constants import STATUS_VALIDATED
40from comments_api.utils import date_to_str
42from .model_helpers import get_all_comments
43from .model_helpers import get_comments_for_site
44from .models import Comment
45from .models import ModerationRights
46from .models import Moderator
47from .permissions import MathdocSitePermission
48from .serializers import CommentSerializer
49from .serializers import CommentSerializerCreate
50from .serializers import ModerationRightsReadSerializer
51from .utils import response_with_error_message
54class CommentListView(APIView):
55 """
56 API view used for:
57 - GET: retrieving the data of the comments matching the given filters
58 (query parameters).
59 - POST: creating a new comment.
61 All methods expect a bunch of end-user rights in the form of query parameters.
62 """
64 permission_classes = [MathdocSitePermission]
66 def get(self, request: Request) -> Response:
67 """
68 Returns all the comments related to the website performing the query,
69 ordered by `date_submitted`.
70 """
71 query_filter = Q()
73 # Query params result in queryset filtering.
74 # Common intersecting filters: DOI and status
75 doi = request.query_params.get(PARAM_DOI)
76 if doi:
77 query_filter = query_filter & Q(doi=doi)
79 status_code = request.query_params.get(PARAM_STATUS)
80 if status_code:
81 status_codes = status_code.split(",")
82 if not all([s in STATUS_LIST for s in status_codes]):
83 return response_with_error_message("Error: Query parameter 'status' malformed.")
84 query_filter = query_filter & Q(status__in=status_codes)
86 # There are 2 main modes:
87 # - website (for display on the article page)
88 # - dashboard (for display in comments dashboard)
89 try:
90 dashboard = request.query_params.get(PARAM_DASHBOARD)
91 if dashboard and dashboard != PARAM_BOOLEAN_VALUE:
92 raise ValueError
93 website = request.query_params.get(PARAM_WEBSITE)
94 if website and website != PARAM_BOOLEAN_VALUE:
95 raise ValueError
96 user = int(request.query_params.get(PARAM_USER, 0))
97 moderator = int(request.query_params.get(PARAM_MODERATOR, 0))
98 admin = request.query_params.get(PARAM_ADMIN)
99 if admin and admin != PARAM_BOOLEAN_VALUE: 99 ↛ 100line 99 didn't jump to line 100, because the condition on line 99 was never true
100 raise ValueError
101 except ValueError:
102 return response_with_error_message("Error: Query parameter(s) malformed.")
104 if dashboard:
105 # We filter the comments per site if the user is a comment author.
106 # Otherwise it should be a moderator.
107 if user:
108 comments = get_comments_for_site(request.user)
109 else:
110 comments = get_all_comments()
112 filters_valid, query_filter = update_dashboard_query_filters(
113 query_filter, request.query_params, admin, user, moderator
114 )
116 if not filters_valid:
117 return response_with_error_message(
118 f"Error: Either '{PARAM_ADMIN}', '{PARAM_MODERATOR}' or "
119 f"'{PARAM_USER}' is required."
120 )
122 elif website:
123 comments = get_comments_for_site(request.user)
124 preview_id = request.query_params.get(PARAM_PREVIEW)
125 if preview_id and user: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 query_filter = query_filter | Q(id=preview_id, author_id=user)
128 else:
129 return response_with_error_message(
130 f"Error: Either '{PARAM_DASHBOARD}' or '{PARAM_WEBSITE}' query parameter"
131 " is required."
132 )
134 serializer = CommentSerializer(
135 # We need to select DISTINCT row because the moderator condition filtering
136 # left joins on the comment moderators (MtoM).
137 comments.filter(query_filter).order_by("date_submitted", "id").distinct(),
138 many=True,
139 )
140 return Response(serializer.data, status=HTTP_200)
142 def post(self, request: Request) -> Response:
143 """
144 Creates a new comment.
145 """
146 # If the comment has a parent, it MUST be validated
147 data: dict[str, Any] = dict(request.data)
148 parent = data.get("parent")
149 if (
150 parent
151 and not Comment.objects.filter(pk=parent, status__in=STATUS_PARENT_VALIDATED).exists()
152 ):
153 return response_with_error_message(
154 "Error: The comment's parent does not exist or is not validated."
155 )
157 if not data.get("date_last_modified"): 157 ↛ 160line 157 didn't jump to line 160, because the condition on line 157 was never false
158 data["date_last_modified"] = date_to_str(timezone.now())
159 # Correctly populate the foreign keys
160 data["site"] = request.user.pk
162 comment_serializer = CommentSerializerCreate(data=data)
163 if comment_serializer.is_valid(): 163 ↛ 167line 163 didn't jump to line 167, because the condition on line 163 was never false
164 comment_serializer.save()
165 return Response(comment_serializer.data, status=HTTP_201)
167 return response_with_error_message(
168 "Error when trying to serialize the comment.", base_data=comment_serializer.errors
169 )
172def update_dashboard_query_filters(
173 query_filter: Q,
174 query_params: QueryDict,
175 admin: str | None,
176 user: int | None,
177 moderator: int | None,
178) -> tuple[bool, Q]:
179 """
180 Updates the filtering logic for the comment dataset query when the API request
181 is made for the comments dashboard.
182 Returns False if the given parameters are not valid (aka. malformed query params).
183 """
184 # Admin view (Mathdoc staff) - Get all comments
185 if admin and admin == PARAM_BOOLEAN_VALUE:
186 return True, query_filter
187 # Base user dashboard. The user can only access his/her comments.
188 elif user:
189 query_filter = query_filter & Q(author_id=user)
190 return True, query_filter
191 # Moderator (Trammel) - Union of:
192 # - all comments of the moderator's allowed collections.
193 # - all comments having a direct relation with this moderator
194 # (see ModeratorRights)
195 elif moderator:
196 collections = query_params.get(PARAM_COLLECTION)
197 if collections:
198 collections = [col.lower() for col in collections.split(",")]
199 query_filter = query_filter & (
200 Q(site__username__in=collections) | Q(moderators__id=moderator)
201 )
202 else:
203 query_filter = query_filter & Q(moderators__id=moderator)
204 return True, query_filter
205 return False, Q()
208class CommentDetailView(APIView):
209 """
210 API view for retrieving and updating the data of a single comment.
212 All methods expect a bunch of end-user rights in the form of query parameters.
213 """
215 permission_classes = [MathdocSitePermission]
217 # Fields available for modification when a comment already exists.
218 editable_fields = [
219 "status",
220 "sanitized_html",
221 "raw_html",
222 "date_last_modified",
223 "article_author_comment",
224 "editorial_team_comment",
225 "hide_author_name",
226 "validation_email_sent",
227 ]
228 # Mod fields only available for moderators
229 moderator_only_fields = [
230 "article_author_comment",
231 "editorial_team_comment",
232 "hide_author_name",
233 "validation_email_sent",
234 ]
235 # Mod fields only available for the comment's author
236 author_only_fields = ["sanitized_html", "raw_html"]
238 def get_comment(self, request: Request, pk: int) -> Comment:
239 """
240 Retrieves the existing requested comment according to the query parameters.
241 The query parameters indicate the "rights" of the end-user.
242 """
243 query_filter = Q()
245 try:
246 user = int(request.query_params.get(PARAM_USER, 0))
247 admin = request.query_params.get(PARAM_ADMIN)
248 moderator = int(request.query_params.get(PARAM_MODERATOR, 0))
249 except ValueError:
250 raise APIException("Error: Request malformed")
252 # We filter the comments per site if the user is a comment author.
253 # Otherwise it should be a moderator.
254 if user:
255 comment = get_comments_for_site(request.user)
256 else:
257 comment = get_all_comments()
259 filters_valid, query_filter = update_dashboard_query_filters(
260 query_filter, request.query_params, admin, user, moderator
261 )
262 if not filters_valid: 262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true
263 raise APIException(
264 f"Error: {PARAM_ADMIN}, {PARAM_USER} or"
265 + f" {PARAM_MODERATOR} filter is required."
266 )
268 try:
269 comment = comment.filter(query_filter).distinct().get(pk=pk)
270 except Comment.DoesNotExist:
271 raise APIException(
272 f"The requested comment (# {pk}) does not exist "
273 + "or you don't have the required right to see/edit it."
274 )
276 return comment
278 def get(self, request: Request, pk: int) -> Response:
279 try:
280 comment = self.get_comment(request, pk)
281 except APIException as e:
282 return response_with_error_message(e.detail)
284 serializer = CommentSerializer(comment)
285 return Response(serializer.data)
287 @transaction.atomic
288 def put(self, request: Request, pk: int) -> Response:
289 """
290 Updates or deletes an existing comment.
292 A comment is actually deleted (removed from DB) if its state allows it.
293 Otherwise it gets "soft deleted", ie. we remove its content, its author and
294 its status is changed to `deleted`.
295 """
296 try:
297 comment = self.get_comment(request, pk)
298 except APIException as e:
299 return response_with_error_message(e.detail)
301 data = {k: v for k, v in dict(request.data).items() if k in self.editable_fields}
302 now = date_to_str(timezone.now())
304 moderator = int(request.query_params.get(PARAM_MODERATOR, 0))
305 status_code = data.get("status")
307 # Check if the update is the moderation of the comment
308 if moderator:
309 if status_code not in [STATUS_VALIDATED, STATUS_REJECTED]:
310 return response_with_error_message(
311 "Error: Incorrect status update for a moderation."
312 )
313 # For a moderation (change of status), we need to assert the parent comment
314 # is in a valid state. This is slightly overkill as a comment should not be
315 # created if its parent is not in a valid state.
316 if comment.parent is not None and comment.parent.status not in STATUS_PARENT_VALIDATED: 316 ↛ 317line 316 didn't jump to line 317, because the condition on line 316 was never true
317 return response_with_error_message(
318 "Error: The parent comment is in an inconsistent state.", HTTP_401
319 )
321 data: dict[str, Any] = {
322 k: v for k, v in data.items() if k not in self.author_only_fields
323 }
325 # Forbidden to reject previously validated comment with children
326 if ( 326 ↛ 331line 326 didn't jump to line 331
327 status_code != comment.status
328 and status_code == STATUS_REJECTED
329 and comment.children.exists()
330 ):
331 return response_with_error_message(
332 "Error: The comment can't be rejected because it has replies.", HTTP_401
333 )
334 if status_code == STATUS_VALIDATED:
335 data["validation_email_sent"] = True
336 # Flag the comment as not new anymore when it gets moderated.
337 # Otherwise moderators could receive an e-mail about this "pending" comment.
338 data["is_new"] = False
340 else:
341 # Assert the user is the comment's author
342 if not int(request.query_params.get(PARAM_USER, 0)) == comment.author_id: 342 ↛ 343line 342 didn't jump to line 343, because the condition on line 342 was never true
343 return response_with_error_message(
344 "Error: You don't have sufficient rights to delete this comment.", HTTP_401
345 )
346 # Discard fields reserved for moderators
347 data = {k: v for k, v in data.items() if k not in self.moderator_only_fields}
348 # Check if the update is the deletion of the comment
349 if status_code == STATUS_SOFT_DELETED:
350 # DELETE_AFTER_MODERATION UPDATE: Deletion is only allowed if the
351 # comment has not been moderated yet
352 if comment.status not in STATUS_CAN_DELETE:
353 return response_with_error_message(
354 "Error: You can't delete a moderated which has " "already been processed.",
355 HTTP_401,
356 )
357 comment.delete()
358 return Response({}, status=HTTP_200)
360 # DELETE_AFTER_MODERATION UPDATE - Disabled for now
361 # # Actually deletes the comment from the databse if it's not
362 # # published yet or if it has not children
363 # if (
364 # comment.status in STATUS_CAN_DELETE
365 # or not comment.children.exists()
366 # ):
367 # comment.delete()
368 # return Response({}, status=HTTP_200)
370 # # The comment has already been published - we will just empty its content
371 # data = {
372 # "status": STATUS_SOFT_DELETED,
373 # "date_last_modified": now,
374 # "sanitized_html": "<p>This comment has been deleted.</p>",
375 # "raw_html": "<p>This comment has been deleted.</p>",
376 # "author_id": None,
377 # "author_email": None,
378 # "author_first_name": None,
379 # "author_last_name": None,
380 # "author_provider": None,
381 # "author_provider_uid": None,
382 # }
383 # EDIT case
384 else:
385 if comment.status not in STATUS_CAN_EDIT:
386 return response_with_error_message(
387 "Error: You can't edit a comment which has " "already been processed.",
388 HTTP_401,
389 )
390 elif comment.moderators.exists():
391 return response_with_error_message(
392 "Error: You can't edit a comment with assigned moderators.", HTTP_401
393 )
394 data = {k: v for k, v in data.items() if k in self.author_only_fields}
395 if "date_last_modified" not in data: 395 ↛ 398line 395 didn't jump to line 398, because the condition on line 395 was never false
396 data["date_last_modified"] = now
398 serializer = CommentSerializerCreate(comment, data=data, partial=True)
399 if serializer.is_valid(): 399 ↛ 428line 399 didn't jump to line 428, because the condition on line 399 was never false
400 serializer.save()
402 # For a moderation update/create the comment moderation right
403 if moderator:
404 try:
405 moderator_obj = Moderator.objects.get(pk=moderator)
406 except Moderator.DoesNotExist:
407 moderator_obj = Moderator(id=moderator)
408 moderator_obj.save()
410 try:
411 moderation_right = moderator_obj.moderationrights_set.get(comment__id=pk)
412 except ModerationRights.DoesNotExist:
413 moderation_right = ModerationRights(
414 moderator=moderator_obj, comment=comment, date_created=now
415 )
417 # Update the moderated date only for status change
418 if (
419 moderation_right.status != status_code
420 or moderation_right.date_moderated is None
421 ):
422 moderation_right.date_moderated = now
423 moderation_right.status = status_code
424 moderation_right.save()
426 return Response(serializer.data, status=HTTP_200)
428 return response_with_error_message(
429 f"Error when trying to update the comment # {pk}.", base_data=serializer.errors
430 )
433class ModeratorRightList(APIView):
434 permission_classes = [MathdocSitePermission]
436 def get(self, request: Request) -> Response:
437 """
438 Filters the moderation rights related to comments in the queried collections.
439 Returns all the moderation rights of the pending (submitted) comments
440 and the used moderation rights of the other (processed) comments.
441 Returns:
442 [
443 {
444 "moderator_id": int,
445 "comment_id": int,
446 "comment__status": str
447 }
448 ]
449 """
450 admin = request.query_params.get(PARAM_ADMIN, "")
451 collections = request.query_params.get(PARAM_COLLECTION, "")
452 if not admin == PARAM_BOOLEAN_VALUE and not collections:
453 return response_with_error_message("Error: missing or malformed query parameter.")
455 pending_moderation = ModerationRights.objects.filter(comment__status=STATUS_SUBMITTED)
456 performed_moderation = ModerationRights.objects.filter(
457 ~Q(comment__status=STATUS_SUBMITTED),
458 date_moderated__isnull=False,
459 )
461 if collections: 461 ↛ 468line 461 didn't jump to line 468
462 collections = [col.lower() for col in collections.split(",")]
464 pending_moderation = pending_moderation.filter(comment__site__username__in=collections)
465 performed_moderation = ModerationRights.objects.filter(
466 comment__site__username__in=collections,
467 )
468 moderation_rights = (
469 pending_moderation.union(performed_moderation)
470 .values("moderator_id", "comment_id", "comment__status")
471 .order_by("moderator_id", "comment_id")
472 )
474 serializer = ModerationRightsReadSerializer(moderation_rights, many=True)
475 return Response(serializer.data, status=HTTP_200)
477 @transaction.atomic
478 def post(self, request: Request) -> Response:
479 """
480 Handles 2 cases:
481 - Create a new moderator right. Also create a moderator if it does not exist.
482 - Delete a bulk of moderator rights.
483 """
484 # Check the provided moderator data
485 try:
486 # PARAM_MODERATOR is either a single int or comma-separated ints
487 moderator_id = request.data[PARAM_MODERATOR]
488 if not isinstance(moderator_id, int) and "," in moderator_id:
489 moderator_id = [int(val) for val in moderator_id.split(",")]
490 else:
491 moderator_id = int(moderator_id)
492 comment_id = int(request.data[PARAM_COMMENT])
493 collections = request.query_params[PARAM_COLLECTION]
494 action = request.query_params[PARAM_ACTION]
495 if action not in [PARAM_ACTION_CREATE, PARAM_ACTION_DELETE]:
496 raise ValueError
497 except (KeyError, ValueError):
498 return response_with_error_message("Error: missing or malformed parameter(s).")
500 collections = [col.lower() for col in collections.split(",")]
501 try:
502 comment = Comment.objects.filter(site__username__in=collections).get(pk=comment_id)
503 except Comment.DoesNotExist:
504 return response_with_error_message(
505 "Error: The provided comment does not exist or you don't have "
506 "the rights to manage its moderators."
507 )
509 # Creation
510 if action == PARAM_ACTION_CREATE:
511 if not isinstance(moderator_id, int):
512 return response_with_error_message("Error: malformed parameter 'moderator_id'.")
513 try:
514 moderator = Moderator.objects.get(pk=moderator_id)
515 except Moderator.DoesNotExist:
516 moderator = Moderator(id=moderator_id)
517 moderator.save()
519 now = timezone.now()
520 mod_right = ModerationRights(moderator=moderator, comment=comment, date_created=now)
521 mod_right.save()
522 return Response(ModerationRightsReadSerializer(mod_right).data, status=HTTP_201)
523 # Deletion
524 else:
525 if not isinstance(moderator_id, list):
526 moderator_id = [moderator_id]
527 ModerationRights.objects.filter(
528 comment=comment, moderator_id__in=moderator_id
529 ).delete()
530 return Response({}, status=HTTP_200)
533class NewCommentsList(APIView):
534 permission_classes = [MathdocSitePermission]
536 def get_comments(self, request: Request):
537 """
538 Gets the comments dataset related to the given request from its
539 query parameters.
540 """
541 comments = Comment.objects.filter(is_new=True)
542 collections = request.query_params.get(PARAM_COLLECTION)
543 if collections:
544 collections = [col.lower() for col in collections.split(",")]
545 comments = comments.filter(site__username__in=collections)
547 return comments
549 def get(self, request: Request) -> Response:
550 """
551 Returns the list of new comments' ID, grouped by site.
552 Returns:
553 [
554 {
555 "pid": str,
556 "comments": list(int)
557 }
558 ]
559 """
560 comments = self.get_comments(request)
562 # Cool way to do it, but shitty database engines don't use the same function name
563 # It's GROUP_CONCAT for MySQL & SQLLite
564 # It's ARRAY_AGG/STRING_AGG for PostgreSQL
565 # data = comments.annotate(pid=F("site__username")) \
566 # .values("pid") \
567 # .annotate(comments=ArrayAgg("pk"))
569 # Need additional post-processing to aggregate
570 data = {}
571 for comment in comments.annotate(pid=F("site__username")).values("pid", "pk"):
572 pid = comment["pid"]
573 if pid not in data:
574 data[pid] = {"pid": pid, "comments": []}
575 data[pid]["comments"].append(comment["pk"])
577 return Response(data.values(), status=HTTP_200)
579 def post(self, request: Request) -> Response:
580 """
581 Marks the given comment IDs as not new anymore.
582 """
583 comments_id = request.data.get(PARAM_COMMENT)
584 if not isinstance(comments_id, list): 584 ↛ 585line 584 didn't jump to line 585, because the condition on line 584 was never true
585 return response_with_error_message(f"Error: malformed parameter ({PARAM_COMMENT}).")
587 comments = self.get_comments(request)
589 comments.filter(pk__in=comments_id).update(is_new=False)
591 return Response({}, status=HTTP_200)
594class CommentsSummaryView(APIView):
595 permission_classes = [MathdocSitePermission]
597 def get(self, request: Request) -> Response:
598 """
599 Returns the summary of number of comments per status, per site (collection).
600 Returns:
601 {
602 "Site 1: {
603 "Status 1": nb,
604 "Status 2": nb
605 },
606 "Site 2: {...},
607 ...
608 }
609 """
610 comments = Comment.objects.all()
611 collections = request.query_params.get(PARAM_COLLECTION)
612 if collections:
613 collections = [col.lower() for col in collections.split(",")]
614 comments = comments.filter(site__username__in=collections)
616 comments = (
617 comments.annotate(pid=F("site__username"))
618 .values("pid", "status")
619 .annotate(count=Count("pk"))
620 )
622 data = {}
623 for comment in comments:
624 pid = comment["pid"]
625 status = comment["status"]
626 if pid not in data:
627 data[pid] = {}
628 data[pid][status] = comment["count"]
630 return Response(data, HTTP_200)