Coverage for app/venv/lib/python3.14/site-packages/weblate/utils/views.py: 30%

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

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5"""Helper methods for views.""" 

6 

7from __future__ import annotations 

8 

9import os 

10import time 

11from contextlib import suppress 

12from typing import TYPE_CHECKING, BinaryIO, cast 

13from zipfile import ZipFile 

14 

15from django.conf import settings 

16from django.core.paginator import EmptyPage, Paginator 

17from django.http import ( 

18 FileResponse, 

19 Http404, 

20 HttpResponse, 

21 HttpResponseRedirect, 

22) 

23from django.shortcuts import get_object_or_404 

24from django.utils.cache import get_conditional_response 

25from django.utils.http import http_date 

26from django.utils.translation import activate, gettext, gettext_lazy, pgettext_lazy 

27from django.views.decorators.gzip import gzip_page 

28from django.views.generic.base import View 

29from django.views.generic.edit import FormView 

30 

31from weblate.formats.models import EXPORTERS, FILE_FORMATS 

32from weblate.lang.models import Language 

33from weblate.trans.models import Category, Component, Project, Translation, Unit 

34from weblate.utils import messages 

35from weblate.utils.errors import report_error 

36from weblate.utils.stats import ( 

37 CategoryLanguage, 

38 ProjectLanguage, 

39 prefetch_stats, 

40) 

41from weblate.vcs.git import LocalRepository 

42 

43if TYPE_CHECKING: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true

44 from collections.abc import Generator 

45 

46 from django.db.models import Model 

47 from django.http import ( 

48 HttpRequest, 

49 HttpResponseBase, 

50 ) 

51 

52 from weblate.auth.models import AuthenticatedHttpRequest 

53 from weblate.trans.mixins import BaseURLMixin 

54 from weblate.utils.stats import ( 

55 BaseStats, 

56 ) 

57 

58 

59class UnsupportedPathObjectError(Http404): 

60 pass 

61 

62 

63def key_name(instance): 

64 from weblate.trans.templatetags.translations import get_breadcrumbs 

65 

66 return "/".join( 

67 str(item) for item in get_breadcrumbs(instance, flags=False, only_names=True) 

68 ) 

69 

70 

71def key_translated(instance): 

72 return instance.stats.translated_percent 

73 

74 

75def key_untranslated(instance): 

76 return instance.stats.todo 

77 

78 

79def key_untranslated_words(instance): 

80 return instance.stats.todo_words 

81 

82 

83def key_untranslated_chars(instance): 

84 return instance.stats.todo_chars 

85 

86 

87def key_nottranslated(instance): 

88 return instance.stats.nottranslated 

89 

90 

91def key_checks(instance): 

92 return instance.stats.allchecks 

93 

94 

95def key_suggestions(instance): 

96 return instance.stats.suggestions 

97 

98 

99def key_comments(instance): 

100 return instance.stats.comments 

101 

102 

103SORT_KEYS = { 

104 "name": key_name, 

105 "translated": key_translated, 

106 "untranslated": key_untranslated, 

107 "untranslated_words": key_untranslated_words, 

108 "untranslated_chars": key_untranslated_chars, 

109 "nottranslated": key_nottranslated, 

110 "checks": key_checks, 

111 "suggestions": key_suggestions, 

112 "comments": key_comments, 

113} 

114 

115 

116def optional_form(form, perm_user, perm, perm_obj, **kwargs): 

117 if not perm_user.has_perm(perm, perm_obj): 

118 return None 

119 return form(**kwargs) 

120 

121 

122def get_percent_color(percent) -> str: 

123 if percent >= 85: 

124 return "#2eccaa" 

125 if percent >= 50: 

126 return "#38f" 

127 return "#f6664c" 

128 

129 

130def get_page_limit(request: AuthenticatedHttpRequest, default: int) -> tuple[int, int]: 

131 """Return page and limit as integers.""" 

132 try: 

133 limit = int(request.GET.get("limit", default)) 

134 except ValueError: 

135 limit = default 

136 # Cap it to range 10 - 2000 

137 limit = min(max(10, limit), 2000) 

138 try: 

139 page = int(request.GET.get("page", 1)) 

140 except ValueError: 

141 page = 1 

142 page = max(1, page) 

143 return page, limit 

144 

145 

146def sort_objects(object_list, sort_by: str): 

147 if sort_by.startswith("-"): 

148 sort_key = sort_by[1:] 

149 reverse = True 

150 else: 

151 sort_key = sort_by 

152 reverse = False 

153 try: 

