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

1import re 

2from typing import Any 

3 

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 

18 

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 

34 

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 

54 

55 

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

63 

64 

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

74 

75 

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

82 

83 template_name = "dashboard/comment_login.html" 

84 

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 

92 

93 return context 

94 

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) 

100 

101 

102class CommentSectionView(OIDCCommentRightsMixin, AbstractCommentRightsMixin, TemplateView): 

103 template_name = "common/article_comments.html" 

104 

105 def get_context_data(self, **kwargs): 

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

107 

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 ) 

115 

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) 

120 

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 

124 

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 

135 

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

140 

141 default_parent = ":~:init:~:" 

142 default_parent_author_name = ":~:init:~:" 

143 

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 

163 

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) 

167 

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 

186 

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 } 

206 

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 ) 

213 

214 context["is_user_oidc_authenticated"] = is_user_oidc_authenticated 

215 return context 

216 

217 

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

224 

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. 

231 

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) 

246 

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 

254 

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) 

261 

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 

271 

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 

279 

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 } 

293 

294 redirect_url = add_fragment_to_url( 

295 redirect_url, 

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

297 ) 

298 

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) 

313 

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

315 

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 

321 

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 ) 

334 

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 

345 

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 

350 

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 ) 

367 

368 if error: 

369 return response 

370 

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 ) 

396 

397 return HttpResponseRedirect(redirect_url) 

398 

399 

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. 

406 

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 ) 

425 

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 

432 

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

437 

438 # Contains the ordered comments, ready for display 

439 ordered_comments = [] 

440 

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

448 

449 for comment in comments: 

450 # Format data 

451 format_comment(comment) 

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

453 comment["show_buttons"] = True 

454 

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

480 

481 return ordered_comments 

482 

483 

484class CommentDashboardListView( 

485 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardListView 

486): 

487 pass 

488 

489 

490class CommentDashboardDetailsView( 

491 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentDashboardDetailsView 

492): 

493 pass 

494 

495 

496class CommentChangeStatusView( 

497 SSOLoginRequiredMixin, OIDCCommentRightsMixin, BaseCommentChangeStatusView 

498): 

499 pass