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

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 

6 

7from collections import defaultdict 

8from operator import itemgetter 

9from typing import TYPE_CHECKING, Any 

10 

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 

15 

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 

23 

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 

27 

28 from weblate.auth.models import AuthenticatedHttpRequest, User 

29 

30# Header, three longer fields for name, email and date joined, shorter fields for numbers 

31RST_HEADING = " ".join(["=" * 40] * 3 + ["=" * 24] * 24) 

32 

33HTML_HEADING = "<table>\n<tr>{0}</tr>" 

34 

35 

36def format_plaintext(format_string, *args, **kwargs): 

37 """ 

38 Format a plain string. 

39 

40 Same as `format_html` in syntax, but performs no escaping. 

41 """ 

42 return format_string.format(*args, **kwargs) 

43 

44 

45def format_plaintext_join(sep, format_string, args_generator): 

46 """ 

47 Format a plain string with a list. 

48 

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) 

52 

53 

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) 

65 

66 base = Change.objects.content() 

67 if user: 

68 base = base.filter(author=user) 

69 

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} 

79 

80 languages = Language.objects.filter(**kwargs) 

81 if language_code: 

82 languages = languages.filter(code=language_code) 

83 

84 order_by = "change_count" if sort_by == "count" else "author__date_joined" 

85 if sort_order == "descending": 

86 order_by = "-" + order_by 

87 

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 ) 

105 

106 return [{language: authors} for language, authors in result.items()] 

107 

108 

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} 

123 

124 form = ReportsForm(scope, request.POST) 

125 

126 if not form.is_valid(): 

127 show_form_errors(request, form) 

128 return redirect_param(obj or "home", "#reports") 

129 

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 ) 

139 

140 if form.cleaned_data["style"] == "json": 

141 return JsonResponse(data=data, safe=False) 

142 

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 

162 

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 ) 

180 

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

190 

191 

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) 

221 

222 

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 } 

238 

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) 

243 

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 

249 

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] 

259 

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

265 

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 

272 

273 suffix = action_map.get(change.action, "edit") 

274 

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 

281 

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) 

286 

287 return result_list 

288 

289 

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} 

304 

305 form = ReportsForm(kwargs, request.POST) 

306 

307 if not form.is_valid(): 

308 show_form_errors(request, form) 

309 return redirect_param(obj or "home", "#reports") 

310 

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 ) 

320 

321 if form.cleaned_data["style"] == "json": 

322 return JsonResponse(data=data, safe=False) 

323 

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 ) 

353 

354 start: str | SafeString 

355 

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 

382 

383 result = [start] 

384 

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) 

425 

426 result.append(end) 

427 

428 return HttpResponse("\n".join(result), content_type=f"{mime}; charset=utf-8")