154 key = SORT_KEYS[sort_key] 

155 except KeyError: 

156 return object_list, None 

157 return sorted(object_list, key=key, reverse=reverse), sort_by 

158 

159 

160def get_paginator( 

161 request: AuthenticatedHttpRequest, 

162 object_list, 

163 *, 

164 page_limit: int | None = None, 

165 stats: bool = False, 

166): 

167 """Return paginator and current page.""" 

168 page, limit = get_page_limit(request, page_limit or settings.DEFAULT_PAGE_LIMIT) 

169 sort_by = request.GET.get("sort_by") 

170 stats_fetched = False 

171 if sort_by: 

172 # All but ordering by name needs stats 

173 if sort_by != "name" and stats: 

174 object_list = prefetch_stats(object_list) 

175 stats_fetched = True 

176 

177 object_list, sort_by = sort_objects(object_list, sort_by) 

178 paginator = Paginator(object_list, limit) 

179 paginator.sort_by = sort_by # type: ignore[attr-defined] 

180 try: 

181 result = paginator.page(page) 

182 except EmptyPage: 

183 result = paginator.page(paginator.num_pages) 

184 

185 # Prefetch stats if asked for and were not yet fetched 

186 if stats and not stats_fetched: 

187 return prefetch_stats(result) 

188 

189 return result 

190 

191 

192class PathViewMixin(View): 

193 supported_path_types: tuple[type[Model | BaseURLMixin] | None, ...] = () 

194 request: AuthenticatedHttpRequest 

195 

196 def get_path_object(self): 

197 if not self.supported_path_types: 

198 msg = "Specifying supported path types is required" 

199 raise ValueError(msg) 

200 return parse_path( 

201 self.request, self.kwargs.get("path", ""), self.supported_path_types 

202 ) 

203 

204 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

205 super().setup(request, *args, **kwargs) 

206 self.path_object = self.get_path_object() 

207 

208 

209SORT_CHOICES = { 

210 "-priority,position": gettext_lazy("Position and priority"), 

211 "position": gettext_lazy("Position"), 

212 "priority": gettext_lazy("Priority"), 

213 "labels": gettext_lazy("Labels"), 

214 "source": gettext_lazy("Source string"), 

215 "target": gettext_lazy("Target string"), 

216 "timestamp": gettext_lazy("String age"), 

217 "last_updated": gettext_lazy("Last updated"), 

218 "num_words": gettext_lazy("Number of words"), 

219 "num_comments": gettext_lazy("Number of comments"), 

220 "num_failing_checks": gettext_lazy("Number of failing checks"), 

221 "context": pgettext_lazy("Translation key", "Key"), 

222 "location": gettext_lazy("String location"), 

223 "component,-priority": gettext_lazy("Component and priority"), 

224} 

225 

226SORT_LOOKUP = {key.replace("-", ""): value for key, value in SORT_CHOICES.items()} 

227 

228 

229def get_sort_name(request: AuthenticatedHttpRequest, obj=None): 

230 """Get sort name.""" 

231 if isinstance(obj, (Project, Category)): 

232 default = "component,-priority" 

233 elif hasattr(obj, "component") and obj.component.is_glossary: 

234 default = "source" 

235 else: 

236 default = "-priority,position" 

237 sort_query = request.GET.get("sort_by", default) 

238 sort_params = sort_query.replace("-", "") 

239 sort_name = SORT_LOOKUP.get(sort_params, gettext("Position and priority")) 

240 return { 

241 "query": sort_query, 

242 "name": sort_name, 

243 } 

244 

245 

246def parse_path( # noqa: C901 

247 request: AuthenticatedHttpRequest | None, 

248 path: list[str] | tuple[str, ...] | None, 

249 types: tuple[type[Model | BaseURLMixin] | None, ...], 

250): 

251 if None in types and not path: 

252 return None 

253 

254 allowed_types = {x for x in types if x is not None} 

255 acting_user = request.user if request else None 

256 

257 def check_type(cls) -> None: 

258 if cls not in allowed_types: 

259 msg = f"Not supported object type: {cls}" 

260 raise UnsupportedPathObjectError(msg) 

261 

262 if path is None: 

263 msg = "Missing path" 

264 raise UnsupportedPathObjectError(msg) 

265 

266 path = list(path) 

267 

268 # Language URL 

269 if path[:2] == ["-", "-"] and len(path) == 3: 

270 if path[2] == "-" and None in types: 

271 return None 

272 check_type(Language) 

273 return get_object_or_404(Language, code=path[2]) 

274 

