Coverage for baibu/localization/views.py: 100%
201 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Pages for translators: languages, the catalogue editor, publishing and history."""
3import logging
5from django.contrib import messages
6from django.contrib.auth.decorators import login_required
7from django.contrib.auth.decorators import permission_required
8from django.core.paginator import Paginator
9from django.http import Http404
10from django.http import HttpRequest
11from django.http import HttpResponse
12from django.shortcuts import get_object_or_404
13from django.shortcuts import redirect
14from django.shortcuts import render
15from django.utils.translation import gettext as _
16from django.utils.translation import ngettext
17from django.utils.translation import to_locale
18from django.views.decorators.cache import cache_control
19from django.views.decorators.http import require_GET
20from django.views.decorators.http import require_http_methods
21from django.views.decorators.http import require_POST
23from . import catalogues
24from . import services
25from .extraction import ExtractionError
26from .forms import PluralFormsForm
27from .forms import UploadForm
28from .models import TranslationCatalogue
29from .models import TranslationPublication
30from .permissions import PERMISSION
32logger = logging.getLogger(__name__)
34STATES = ("untranslated", "fuzzy", "translated", "all")
35PAGE_SIZE = 25
36MAX_QUERY_LENGTH = 200
39def translator_required(view):
40 view = permission_required(PERMISSION, raise_exception=True)(view)
41 view = login_required(view)
42 return cache_control(private=True, no_store=True)(view)
45def _language_name(language_code: str) -> str:
46 names = dict(services.translatable_languages())
47 if language_code not in names:
48 raise Http404
49 return names[language_code]
52# Overview ------------------------------------------------------------------------
55@translator_required
56@require_GET
57def index(request: HttpRequest) -> HttpResponse:
58 existing = {catalogue.language_code: catalogue for catalogue in TranslationCatalogue.objects.all()}
59 languages = []
60 for code, name in services.translatable_languages():
61 catalogue = existing.get(code)
62 publication = services.latest_publication(code)
63 row = {"code": code, "name": name, "publication": publication, "stats": None, "unpublished": False}
64 if catalogue is not None:
65 try:
66 draft = services.read_draft(catalogue)
67 except (FileNotFoundError, catalogues.CatalogueError):
68 logger.exception("Could not read the %s draft", code)
69 else:
70 row["stats"] = catalogues.stats(draft)
71 row["unpublished"] = services.has_unpublished_changes(draft, publication)
72 languages.append(row)
73 return render(
74 request,
75 "localization/index.html",
76 {"languages": languages, "source": services.latest_source()},
77 )
80@translator_required
81@require_POST
82def update_sources(request: HttpRequest) -> HttpResponse:
83 try:
84 result = services.update_sources(user=request.user)
85 except ExtractionError:
86 logger.exception("Could not extract source strings")
87 messages.error(request, _("The source strings could not be extracted. The error has been logged."))
88 return redirect("localization:index")
89 if result.created:
90 messages.success(
91 request,
92 ngettext(
93 "Source strings updated: %(count)d message.",
94 "Source strings updated: %(count)d messages.",
95 result.source.entry_count,
96 )
97 % {"count": result.source.entry_count},
98 )
99 else:
100 messages.success(request, _("The source strings have not changed."))
101 return redirect("localization:index")
104# Editing -------------------------------------------------------------------------
107def _edits_from_post(data, plural_count: int) -> list[services.Edit]:
108 edits = []
109 for key in data.getlist("key")[: PAGE_SIZE * 2]:
110 values = [data.get(f"msgstr-{key}-{index}", "") for index in range(plural_count)]
111 if f"msgstr-{key}-1" not in data:
112 values = values[:1]
113 edits.append(
114 services.Edit(key=key, values=values, fuzzy=f"fuzzy-{key}" in data, seen=data.get(f"seen-{key}", ""))
115 )
116 return edits
119def _matches(entry, query: str) -> bool:
120 texts = [entry.msgid, entry.msgid_plural, entry.msgctxt, entry.msgstr, *entry.msgstr_plural.values()]
121 return any(query in (text or "").casefold() for text in texts)
124def _plural_labels(po) -> list[str]:
125 try:
126 examples = catalogues.plural_examples(catalogues.plural_forms(po))
127 except catalogues.CatalogueError:
128 examples = [[1], [2]]
129 labels = []
130 for index, numbers in enumerate(examples, start=1):
131 if numbers:
132 labels.append(
133 _("Form %(number)d (n = %(examples)s…)")
134 % {"number": index, "examples": ", ".join(str(n) for n in numbers)}
135 )
136 else:
137 labels.append(_("Form %(number)d") % {"number": index})
138 return labels
141def _row(entry, *, plural_labels, submitted, errors) -> dict:
142 key = catalogues.entry_key(entry)
143 edit = submitted.get(key)
144 if entry.msgid_plural:
145 values = [entry.msgstr_plural[index] for index in sorted(entry.msgstr_plural)]
146 labels = plural_labels
147 else:
148 values = [entry.msgstr]
149 labels = [_("Translation")]
150 if edit is not None:
151 values = edit.values + [""] * (len(values) - len(edit.values))
152 longest = max([len(entry.msgid), len(entry.msgid_plural or ""), *(len(value) for value in values)])
153 fields = [
154 {"name": f"msgstr-{key}-{index}", "label": labels[index] if index < len(labels) else "", "value": value}
155 for index, value in enumerate(values)
156 ]
157 return {
158 "key": key,
159 "entry": entry,
160 "state": catalogues.entry_state(entry),
161 "seen": services.fingerprint(entry),
162 "fields": fields,
163 "rows": min(8, 1 + longest // 60),
164 "fuzzy": edit.fuzzy if edit is not None else entry.fuzzy,
165 "error": errors.get(key),
166 "occurrences": [f"{path}:{line}" if line else path for path, line in entry.occurrences[:3]],
167 "more_occurrences": max(0, len(entry.occurrences) - 3),
168 }
171@translator_required
172@require_http_methods(["GET", "POST"])
173def catalogue(request: HttpRequest, language_code: str) -> HttpResponse:
174 language_name = _language_name(language_code)
175 try:
176 current = services.get_catalogue(language_code, user=request.user)
177 except services.NoSourceError:
178 messages.info(request, _("Extract the source strings first."))
179 return redirect("localization:index")
180 state = request.GET.get("state", "untranslated")
181 state = state if state in STATES else "untranslated"
182 query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH]
183 submitted: dict = {}
184 errors: dict = {}
185 if request.method == "POST":
186 draft = services.read_draft(current)
187 edits = _edits_from_post(request.POST, catalogues.plural_count(draft))
188 result = services.save_entries(language_code, edits, user=request.user)
189 if result.changed:
190 messages.success(
191 request,
192 ngettext("%(count)d translation saved.", "%(count)d translations saved.", result.changed)
193 % {"count": result.changed},
194 )
195 if result.conflicts:
196 messages.warning(
197 request,
198 ngettext(
199 "%(count)d translation was changed by someone else while you were editing; "
200 "it was not overwritten. Check it below.",
201 "%(count)d translations were changed by someone else while you were editing; "
202 "they were not overwritten. Check them below.",
203 len(result.conflicts),
204 )
205 % {"count": len(result.conflicts)},
206 )
207 if not result.errors:
208 return redirect(request.get_full_path())
209 errors = result.errors
210 submitted = {edit.key: edit for edit in edits if edit.key in errors}
211 messages.error(request, _("Some translations were not saved. Correct them below."))
212 current.refresh_from_db()
214 draft = services.read_draft(current)
215 entries = catalogues.active_entries(draft)
216 counts = catalogues.stats(draft)
217 if state != "all":
218 entries = [entry for entry in entries if catalogues.entry_state(entry) == state]
219 if query:
220 folded = query.casefold()
221 entries = [entry for entry in entries if _matches(entry, folded)]
222 if errors:
223 # Keep the entries being corrected on the page whatever the filter.
224 shown = {catalogues.entry_key(entry) for entry in entries}
225 entries += [
226 entry
227 for entry in catalogues.active_entries(draft)
228 if catalogues.entry_key(entry) in errors and catalogues.entry_key(entry) not in shown
229 ]
230 page = Paginator(entries, PAGE_SIZE).get_page(request.GET.get("page"))
231 plural_labels = _plural_labels(draft)
232 publication = services.latest_publication(language_code)
233 return render(
234 request,
235 "localization/catalogue.html",
236 {
237 "language_code": language_code,
238 "language_name": language_name,
239 "catalogue": current,
240 "counts": counts,
241 "state": state,
242 "states": [
243 ("untranslated", _("Untranslated"), counts.untranslated),
244 ("fuzzy", _("Needs review"), counts.fuzzy),
245 ("translated", _("Translated"), counts.translated),
246 ("all", _("All"), counts.total),
247 ],
248 "query": query,
249 "page": page,
250 "rows": [_row(entry, plural_labels=plural_labels, submitted=submitted, errors=errors) for entry in page],
251 "publication": publication,
252 "unpublished": services.has_unpublished_changes(draft, publication),
253 "plural_form": PluralFormsForm(initial={"plural_forms": catalogues.plural_forms(draft)}),
254 "upload_form": UploadForm(),
255 },
256 )
259@translator_required
260@require_POST
261def plural_forms(request: HttpRequest, language_code: str) -> HttpResponse:
262 _language_name(language_code)
263 form = PluralFormsForm(request.POST)
264 if form.is_valid():
265 services.set_plural_forms(language_code, form.cleaned_data["plural_forms"], user=request.user)
266 messages.success(request, _("Plural forms saved."))
267 else:
268 messages.error(request, " ".join(form.errors["plural_forms"]))
269 return redirect("localization:catalogue", language_code=language_code)
272@translator_required
273@require_POST
274def upload(request: HttpRequest, language_code: str) -> HttpResponse:
275 _language_name(language_code)
276 form = UploadForm(request.POST, request.FILES)
277 if not form.is_valid():
278 messages.error(request, " ".join(error for errors in form.errors.values() for error in errors))
279 return redirect("localization:catalogue", language_code=language_code)
280 result = services.import_catalogue(language_code, form.cleaned_data["file"], user=request.user)
281 messages.success(
282 request,
283 ngettext(
284 "%(count)d translation taken from the file.", "%(count)d translations taken from the file.", result.changed
285 )
286 % {"count": result.changed},
287 )
288 if result.errors:
289 messages.warning(
290 request,
291 ngettext(
292 "%(count)d translation was skipped because its placeholders do not match the original.",
293 "%(count)d translations were skipped because their placeholders do not match the original.",
294 len(result.errors),
295 )
296 % {"count": len(result.errors)},
297 )
298 return redirect("localization:catalogue", language_code=language_code)
301@translator_required
302@require_GET
303def download(request: HttpRequest, language_code: str) -> HttpResponse:
304 _language_name(language_code)
305 current = get_object_or_404(TranslationCatalogue, language_code=language_code)
306 response = HttpResponse(catalogues.dump(services.read_draft(current)), content_type="text/x-gettext-translation")
307 response["Content-Disposition"] = f'attachment; filename="{to_locale(language_code)}.po"'
308 return response
311# Publishing ----------------------------------------------------------------------
314@translator_required
315@require_POST
316def publish(request: HttpRequest, language_code: str) -> HttpResponse:
317 _language_name(language_code)
318 get_object_or_404(TranslationCatalogue, language_code=language_code)
319 publication = services.publish(language_code, user=request.user)
320 messages.success(
321 request,
322 _("Published version %(version)d. Every page uses it within a few seconds.")
323 % {"version": publication.version},
324 )
325 return redirect("localization:catalogue", language_code=language_code)
328@translator_required
329@require_GET
330def history(request: HttpRequest, language_code: str) -> HttpResponse:
331 language_name = _language_name(language_code)
332 publications = list(
333 TranslationPublication.objects.filter(language_code=language_code)
334 .select_related("published_by", "restored_from")
335 .order_by("-version")
336 )
337 return render(
338 request,
339 "localization/history.html",
340 {
341 "language_code": language_code,
342 "language_name": language_name,
343 "publications": publications,
344 "live": publications[0] if publications else None,
345 },
346 )
349@translator_required
350@require_POST
351def restore(request: HttpRequest, language_code: str, pk) -> HttpResponse:
352 _language_name(language_code)
353 publication = get_object_or_404(TranslationPublication, pk=pk, language_code=language_code)
354 restored = services.restore(publication, user=request.user)
355 messages.success(
356 request,
357 _("Version %(old)d is live again, as version %(new)d. The draft was not changed.")
358 % {"old": publication.version, "new": restored.version},
359 )
360 return redirect("localization:history", language_code=language_code)