Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/reports.py: 13%
169 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2# Copyright © WofWca <wofwca@protonmail.com>
3#
4# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7from collections import defaultdict
8from operator import itemgetter
9from typing import TYPE_CHECKING, Any
11from django.contrib.auth.decorators import login_required
12from django.http import HttpResponse, JsonResponse
13from django.utils.html import conditional_escape, format_html, format_html_join
14from django.views.decorators.http import require_POST
16from weblate.auth.models import AuthenticatedHttpRequest
17from weblate.lang.models import Language
18from weblate.trans.actions import ActionEvents
19from weblate.trans.forms import ReportsForm
20from weblate.trans.models import Category, Change, Component, Project
21from weblate.trans.util import count_words, redirect_param
22from weblate.utils.views import parse_path, show_form_errors
24if TYPE_CHECKING: 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true
25 from django.db.models import Model
26 from django.utils.safestring import SafeString
28 from weblate.auth.models import AuthenticatedHttpRequest, User
30# Header, three longer fields for name, email and date joined, shorter fields for numbers
31RST_HEADING = " ".join(["=" * 40] * 3 + ["=" * 24] * 24)
33HTML_HEADING = "<table>\n<tr>{0}</tr>"
36def format_plaintext(format_string, *args, **kwargs):
37 """
38 Format a plain string.
40 Same as `format_html` in syntax, but performs no escaping.
41 """
42 return format_string.format(*args, **kwargs)
45def format_plaintext_join(sep, format_string, args_generator):
46 """
47 Format a plain string with a list.
49 Same as `format_html_join` in syntax, but performs no escaping.
50 """
51 return sep.join(format_plaintext(format_string, *args) for args in args_generator)
54def generate_credits(
55 user: User | None,
56 start_date,
57 end_date,
58 language_code: str,
59 entity: Project | Component | Category,
60 sort_by: str,
61 sort_order: str,
62):
63 """Generate credits data for given component."""
64 result = defaultdict(list)
66 base = Change.objects.content()
67 if user:
68 base = base.filter(author=user)
70 kwargs: dict[str, Any]
71 if entity is None:
72 kwargs = {"translation__isnull": False}
73 elif isinstance(entity, Project):
74 kwargs = {"translation__component__project": entity}
75 elif isinstance(entity, Category):
76 kwargs = {"translation__component__category": entity}
77 else:
78 kwargs = {"translation__component": entity}
80 languages = Language.objects.filter(**kwargs)
81 if language_code:
82 languages = languages.filter(code=language_code)
84 order_by = "change_count" if sort_by == "count" else "author__date_joined"
85 if sort_order == "descending":
86 order_by = "-" + order_by
88 for *author, language in (
89 base.filter(language__in=languages, **kwargs)
90 .authors_list(
91 (start_date, end_date),
92 values_list=("author__date_joined", "language__name"),
93 )
94 .order_by("language__name", order_by)
95 ):
96 result[language].append(
97 {
98 "email": author[0],
99 "username": author[1],
100 "full_name": author[2],
101 "change_count": author[3],
102 "date_joined": author[4].isoformat(),
103 }
104 )
106 return [{language: authors} for language, authors in result.items()]
109@login_required
110@require_POST
111def get_credits(request: AuthenticatedHttpRequest, path=None):
112 """View for credits."""
113 obj = parse_path(request, path, (Component, Category, Project, None))
114 scope: dict[str, Model]
115 if obj is None:
116 scope = {}
117 elif isinstance(obj, Project):
118 scope = {"project": obj}
119 elif isinstance(obj, Category):
120 scope = {"category": obj}
121 else:
122 scope = {"component": obj}
124 form = ReportsForm(scope, request.POST)
126 if not form.is_valid():
127 show_form_errors(request, form)
128 return redirect_param(obj or "home", "#reports")
130 data = generate_credits(
131 None if request.user.has_perm("reports.view", obj) else request.user,
132 form.cleaned_data["period"]["start_date"],
133 form.cleaned_data["period"]["end_date"],
134 form.cleaned_data["language"],
135 obj,
136 form.cleaned_data["sort_by"],
137 form.cleaned_data["sort_order"],
138 )
140 if form.cleaned_data["style"] == "json":
141 return JsonResponse(data=data, safe=False)
143 if form.cleaned_data["style"] == "html":
144 wrap_format = "<table><tbody>{}</tbody></table>"
145 language_format = """
146 <tr>
147 <th>{language}</th>
148 <td><ul>{translators}</ul></td>
149 </tr>
150 """
151 translator_format = '<li><a href="mailto:{0}">{2} ({1})</a> - {3}</li>'
152 mime = "text/html"
153 format_html_or_plain = format_html
154 format_html_or_plain_join = format_html_join
155 else:
156 wrap_format = "{}"
157 language_format = "* {language}\n\n{translators}\n"
158 translator_format = " * {2} ({1}) <{0}> - {3}"
159 mime = "text/plain"
160 format_html_or_plain = format_plaintext
161 format_html_or_plain_join = format_plaintext_join
163 language_outputs = []
164 for language in data:
165 name, translators = language.popitem()
166 language_outputs.append(
167 format_html_or_plain(
168 language_format,
169 language=name,
170 translators=format_html_or_plain_join(
171 "\n",
172 translator_format,
173 (
174 (t["email"], t["username"], t["full_name"], t["change_count"])
175 for t in translators
176 ),
177 ),
178 )
179 )
181 body = format_html_or_plain(
182 wrap_format,
183 format_html_or_plain_join("\n\n", "{}", ((v,) for v in language_outputs)),
184 )
185 # Just in case someone messes something up.
186 # Also consider simply using `html.unescape` instead.
187 if mime != "text/plain":
188 body = conditional_escape(body)
189 return HttpResponse(body, content_type=f"{mime}; charset=utf-8")
192COUNT_DEFAULTS = dict.fromkeys(
193 (
194 "t_chars",
195 "t_words",
196 "chars",
197 "words",
198 "edits",
199 "count",
200 "t_chars_new",
201 "t_words_new",
202 "chars_new",
203 "words_new",
204 "edits_new",
205 "count_new",
206 "t_chars_approve",
207 "t_words_approve",
208 "chars_approve",
209 "words_approve",
210 "edits_approve",
211 "count_approve",
212 "t_chars_edit",
213 "t_words_edit",
214 "chars_edit",
215 "words_edit",
216 "edits_edit",
217 "count_edit",
218 ),
219 0,
220)
223def generate_counts(
224 user: User,
225 start_date,
226 end_date,
227 language_code: str,
228 sort_by: str,
229 sort_order: str,
230 **kwargs,
231):
232 """Generate credits data for given component."""
233 result = {}
234 action_map = {
235 ActionEvents.NEW: "new",
236 ActionEvents.APPROVE: "approve",
237 }
239 base = Change.objects.content().filter(unit__isnull=False)
240 base = base.filter(author=user) if user else base.filter(author__isnull=False)
241 if language_code:
242 base = base.filter(language__code=language_code)
244 changes = base.filter(
245 timestamp__range=(start_date, end_date), **kwargs
246 ).prefetch_related("author", "unit")
247 for change in changes:
248 email = change.author.email
250 if email not in result:
251 result[email] = current = {
252 "name": change.author.full_name,
253 "email": email,
254 "date_joined": change.author.date_joined.isoformat(),
255 }
256 current.update(COUNT_DEFAULTS)
257 else:
258 current = result[email]
260 src_chars = len(change.unit.source)
261 src_words = change.unit.num_words
262 tgt_chars = len(change.target)
263 tgt_words = count_words(change.target, change.language)
264 edits = change.get_distance()
266 current["chars"] += src_chars
267 current["words"] += src_words
268 current["t_chars"] += tgt_chars
269 current["t_words"] += tgt_words
270 current["edits"] += edits
271 current["count"] += 1
273 suffix = action_map.get(change.action, "edit")
275 current["t_chars_" + suffix] += tgt_chars
276 current["t_words_" + suffix] += tgt_words
277 current["chars_" + suffix] += src_chars
278 current["words_" + suffix] += src_words
279 current["edits_" + suffix] += edits
280 current["count_" + suffix] += 1
282 result_list = list(result.values())
283 sort_by_key = "count" if sort_by == "count" else "date_joined"
284 reverse = sort_order == "descending"
285 result_list.sort(key=itemgetter(sort_by_key), reverse=reverse)
287 return result_list
290@login_required
291@require_POST
292def get_counts(request: AuthenticatedHttpRequest, path=None):
293 """View for work counts."""
294 obj = parse_path(request, path, (Component, Category, Project, None))
295 kwargs: dict[str, Model]
296 if obj is None:
297 kwargs = {}
298 elif isinstance(obj, Project):
299 kwargs = {"project": obj}
300 elif isinstance(obj, Category):
301 kwargs = {"category": obj}
302 else:
303 kwargs = {"component": obj}
305 form = ReportsForm(kwargs, request.POST)
307 if not form.is_valid():
308 show_form_errors(request, form)
309 return redirect_param(obj or "home", "#reports")
311 data = generate_counts(
312 None if request.user.has_perm("reports.view", obj) else request.user,
313 form.cleaned_data["period"]["start_date"],
314 form.cleaned_data["period"]["end_date"],
315 form.cleaned_data["language"],
316 form.cleaned_data["sort_by"],
317 form.cleaned_data["sort_order"],
318 **kwargs,
319 )
321 if form.cleaned_data["style"] == "json":
322 return JsonResponse(data=data, safe=False)
324 headers = (
325 "Name",
326 "Email",
327 "Date joined",
328 "Count total",
329 "Edits total",
330 "Source words total",
331 "Source chars total",
332 "Target words total",
333 "Target chars total",
334 "Count new",
335 "Edits new",
336 "Source words new",
337 "Source chars new",
338 "Target words new",
339 "Target chars new",
340 "Count approved",
341 "Edits approved",
342 "Source words approved",
343 "Source chars approved",
344 "Target words approved",
345 "Target chars approved",
346 "Count edited",
347 "Edits edited",
348 "Source words edited",
349 "Source chars edited",
350 "Target words edited",
351 "Target chars edited",
352 )
354 start: str | SafeString
356 if form.cleaned_data["style"] == "html":
357 start = format_html(
358 HTML_HEADING,
359 format_html_join("", "<th>{}</th>", ((header,) for header in headers)),
360 )
361 row_start = "<tr>"
362 cell_name = cell_count = "<td>{0}</td>\n"
363 row_end = "</tr>"
364 mime = "text/html"
365 end = "</table>"
366 format_html_or_plain = format_html
367 format_html_or_plain_join = format_html_join
368 else:
369 start = "{0}\n{1} {2}\n{0}".format(
370 RST_HEADING,
371 " ".join(f"{h:40}" for h in headers[:3]),
372 " ".join(f"{h:24}" for h in headers[3:]),
373 )
374 row_start = ""
375 cell_name = "{0:40} "
376 cell_count = "{0:24} "
377 row_end = ""
378 mime = "text/plain"
379 end = RST_HEADING
380 format_html_or_plain = format_plaintext
381 format_html_or_plain_join = format_plaintext_join
383 result = [start]
385 for item in data:
386 if row_start:
387 result.append(row_start)
388 result.append(
389 format_html_or_plain_join(
390 "",
391 "{}",
392 (
393 (format_html_or_plain(cell_name, item["name"] or "Anonymous"),),
394 (format_html_or_plain(cell_name, item["email"] or ""),),
395 (format_html_or_plain(cell_name, item["date_joined"] or ""),),
396 (format_html_or_plain(cell_count, item["count"]),),
397 (format_html_or_plain(cell_count, item["edits"]),),
398 (format_html_or_plain(cell_count, item["words"]),),
399 (format_html_or_plain(cell_count, item["chars"]),),
400 (format_html_or_plain(cell_count, item["t_words"]),),
401 (format_html_or_plain(cell_count, item["t_chars"]),),
402 (format_html_or_plain(cell_count, item["count_new"]),),
403 (format_html_or_plain(cell_count, item["edits_new"]),),
404 (format_html_or_plain(cell_count, item["words_new"]),),
405 (format_html_or_plain(cell_count, item["chars_new"]),),
406 (format_html_or_plain(cell_count, item["t_words_new"]),),
407 (format_html_or_plain(cell_count, item["t_chars_new"]),),
408 (format_html_or_plain(cell_count, item["count_approve"]),),
409 (format_html_or_plain(cell_count, item["edits_approve"]),),
410 (format_html_or_plain(cell_count, item["words_approve"]),),
411 (format_html_or_plain(cell_count, item["chars_approve"]),),
412 (format_html_or_plain(cell_count, item["t_words_approve"]),),
413 (format_html_or_plain(cell_count, item["t_chars_approve"]),),
414 (format_html_or_plain(cell_count, item["count_edit"]),),
415 (format_html_or_plain(cell_count, item["edits_edit"]),),
416 (format_html_or_plain(cell_count, item["words_edit"]),),
417 (format_html_or_plain(cell_count, item["chars_edit"]),),
418 (format_html_or_plain(cell_count, item["t_words_edit"]),),
419 (format_html_or_plain(cell_count, item["t_chars_edit"]),),
420 ),
421 )
422 )
423 if row_end:
424 result.append(row_end)
426 result.append(end)
428 return HttpResponse("\n".join(result), content_type=f"{mime}; charset=utf-8")