275 # First level is always project 

276 project = get_object_or_404(Project, slug=path.pop(0)) 

277 if request is not None: 

278 request.user.check_access(project) 

279 project.acting_user = acting_user 

280 if not path: 

281 check_type(Project) 

282 return project 

283 

284 # Project/language special case 

285 if path[0] == "-" and len(path) == 2: 

286 check_type(ProjectLanguage) 

287 language = get_object_or_404(Language, code=path[1]) 

288 return ProjectLanguage(project, language) 

289 

290 if not allowed_types & {Component, Category, Translation, Unit}: 

291 msg = "No remaining supported object type" 

292 raise UnsupportedPathObjectError(msg) 

293 

294 # Component/category structure 

295 current: Project | Category | Component = project 

296 category_args = {"category": None} 

297 while path: 

298 slug = path.pop(0) 

299 

300 # Category/language special case 

301 if slug == "-" and len(path) == 1: 

302 language = get_object_or_404(Language, code=path[0]) 

303 check_type(CategoryLanguage) 

304 if not isinstance(current, Category): 

305 raise TypeError 

306 return CategoryLanguage(current, language) 

307 

308 # Try component first 

309 with suppress(Component.DoesNotExist): 

310 current = current.component_set.get(slug=slug, **category_args) 

311 if request is not None: 

312 request.user.check_access_component(current) 

313 current.acting_user = acting_user 

314 break 

315 

316 # Try category 

317 with suppress(Category.DoesNotExist): 

318 current = cast("Project | Category", current).category_set.get( 

319 slug=slug, **category_args 

320 ) 

321 current.acting_user = acting_user 

322 category_args = {} 

323 continue 

324 

325 # Nothing more to try 

326 msg = f"Object {slug} not found in {current}" 

327 raise Http404(msg) 

328 

329 # Nothing left, return current object 

330 if not path: 

331 if not isinstance(current, tuple(allowed_types)): 

332 msg = f"Not supported object type: {current.__class__}" 

333 raise UnsupportedPathObjectError(msg) 

334 return current 

335 

336 if not allowed_types & {Translation, Unit}: 

337 msg = "No remaining supported object type" 

338 raise UnsupportedPathObjectError(msg) 

339 

340 translation = get_object_or_404( 

341 cast("Component", current).translation_set, language__code=path.pop(0) 

342 ) 

343 if not path: 

344 check_type(Translation) 

345 return translation 

346 

347 if len(path) > 1: 

348 msg = f"Invalid path left: {'/'.join(path)}" 

349 raise UnsupportedPathObjectError(msg) 

350 

351 unitid = path.pop(0) 

352 

353 if not unitid.isdigit(): 

354 msg = f"Invalid unit id: {unitid}" 

355 raise Http404(msg) 

356 

357 check_type(Unit) 

358 return get_object_or_404(translation.unit_set, pk=int(unitid)) 

359 

360 

361def parse_path_units( 

362 request: AuthenticatedHttpRequest, 

363 path: list[str] | tuple[str, ...], 

364 types: tuple[type[Model | BaseURLMixin] | None, ...], 

365): 

366 obj = parse_path(request, path, types) 

367 

368 context = {"components": None, "path_object": obj} 

369 if isinstance(obj, Translation): 

370 unit_set = obj.unit_set.all() 

371 context["translation"] = obj 

372 context["component"] = obj.component 

373 context["project"] = obj.component.project 

374 context["components"] = [obj.component] 

375 elif isinstance(obj, Component): 

376 unit_set = Unit.objects.filter(translation__component=obj).prefetch() 

377 context["component"] = obj 

378 context["project"] = obj.project 

379 context["components"] = [obj] 

380 elif isinstance(obj, Project): 

381 unit_set = Unit.objects.filter(translation__component__project=obj).prefetch() 

382 context["project"] = obj 

383 elif isinstance(obj, ProjectLanguage): 

384 unit_set = Unit.objects.filter( 

385 translation__component__project=obj.project, 

386 translation__language=obj.language, 

387 ).prefetch() 

388 context["project"] = obj.project 

389 context["language"] = obj.language 

390 elif isinstance(obj, Category): 

391 unit_set = Unit.objects.filter( 

392 translation__component_id__in=obj.all_component_ids 

393 ).prefetch() 

394 context["project"] = obj.project 

395 elif isinstance(obj, CategoryLanguage): 

396 unit_set = Unit.objects.filter( 

397 translation__component_id__in=obj.category.all_component_ids, 

398 translation__language=obj.language, 

399 ).prefetch() 

