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

1import re 

2from typing import Any 

3 

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 

16 

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 

28 

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 

46 

47 

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

55 

56 

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

66 

67 

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

74 

75 template_name = "dashboard/comment_login.html" 

76 

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 

84 

85 return context 

86 

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) 

92 

93 

94class CommentSectionView(OIDCCommentRightsMixin, AbstractCommentRightsMixin, TemplateView): 

95 template_name = "common/article_comments.html" 

96 

97 def get_context_data(self, **kwargs): 

98 context = super().get_context_data(**kwargs) 

99 

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 ) 

107 

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) 

112 

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 

116 

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 

127 

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

132 

133 default_parent = ":~:init:~:" 

134 default_parent_author_name = ":~:init:~:" 

135 

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 

155 

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) 

159 

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 

178 

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 } 

198 

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 ) 

205 

206 context["is_user_oidc_authenticated"] = is_user_oidc_authenticated 

207 return context 

208 

209 

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

216 

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. 

223 

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) 

238 

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 

246 

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) 

253 

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 

263 

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 

271 

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 } 

285 

286 redirect_url = add_fragment_to_url( 

287 redirect_url, 

288 "add-comment-default" if form_prefix == "default" else "add-comment-reply", 

289 ) 

290 

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) 

305 

306 content_no_tag = re.sub(r"<.*?>|\n|\r|\t", "", content) 

307 

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 

313 

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 ) 

326 

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 

337 

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 

342 

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 ) 

359 

360 if error: 

361 return response 

362 

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 ) 

388 

389 return HttpResponseRedirect(redirect_url) 

390 

391 

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. 

398 

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 ) 

417 

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 

424 

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

429 

430 # Contains the ordered comments, ready for display 

431 ordered_comments = [] 

432 

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 #### 

440 

441 for comment in comments: 

442 # Format data 

443 format_comment(comment) 

444 if comment.get("status") != STATUS_SUBMITTED: 

445 comment["show_buttons"] = True 

446 

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

472 

473 return ordered_comments 

474 

475 

476class CommentDashboardListView( 

477 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardListView 

478): 

479 pass 

480 

481 

482class CommentDashboardDetailsView( 

483 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardDetailsView 

484): 

485 pass 

486 

487 

488class CommentChangeStatusView( 

489 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentChangeStatusView 

490): 

491 pass