Coverage for app/venv/lib/python3.14/site-packages/weblate/machinery/views.py: 31%
290 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
4from __future__ import annotations
6from itertools import chain
7from typing import TYPE_CHECKING, cast
9from django.core.exceptions import PermissionDenied
10from django.http import (
11 Http404,
12 HttpResponseBadRequest,
13 HttpResponseRedirect,
14 JsonResponse,
15)
16from django.shortcuts import get_object_or_404
17from django.urls import reverse
18from django.utils.functional import cached_property
19from django.utils.translation import gettext
20from django.views.decorators.http import require_POST
21from django.views.generic import TemplateView
22from django.views.generic.edit import FormView
24from weblate.configuration.models import Setting, SettingCategory
25from weblate.machinery.base import (
26 MachineTranslationError,
27)
28from weblate.machinery.models import MACHINERY
29from weblate.trans.models import Project, Unit
30from weblate.trans.templatetags.translations import format_language_string
31from weblate.utils.diff import Differ
32from weblate.utils.errors import report_error
33from weblate.utils.views import parse_path
34from weblate.wladmin.views import MENU as MANAGE_MENU
36if TYPE_CHECKING: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 from weblate.auth.models import AuthenticatedHttpRequest
38 from weblate.machinery.base import (
39 BatchMachineTranslation,
40 SettingsDict,
41 )
42 from weblate.trans.models import Translation
45class MachineryMixin:
46 @cached_property
47 def global_settings_dict(self) -> dict[str, SettingsDict]:
48 return cast(
49 "dict[str, SettingsDict]",
50 Setting.objects.get_settings_dict(SettingCategory.MT),
51 )
54class MachineryProjectMixin(MachineryMixin):
55 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None:
56 self.project = parse_path(request, [kwargs["project"]], (Project,))
58 @cached_property
59 def settings_dict(self) -> dict[str, SettingsDict]:
60 return self.project.machinery_settings
63class MachineryGlobalMixin(MachineryMixin):
64 @cached_property
65 def settings_dict(self) -> dict[str, SettingsDict]:
66 return self.global_settings_dict
69class DeprecatedMachinery:
70 is_available = False
71 settings_form: None = None
73 def __init__(self, identifier: str) -> None:
74 self.identifier = self.name = identifier
76 def get_identifier(self) -> str:
77 return self.identifier
79 def get_doc_anchor(self) -> str:
80 return f"mt-{self.get_identifier()}"
83class MachineryConfiguration:
84 def __init__(
85 self,
86 machinery,
87 configuration: SettingsDict | None,
88 sitewide: bool = False,
89 project=None,
90 is_configured: bool = True,
91 ) -> None:
92 self.machinery = machinery
93 self.configuration = configuration
94 self.sitewide = sitewide
95 self.project = project
96 self.is_configured = is_configured
98 @property
99 def is_enabled(self):
100 return self.configuration is not None
102 @property
103 def is_available(self):
104 return self.machinery.is_available
106 @property
107 def id(self):
108 return self.machinery.get_identifier()
110 @property
111 def name(self):
112 return self.machinery.name
114 @property
115 def doc_anchor(self):
116 return self.machinery.get_doc_anchor()
118 @property
119 def has_settings(self):
120 return self.machinery.settings_form is not None
122 def get_absolute_url(self) -> str:
123 kwargs = {"machinery": self.id}
124 if self.project:
125 kwargs["project"] = self.project.slug
126 return reverse("machinery-edit", kwargs=kwargs)
129class ListMachineryView(TemplateView):
130 template_name = "machinery/list.html"
132 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override]
133 super().setup(request, *args, **kwargs)
134 self.project = None
135 self.post_setup(request, kwargs)
136 self.configured_services = sorted(
137 self.get_configured_services(), key=lambda obj: obj.name
138 )
139 installed_services = {obj.id for obj in self.configured_services}
140 self.available_services = sorted(
141 (
142 MachineryConfiguration(
143 obj, None, project=self.project, is_configured=False
144 )
145 for name, obj in MACHINERY.items()
146 if name not in installed_services
147 ),
148 key=lambda obj: obj.name,
149 )
151 @cached_property
152 def settings_dict(self) -> dict[str, SettingsDict]:
153 raise NotImplementedError
155 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None:
156 return
158 def get_configured_services(self):
159 for service, configuration in self.settings_dict.items():
160 try:
161 machinery = MACHINERY[service]
162 except KeyError:
163 yield MachineryConfiguration(
164 DeprecatedMachinery(service), configuration, project=self.project
165 )
166 else:
167 yield MachineryConfiguration(
168 machinery, configuration, project=self.project
169 )
171 def get_context_data(self, **kwargs):
172 result = super().get_context_data(**kwargs)
173 result["configured_services"] = self.configured_services
174 result["available_services"] = self.available_services
175 if not self.project:
176 result["menu_items"] = MANAGE_MENU
177 result["menu_page"] = "machinery"
178 return result
181class ListMachineryGlobalView(MachineryGlobalMixin, ListMachineryView):
182 pass
185class ListMachineryProjectView(MachineryProjectMixin, ListMachineryView):
186 def get_configured_services(self):
187 yield from super().get_configured_services()
188 project_settings = set(self.settings_dict)
189 for service, configuration in self.global_settings_dict.items():
190 if service in project_settings:
191 continue
192 try:
193 machinery = MACHINERY[service]
194 except KeyError:
195 yield MachineryConfiguration(
196 DeprecatedMachinery(service), configuration, project=self.project
197 )
198 else:
199 yield MachineryConfiguration(
200 machinery, configuration, sitewide=True, project=self.project
201 )
203 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
204 if not request.user.has_perm("project.edit", self.project):
205 raise PermissionDenied
206 return super().dispatch(request, *args, **kwargs)
208 def get_context_data(self, **kwargs):
209 result = super().get_context_data(**kwargs)
210 result["project"] = self.project
211 return result
214class EditMachineryView(FormView):
215 template_name = "machinery/edit.html"
217 machinery: DeprecatedMachinery | BatchMachineTranslation
219 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override]
220 super().setup(request, *args, **kwargs)
221 self.machinery_id = kwargs["machinery"]
222 try:
223 self.machinery = MACHINERY[self.machinery_id]
224 except KeyError:
225 self.machinery = DeprecatedMachinery(self.machinery_id)
226 self.project = None
227 self.post_setup(request, kwargs)
229 def get_form_class(self):
230 return self.machinery.settings_form
232 def get_form_kwargs(self):
233 result = super().get_form_kwargs()
234 result["machinery"] = self.machinery
235 return result
237 @cached_property
238 def settings_dict(self) -> dict[str, SettingsDict]:
239 raise NotImplementedError
241 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None:
242 return
244 def get_initial(self):
245 return self.settings_dict.get(self.machinery_id) or {}
247 def get_context_data(self, **kwargs):
248 result = super().get_context_data(**kwargs)
249 result["machinery_id"] = self.machinery.get_identifier()
250 result["machinery_name"] = self.machinery.name
251 result["machinery_doc_anchor"] = self.machinery.get_doc_anchor()
252 return result
254 def install_service(self) -> None:
255 self.save_settings({})
257 def form_valid(self, form):
258 self.save_settings(form.cleaned_data)
259 return super().form_valid(form)
261 def save_settings(self, data) -> None:
262 raise NotImplementedError
264 def delete_service(self) -> None:
265 raise NotImplementedError
267 def enable_service(self) -> None:
268 return
270 def get_success_url(self):
271 if self.project:
272 return reverse("machinery-list", kwargs={"project": self.project.slug})
273 return reverse("manage-machinery")
275 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
276 if "delete" in request.POST:
277 self.delete_service()
278 return HttpResponseRedirect(self.get_success_url())
280 if not self.machinery.is_available:
281 msg = "Invalid service specified"
282 raise Http404(msg)
284 if "enable" in request.POST:
285 self.delete_service()
286 return HttpResponseRedirect(self.get_success_url())
288 if "install" in request.POST:
289 if self.machinery.settings_form is not None:
290 return HttpResponseRedirect(request.path)
291 self.install_service()
292 return HttpResponseRedirect(self.get_success_url())
293 return super().post(request, *args, **kwargs)
295 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
296 if not self.machinery.is_available:
297 msg = "Invalid service specified"
298 raise Http404(msg)
299 return super().get(request, *args, **kwargs)
302class EditMachineryGlobalView(MachineryGlobalMixin, EditMachineryView):
303 def save_settings(self, data: SettingsDict) -> None:
304 setting, created = Setting.objects.get_or_create(
305 category=SettingCategory.MT,
306 name=self.machinery_id,
307 defaults={"value": data},
308 )
309 if not created and setting.value != data:
310 setting.value = data
311 setting.save()
313 def delete_service(self) -> None:
314 Setting.objects.filter(
315 category=SettingCategory.MT,
316 name=self.machinery_id,
317 ).delete()
319 def form_valid(self, form):
320 self.save_settings(form.cleaned_data)
321 return super().form_valid(form)
323 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
324 if not request.user.has_perm("machinery.edit"):
325 raise PermissionDenied
326 return super().dispatch(request, *args, **kwargs)
329class EditMachineryProjectView(MachineryProjectMixin, EditMachineryView):
330 def save_settings(self, data: SettingsDict | None) -> None:
331 self.project.machinery_settings[self.machinery_id] = data
332 self.project.save(update_fields=["machinery_settings"])
334 def delete_service(self) -> None:
335 if self.machinery_id in self.project.machinery_settings:
336 self.enable_service()
337 else:
338 self.save_settings(None)
340 def enable_service(self) -> None:
341 del self.project.machinery_settings[self.machinery_id]
342 self.project.save(update_fields=["machinery_settings"])
344 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override]
345 super().setup(request, *args, **kwargs)
346 self.project = parse_path(request, [kwargs["project"]], (Project,))
348 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
349 if not request.user.has_perm("project.edit", self.project):
350 raise PermissionDenied
351 return super().dispatch(request, *args, **kwargs)
353 def get_context_data(self, **kwargs):
354 result = super().get_context_data(**kwargs)
355 result["project"] = self.project
356 return result
359def format_string_helper(
360 source: str, translation: Translation, diff: str | None = None
361):
362 return format_language_string(source, translation, diff=diff)["items"][0]["content"]
365def format_results_helper(
366 item: dict,
367 targets: list[str],
368 plural_form: int,
369 translation: Translation,
370 source_translation: Translation,
371) -> None:
372 item["plural_form"] = plural_form
373 item["diff"] = format_string_helper(item["text"], translation, targets[plural_form])
374 item["source_diff"] = format_string_helper(
375 item["source"], source_translation, item["original_source"]
376 )
377 item["html"] = format_string_helper(item["text"], translation)
380def handle_machinery(request: AuthenticatedHttpRequest, service, unit, search=None):
381 translation = unit.translation
382 component = translation.component
383 source_translation = component.source_translation
384 if not request.user.has_perm("machinery.view", translation):
385 raise PermissionDenied
387 try:
388 translation_service_class = MACHINERY[service]
389 except KeyError as error:
390 msg = "Invalid service specified"
391 raise Http404(msg) from error
393 # Error response
394 response = {
395 "responseStatus": 500,
396 "responseDetails": "",
397 "translations": [],
398 "lang": translation.language.code,
399 "dir": translation.language.direction,
400 "service": translation_service_class.name,
401 }
403 machinery_settings = component.project.get_machinery_settings()
404 Differ()
405 targets = unit.get_target_plurals()
407 try:
408 translation_service = translation_service_class(machinery_settings[service])
409 except KeyError:
410 response["responseDetails"] = gettext("Service is currently not available.")
411 else:
412 try:
413 if search:
414 translations = translation_service.search(unit, search, request.user)
415 for item in translations:
416 format_results_helper(
417 item, targets, 0, translation, source_translation
418 )
419 else:
420 translations = translation_service.translate(unit, request.user)
421 for plural_form, possible_translations in enumerate(translations):
422 for item in possible_translations:
423 format_results_helper(
424 item, targets, plural_form, translation, source_translation
425 )
426 translations = list(chain.from_iterable(translations))
427 response["translations"] = translations
428 response["responseStatus"] = 200
429 except MachineTranslationError as exc:
430 response["responseDetails"] = str(exc)
431 except Exception as error:
432 report_error("Machinery failed", project=component.project)
433 response["responseDetails"] = f"{error.__class__.__name__}: {error}"
435 if response["responseStatus"] != 200:
436 translation.log_info("machinery failed: %s", response["responseDetails"])
438 return JsonResponse(data=response)
441@require_POST
442def translate(request: AuthenticatedHttpRequest, unit_id: int, service: str):
443 """AJAX handler for translating."""
444 unit = get_object_or_404(Unit, pk=unit_id)
445 return handle_machinery(request, service, unit)
448@require_POST
449def memory(request: AuthenticatedHttpRequest, unit_id: int):
450 """AJAX handler for translation memory."""
451 unit = get_object_or_404(Unit, pk=unit_id)
452 query = request.POST.get("q")
453 if not query:
454 return HttpResponseBadRequest("Missing search string")
456 return handle_machinery(request, "weblate-translation-memory", unit, search=query)