400 context["project"] = obj.category.project 

401 context["language"] = obj.language 

402 elif isinstance(obj, Language): 

403 unit_set = ( 

404 Unit.objects.filter_access(request.user) 

405 .filter(translation__language=obj) 

406 .prefetch() 

407 ) 

408 context["language"] = obj 

409 elif obj is None: 

410 unit_set = Unit.objects.filter_access(request.user) 

411 else: 

412 msg = f"Unsupported result: {obj}" 

413 raise TypeError(msg) 

414 

415 return obj, unit_set, context 

416 

417 

418def guess_filemask_from_doc(data, docfile=None) -> None: 

419 if "filemask" in data: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true

420 return 

421 

422 if docfile is None: 422 ↛ 425line 422 didn't jump to line 425 because the condition on line 422 was always true

423 docfile = data["docfile"] 

424 

425 ext = "" 

426 if hasattr(docfile, "name"): 426 ↛ 427line 426 didn't jump to line 427 because the condition on line 426 was never true

427 ext = os.path.splitext(os.path.basename(docfile.name))[1] 

428 

429 if not ext and "file_format" in data and data["file_format"] in FILE_FORMATS: 

430 ext = FILE_FORMATS[data["file_format"]].extension() 

431 

432 data["filemask"] = "{}/{}{}".format(data.get("slug", "translations"), "*", ext) 

433 

434 

435def create_component_from_doc(data, docfile, target_language: Language | None = None): 

436 # Calculate filename 

437 uploaded = docfile or data["docfile"] 

438 guess_filemask_from_doc(data, uploaded) 

439 filemask = data["filemask"] 

440 file_language_code = ( 

441 target_language.code 

442 if target_language # bilingual file 

443 else data["source_language"].code 

444 if "source_language" in data 

445 else settings.DEFAULT_LANGUAGE 

446 ) 

447 filename = filemask.replace("*", file_language_code) 

448 # Create fake component (needed to calculate path) 

449 fake = Component( 

450 project=data["project"], 

451 slug=data["slug"], 

452 name=data["name"], 

453 category=data.get("category", None), 

454 filemask=filemask, 

455 ) 

456 

457 if not target_language: 

458 fake.template = filename 

459 

460 # Create repository 

461 LocalRepository.from_files(fake.full_path, {filename: uploaded.read()}) 

462 return fake 

463 

464 

465def create_component_from_zip(data, zipfile=None): 

466 # Create fake component (needed to calculate path) 

467 fake = Component( 

468 project=data["project"], 

469 category=data.get("category", None), 

470 slug=data["slug"], 

471 name=data["name"], 

472 ) 

473 

474 # Create repository 

475 LocalRepository.from_zip(fake.full_path, zipfile or data["zipfile"]) 

476 return fake 

477 

478 

479def try_set_language(lang) -> None: 

480 """Try to activate language.""" 

481 try: 

482 activate(lang) 

483 except Exception: 

484 # Ignore failure on activating language 

485 activate("en") 

486 

487 

488def import_message( 

489 request: AuthenticatedHttpRequest, count, message_none, message_ok 

490) -> None: 

491 if count == 0: 

492 messages.warning(request, message_none) 

493 else: 

494 try: 

495 message = message_ok % count 

496 except TypeError: 

497 message = message_ok 

498 messages.success(request, message) 

499 

500 

501def iter_files(filenames: list[str]) -> Generator[str]: 

502 for filename in filenames: 

503 if os.path.isdir(filename): 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 for root, _unused, files in os.walk(filename): 

505 if "/.git/" in root or "/.hg/" in root: 

506 continue 

507 yield from (os.path.join(root, name) for name in files) 

508 else: 

509 yield filename 

510 

511 

512def zip_download( 

513 root: str, 

514 filenames: list[str], 

515 name: str = "translations", 

516 extra: dict[str, bytes | str] | None = None, 

517) -> HttpResponse: 

518 response = HttpResponse(content_type="application/zip") 

519 with ZipFile(cast("BinaryIO", response), "w", strict_timestamps=False) as zipfile: 

520 for filename in iter_files(filenames): 

521 try: 

522 zipfile.write(filename, arcname=os.path.relpath(filename, root)) 

523 except FileNotFoundError: 

524 continue 

525 if extra: 525 ↛ 526line 525 didn't jump to line 526 because the condition on line 525 was never true

526 for filename, content in extra.items(): 

527 zipfile.writestr(filename, content) 

528 response["Content-Disposition"] = f'attachment; filename="{name}.zip"' 

529 return response 

530 

