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
« 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
5"""Helper methods for views."""
7from __future__ import annotations
9import os
10import time
11from contextlib import suppress
12from typing import TYPE_CHECKING, BinaryIO, cast
13from zipfile import ZipFile
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
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
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
46 from django.db.models import Model
47 from django.http import (
48 HttpRequest,
49 HttpResponseBase,
50 )
52 from weblate.auth.models import AuthenticatedHttpRequest
53 from weblate.trans.mixins import BaseURLMixin
54 from weblate.utils.stats import (
55 BaseStats,
56 )
59class UnsupportedPathObjectError(Http404):
60 pass
63def key_name(instance):
64 from weblate.trans.templatetags.translations import get_breadcrumbs
66 return "/".join(
67 str(item) for item in get_breadcrumbs(instance, flags=False, only_names=True)
68 )
71def key_translated(instance):
72 return instance.stats.translated_percent
75def key_untranslated(instance):
76 return instance.stats.todo
79def key_untranslated_words(instance):
80 return instance.stats.todo_words
83def key_untranslated_chars(instance):
84 return instance.stats.todo_chars
87def key_nottranslated(instance):
88 return instance.stats.nottranslated
91def key_checks(instance):
92 return instance.stats.allchecks
95def key_suggestions(instance):
96 return instance.stats.suggestions
99def key_comments(instance):
100 return instance.stats.comments
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}
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)
122def get_percent_color(percent) -> str:
123 if percent >= 85:
124 return "#2eccaa"
125 if percent >= 50:
126 return "#38f"
127 return "#f6664c"
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
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
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
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)
185 # Prefetch stats if asked for and were not yet fetched
186 if stats and not stats_fetched:
187 return prefetch_stats(result)
189 return result
192class PathViewMixin(View):
193 supported_path_types: tuple[type[Model | BaseURLMixin] | None, ...] = ()
194 request: AuthenticatedHttpRequest
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 )
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()
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}
226SORT_LOOKUP = {key.replace("-", ""): value for key, value in SORT_CHOICES.items()}
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 }
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
254 allowed_types = {x for x in types if x is not None}
255 acting_user = request.user if request else None
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)
262 if path is None:
263 msg = "Missing path"
264 raise UnsupportedPathObjectError(msg)
266 path = list(path)
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])
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
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)
290 if not allowed_types & {Component, Category, Translation, Unit}:
291 msg = "No remaining supported object type"
292 raise UnsupportedPathObjectError(msg)
294 # Component/category structure
295 current: Project | Category | Component = project
296 category_args = {"category": None}
297 while path:
298 slug = path.pop(0)
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)
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
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
325 # Nothing more to try
326 msg = f"Object {slug} not found in {current}"
327 raise Http404(msg)
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
336 if not allowed_types & {Translation, Unit}:
337 msg = "No remaining supported object type"
338 raise UnsupportedPathObjectError(msg)
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
347 if len(path) > 1:
348 msg = f"Invalid path left: {'/'.join(path)}"
349 raise UnsupportedPathObjectError(msg)
351 unitid = path.pop(0)
353 if not unitid.isdigit():
354 msg = f"Invalid unit id: {unitid}"
355 raise Http404(msg)
357 check_type(Unit)
358 return get_object_or_404(translation.unit_set, pk=int(unitid))
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)
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)
415 return obj, unit_set, context
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
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"]
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]
429 if not ext and "file_format" in data and data["file_format"] in FILE_FORMATS:
430 ext = FILE_FORMATS[data["file_format"]].extension()
432 data["filemask"] = "{}/{}{}".format(data.get("slug", "translations"), "*", ext)
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 )
457 if not target_language:
458 fake.template = filename
460 # Create repository
461 LocalRepository.from_files(fake.full_path, {filename: uploaded.read()})
462 return fake
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 )
474 # Create repository
475 LocalRepository.from_zip(fake.full_path, zipfile or data["zipfile"])
476 return fake
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")
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)
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
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
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 )
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
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)
578 filenames = translation.filenames
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 )
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}"
612 # Fill in response headers
613 response["Content-Disposition"] = f"attachment; filename={filename}"
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))
623 return response
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()}
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 }
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)
647class ErrorFormView(FormView):
648 request: AuthenticatedHttpRequest
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())
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())