Coverage for src/comments_views/core/utils.py: 91%
137 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-09 14:49 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-09 14:49 +0000
1from json import JSONDecodeError
2from urllib.parse import urlencode
4import requests
5from django.conf import settings
6from django.contrib import messages
7from django.contrib.auth import get_user_model
8from django.http import HttpRequest
9from django.urls import reverse
10from django.utils.translation import gettext_lazy as _
11from requests import RequestException, Response
13from comments_api.constants import API_MESSAGE_KEY
14from comments_api.utils import parse_date
16from .app_settings import app_settings
17from .rights import AbstractUserRights
19# Default timeout (seconds) to be used when performing API requests.
20DEFAULT_TIMEOUT = 4
21DEFAULT_API_ERROR_MESSAGE = _("Error: Something went wrong. Please try again.")
24def comments_server_base_url() -> str:
25 """
26 Returns the base URL of the comment server as defined in settings.py.
27 Fallbacks to https://comments.centre-mersenne.org
28 """
29 comment_base_url = app_settings.API_BASE_URL
31 # Remove trailing slash
32 if comment_base_url[-1] == "/":
33 comment_base_url = comment_base_url[:-1]
34 return comment_base_url
37def comments_server_url(
38 query_params: dict[str, str] = {}, url_prefix="comments", item_id=None
39) -> str:
40 """
41 Returns a formatted URL to query the comment server (API).
42 The default URL corresponds to the list of all available comments.
44 Parameters:
45 - `query_params`: A dictionnary of query parameters to be appended to the URL.
46 They are used to precisely filter the comment dataset.
47 - `comment_id`: Filters on the given comment ID
48 """
49 base_url = f"{comments_server_base_url()}/api/{url_prefix}/"
50 if item_id:
51 base_url += f"{item_id}/"
52 if query_params:
53 base_url += f"?{urlencode(query_params)}"
54 return base_url
57def comments_credentials() -> tuple[str, str]:
58 """
59 Returns the current server credentials (login, password) required to authenticate
60 on the comment server.
61 """
62 credentials = app_settings.API_CREDENTIALS
63 if credentials:
64 assert isinstance(credentials, tuple) and len(credentials) == 2, (
65 "The server has malformed comments server credentials."
66 "Please provide them as `(login, password)`"
67 )
69 return credentials
71 raise AttributeError("The server is missing the comments server credentials")
74def add_api_error_message(initial_message: str, api_json_data: dict) -> str:
75 """Adds the API error description to the given string."""
76 formatted_message = initial_message
77 api_error = api_json_data.get(API_MESSAGE_KEY)
78 if api_error and isinstance(api_error, str):
79 formatted_message = f"{formatted_message}<br><br>" if formatted_message else ""
80 formatted_message += f'"{api_error}"'
82 return formatted_message
85def json_from_response(
86 response: Response, request_for_message: HttpRequest | None = None
87) -> tuple[bool, dict]:
88 """
89 Returns the JSON content of a HTTP response and whether an exception was raised
90 while doing so.
91 We consider that there's an exception if the response does not contain correctly
92 formed JSON (except HTTP 204) or if its status code >= 300.
94 Params:
95 - `response` The HTTP response to parse.
96 - `request_for_message` The current HTTP request handled by Django. Used
97 to add a message (Django) when there's an exception.
98 Don't provide it if you don't want any messages.
100 Returns:
101 - `error` Whether an exception was raised.
102 - `content` The JSON content of the HTTP response.
103 """
104 try:
105 data = response.json()
106 except JSONDecodeError:
107 # 204 No Content
108 if response.status_code == 204:
109 return (False, {})
110 if request_for_message is not None:
111 messages.error(request_for_message, DEFAULT_API_ERROR_MESSAGE)
112 return (True, {})
114 error = False
115 if response.status_code >= 300:
116 if request_for_message is not None:
117 error_message = add_api_error_message(DEFAULT_API_ERROR_MESSAGE, data)
118 messages.error(request_for_message, error_message)
119 error = True
120 return (error, data)
123def format_comment_author_name(comment: dict) -> str:
124 """
125 Formats an author name according to the comments properties.
126 """
127 author_name = comment.get("author_name", "")
129 hide_name = comment.get("hide_author_name")
130 article_author = comment.get("article_author_comment")
131 editorial_team = comment.get("editorial_team_comment")
132 extra_text = ""
134 if article_author:
135 if hide_name:
136 return _("Auteur(s) de l'article").capitalize()
137 extra_text = _("Auteur de l'article")
138 elif editorial_team:
139 extra_text = _("Équipe éditoriale")
140 if hide_name:
141 return extra_text.capitalize()
143 if not extra_text:
144 return author_name
146 return f"{author_name} ({extra_text.lower()})"
149def format_comment(
150 comment: dict, rights: AbstractUserRights | None = None, users: dict = {}
151) -> None:
152 """
153 Format a comment data for display.
154 Formatted data includes:
155 - author display name (actual name or function)
156 - Moderation data (only if `users is provided`)
157 - comment URL
159 Params:
160 - `comment` The comment data (usually a dict constructed from
161 the JSON data).
162 - `rights` The user rights used to check access over moderation data.
163 - `users` The dictionnary of users used to map moderator IDs
164 """
165 # Format author name and parent author name
166 comment["author_display_name"] = format_comment_author_name(comment)
168 parent = comment.get("parent")
169 # The comment data may contain either the parent dictionnary data or
170 # just the parent ID
171 if parent and isinstance(parent, dict):
172 comment["parent"]["author_display_name"] = format_comment_author_name(parent)
174 # Format author provider link (only ORCID right now)
175 author_provider = comment.get("author_provider")
176 author_provider_uid = comment.get("author_provider_uid")
177 if author_provider and author_provider.lower() == "orcid" and author_provider_uid: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 comment["author_provider_link"] = f"{app_settings.ORCID_BASE_URL}{author_provider_uid}/"
180 # Parse dates for display
181 date_submitted = comment.get("date_submitted")
182 if date_submitted: 182 ↛ 185line 182 didn't jump to line 185 because the condition on line 182 was always true
183 comment["date_submitted"] = parse_date(date_submitted)
185 moderation_data = comment.get("moderation_data")
186 if moderation_data: 186 ↛ 205line 186 didn't jump to line 205 because the condition on line 186 was always true
187 if moderation_data.get("date_moderated"): 187 ↛ 192line 187 didn't jump to line 192 because the condition on line 187 was always true
188 comment["moderation_data"]["date_moderated"] = parse_date(
189 moderation_data.get("date_moderated")
190 )
192 moderator_id = moderation_data.get("moderator")
193 if ( 193 ↛ 202line 193 didn't jump to line 202 because the condition on line 193 was never true
194 moderator_id
195 and moderator_id in users
196 and rights
197 and (
198 rights.comment_can_manage_moderators(comment)
199 or getattr(rights.user, "is_superuser", False)
200 )
201 ):
202 comment["moderation_data"]["moderated_by"] = users[moderator_id]
204 # Format comment link
205 base_url = comment.get("base_url")
206 if base_url: 206 ↛ 220line 206 didn't jump to line 220 because the condition on line 206 was always true
207 # Remove potential trailing slash
208 if base_url[-1] == "/":
209 base_url = base_url[:-1]
210 article_route = reverse("article", kwargs={"aid": comment["doi"]})
211 # If a site has an URL prefix, we need to discard it from the article_route,
212 # it should already be present in base_url.
213 url_prefix = getattr(settings, "SITE_URL_PREFIX", None)
214 if url_prefix: 214 ↛ 218line 214 didn't jump to line 218 because the condition on line 214 was always true
215 url_prefix = f"/{url_prefix}"
216 if article_route.startswith(url_prefix):
217 article_route = article_route[len(url_prefix) :]
218 comment["base_url"] = f"{base_url}{article_route}"
220 if rights: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 comment["can_moderate"] = rights.comment_can_moderate(comment)
223 # Get the moderators
224 if rights and users: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 moderators = comment.get("moderators")
226 if moderators and (
227 rights.comment_can_manage_moderators(comment)
228 or getattr(rights.user, "is_superuser", False)
229 ):
230 comment["moderators_processed"] = {
231 moderator_id: users[moderator_id]
232 for moderator_id in moderators
233 if moderator_id in users
234 }
237def get_user_dict() -> dict[int, dict[str, str]]:
238 """
239 Return a dict of users indexed by the user's pk.
240 A value is a dict of the user's `pk`, `first_name`, `last_name` and `email`
241 """
242 users = get_user_model().objects.all().values("pk", "first_name", "last_name", "email")
243 return {u["pk"]: u for u in users}
246def api_request_wrapper(http_method: str, url: str, **kwargs) -> Response | None:
247 """
248 Light wrapper around `get`, `post`, and `put` methods of the `requests` library:
249 - Catch any `RequestException`.
250 - Add a default timeout to the request if no `timeout` was provided.
252 Params:
253 - `http_method` The HTTP method to use for the request
254 (get, post, put, head or delete).
256 Returns:
257 - `response` The `Response` object if no `RequestException` was raised,
258 else `None`.
259 """
260 if "timeout" not in kwargs: 260 ↛ 263line 260 didn't jump to line 263 because the condition on line 260 was always true
261 # (connect timeout, read timeout)
262 kwargs["timeout"] = (4, 7)
263 http_method = http_method.upper()
264 try:
265 if http_method == "GET":
266 response = requests.get(url, **kwargs)
267 elif http_method == "POST":
268 response = requests.post(url, **kwargs)
269 elif http_method == "PUT":
270 response = requests.put(url, **kwargs)
271 # Other HTTP methods are not used. They can be added above
272 # The timeout kwarg is only available for HEAD and DELETE
273 else:
274 raise ValueError(f"Wrong argument for 'http_method': '{http_method}'")
275 except RequestException:
276 response = None
278 return response
281def make_api_request(
282 http_method: str, url: str, request_for_message: HttpRequest | None = None, **kwargs
283) -> tuple[bool, dict]:
284 """
285 Performs the HTTP request to the provided URL and return whether an exception was
286 raised and the JSON content of the response. \\
287 Catched exceptions are any `RequestException` or `JSONDecodeError`. \\
288 Any additional kwargs are passed directly to the `requests` library.
290 Params:
291 - `http_method` The HTTP method to use for the request
292 (get, post, put, head or delete).
293 - `url` The URL to request.
294 - `request_for_message` The current HTTP request handled by Django. Used
295 to add a message (Django) when there's an exception.
296 Don't provide it if you don't want any messages.
298 Returns:
299 - `error` Whether an exception was raised.
300 - `content` The JSON content of the HTTP response.
301 """
302 response = api_request_wrapper(http_method, url, **kwargs)
304 if response is None:
305 if request_for_message is not None:
306 messages.error(request_for_message, DEFAULT_API_ERROR_MESSAGE)
307 return True, {}
309 return json_from_response(response, request_for_message)
312def get_comment(
313 query_params: dict[str, str],
314 id: int,
315 request_for_message: HttpRequest | None = None,
316) -> tuple[bool, dict]:
317 """
318 Make an HTTP request to retrieve the requested comment. Commodity wrapper
319 around `make_api_request`.
321 Params:
322 - `query_params` Query parameters for the API URL.
323 - `id` The comment's ID.
324 - `request_for_message` The current HTTP request handled by Django. Used
325 to add a message (Django) when there's an exception.
326 Don't provide it if you don't want any messages.
328 Returns:
329 - `error` Whether an exception was raised.
330 - `content` The JSON content of the HTTP response.
331 """
332 return make_api_request(
333 "GET",
334 comments_server_url(query_params, item_id=id),
335 request_for_message,
336 auth=comments_credentials(),
337 )