531 

532def handle_last_modified( 

533 request: HttpRequest, stats: BaseStats 

534) -> HttpResponseBase | None: 

535 last_modified = stats.last_changed 

536 if not last_modified: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true

537 return None 

538 # Respond with 302/412 response if needed 

539 return get_conditional_response( 

540 request, 

541 last_modified=int(last_modified.timestamp()), 

542 ) 

543 

544 

545@gzip_page 

546def download_translation_file( 

547 request, 

548 translation: Translation, 

549 fmt: str | None = None, 

550 query_string: str | None = None, 

551): 

552 response = handle_last_modified(request, translation.stats) 

553 if response is not None: 553 ↛ 554line 553 didn't jump to line 554 because the condition on line 553 was never true

554 return response 

555 

556 if fmt is not None: 

557 try: 

558 exporter_cls = EXPORTERS[fmt] 

559 except KeyError as exc: 

560 msg = f"Conversion to {fmt} is not supported" 

561 raise Http404(msg) from exc 

562 if not exporter_cls.supports(translation): 

563 msg = "File format is not compatible with this translation" 

564 raise Http404(msg) 

565 exporter = exporter_cls(translation=translation) 

566 units = translation.unit_set.prefetch_full().order_by("position") 

567 if query_string: 567 ↛ 568line 567 didn't jump to line 568 because the condition on line 567 was never true

568 units = units.search(query_string) 

569 exporter.add_units(units) 

570 response = exporter.get_response() 

571 else: 

572 # Force flushing pending units 

573 try: 

574 translation.commit_pending("download", None) 

575 except Exception: 

576 report_error("Download commit", project=translation.component.project) 

577 

578 filenames = translation.filenames 

579 

580 if len(filenames) == 1: 580 ↛ 594line 580 didn't jump to line 594 because the condition on line 580 was always true

581 extension = ( 

582 os.path.splitext(translation.filename)[1] 

583 or f".{translation.component.file_format_cls.extension()}" 

584 ) 

585 if not os.path.exists(filenames[0]): 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true

586 msg = "File not found" 

587 raise Http404(msg) 

588 # Create response 

589 response = FileResponse( 

590 open(filenames[0], "rb"), # noqa: SIM115 

591 content_type=translation.component.file_format_cls.mimetype(), 

592 ) 

593 else: 

594 extension = ".zip" 

595 filename = translation.get_filename() 

596 if not filename: 

597 msg = "No file to download" 

598 raise Http404(msg) 

599 response = zip_download( 

600 filename, 

601 filenames, 

602 translation.full_slug.replace("/", "-"), 

603 ) 

604 

605 # Construct filename (do not use real filename as it is usually not 

606 # that useful) 

607 project_slug = translation.component.project.slug 

608 component_slug = translation.component.slug 

609 language_code = translation.language.code 

610 filename = f"{project_slug}-{component_slug}-{language_code}{extension}" 

611 

612 # Fill in response headers 

613 response["Content-Disposition"] = f"attachment; filename={filename}" 

614 

615 # Last-Modified timestamp 

616 if last_changed := translation.stats.last_changed: 616 ↛ 620line 616 didn't jump to line 620 because the condition on line 616 was always true

617 last_modified = last_changed.timestamp() 

618 else: 

619 # Use current timestamp if stats do not have any 

620 last_modified = time.time() 

621 response["Last-Modified"] = http_date(int(last_modified)) 

622 

623 return response 

624 

625 

626def get_form_data(data: dict[str, str | int | None]) -> dict[str, str | int]: 

627 return {key: "" if value is None else value for key, value in data.items()} 

628 

629 

630def get_form_errors(form): 

631 for error in form.non_field_errors(): 

632 yield error 

633 for field in form: 

634 for error in field.errors: 

635 yield gettext("Error in parameter %(field)s: %(error)s") % { 

636 "field": field.name, 

637 "error": error, 

638 } 

639 

640 

641def show_form_errors(request: AuthenticatedHttpRequest, form) -> None: 

642 """Show all form errors as a message.""" 

643 for error in get_form_errors(form): 

644 messages.error(request, error) 

645 

646 

647class ErrorFormView(FormView): 

648 request: AuthenticatedHttpRequest 

649 

650 def form_invalid(self, form): 

651 """If the form is invalid, redirect to the supplied URL.""" 

652 show_form_errors(self.request, form) 

653 return HttpResponseRedirect(self.get_success_url()) 

654 

655 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

656 """There is no GET view here.""" 

657 return HttpResponseRedirect(self.get_success_url())