Coverage for sites/comments_site/comments_database/views.py: 94%

295 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-06-19 08:36 +0000

1from typing import Any 

2 

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 

10 

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 

17 

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 

41 

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 

52 

53 

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. 

60 

61 All methods expect a bunch of end-user rights in the form of query parameters. 

62 """ 

63 

64 permission_classes = [MathdocSitePermission] 

65 

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() 

72 

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) 

78 

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) 

85 

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.") 

103 

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() 

111 

112 filters_valid, query_filter = update_dashboard_query_filters( 

113 query_filter, request.query_params, admin, user, moderator 

114 ) 

115 

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 ) 

121 

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) 

127 

128 else: 

129 return response_with_error_message( 

130 f"Error: Either '{PARAM_DASHBOARD}' or '{PARAM_WEBSITE}' query parameter" 

131 " is required." 

132 ) 

133 

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) 

141 

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 ) 

156 

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 

161 

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) 

166 

167 return response_with_error_message( 

168 "Error when trying to serialize the comment.", base_data=comment_serializer.errors 

169 ) 

170 

171 

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() 

206 

207 

208class CommentDetailView(APIView): 

209 """ 

210 API view for retrieving and updating the data of a single comment. 

211 

212 All methods expect a bunch of end-user rights in the form of query parameters. 

213 """ 

214 

215 permission_classes = [MathdocSitePermission] 

216 

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"] 

237 

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() 

244 

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") 

251 

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() 

258 

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 ) 

267 

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 ) 

275 

276 return comment 

277 

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) 

283 

284 serializer = CommentSerializer(comment) 

285 return Response(serializer.data) 

286 

287 @transaction.atomic 

288 def put(self, request: Request, pk: int) -> Response: 

289 """ 

290 Updates or deletes an existing comment. 

291 

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) 

300 

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()) 

303 

304 moderator = int(request.query_params.get(PARAM_MODERATOR, 0)) 

305 status_code = data.get("status") 

306 

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 ) 

320 

321 data: dict[str, Any] = { 

322 k: v for k, v in data.items() if k not in self.author_only_fields 

323 } 

324 

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 

339 

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) 

359 

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) 

369 

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 

397 

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() 

401 

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() 

409 

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 ) 

416 

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() 

425 

426 return Response(serializer.data, status=HTTP_200) 

427 

428 return response_with_error_message( 

429 f"Error when trying to update the comment # {pk}.", base_data=serializer.errors 

430 ) 

431 

432 

433class ModeratorRightList(APIView): 

434 permission_classes = [MathdocSitePermission] 

435 

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.") 

454 

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 ) 

460 

461 if collections: 461 ↛ 468line 461 didn't jump to line 468

462 collections = [col.lower() for col in collections.split(",")] 

463 

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 ) 

473 

474 serializer = ModerationRightsReadSerializer(moderation_rights, many=True) 

475 return Response(serializer.data, status=HTTP_200) 

476 

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).") 

499 

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 ) 

508 

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() 

518 

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) 

531 

532 

533class NewCommentsList(APIView): 

534 permission_classes = [MathdocSitePermission] 

535 

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) 

546 

547 return comments 

548 

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) 

561 

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")) 

568 

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"]) 

576 

577 return Response(data.values(), status=HTTP_200) 

578 

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}).") 

586 

587 comments = self.get_comments(request) 

588 

589 comments.filter(pk__in=comments_id).update(is_new=False) 

590 

591 return Response({}, status=HTTP_200) 

592 

593 

594class CommentsSummaryView(APIView): 

595 permission_classes = [MathdocSitePermission] 

596 

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) 

615 

616 comments = ( 

617 comments.annotate(pid=F("site__username")) 

618 .values("pid", "status") 

619 .annotate(count=Count("pk")) 

620 ) 

621 

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"] 

629 

630 return Response(data, HTTP_200)