Coverage for app/venv/lib/python3.14/site-packages/weblate/addons/forms.py: 42%
217 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
5from __future__ import annotations
7import re
8from typing import TYPE_CHECKING
10from crispy_forms.helper import FormHelper
11from crispy_forms.layout import Field, Layout
12from django import forms
13from django.http import QueryDict
14from django.utils.functional import cached_property
15from django.utils.translation import gettext, gettext_lazy
16from lxml.cssselect import CSSSelector
18from weblate.formats.models import FILE_FORMATS
19from weblate.trans.actions import ActionEvents
20from weblate.trans.discovery import ComponentDiscovery
21from weblate.trans.forms import AutoForm, BulkEditForm
22from weblate.trans.models import Translation
23from weblate.utils.forms import (
24 CachedModelChoiceField,
25 ContextDiv,
26 SortedSelectMultiple,
27 WeblateServiceURLField,
28)
29from weblate.utils.render import validate_render, validate_render_translation
30from weblate.utils.validators import (
31 validate_base64_encoded_string,
32 validate_filename,
33 validate_re,
34)
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 User
38 from weblate.trans.models import Component, Project
41class BaseAddonForm(forms.Form):
42 def __init__(
43 self, user: User | None, addon, instance=None, *args, **kwargs
44 ) -> None:
45 self._addon = addon
46 self.user = user
47 forms.Form.__init__(self, *args, **kwargs)
49 def serialize_form(self):
50 return self.cleaned_data
52 def save(self):
53 self._addon.configure(self.serialize_form())
54 return self._addon.instance
57class GenerateMoForm(BaseAddonForm):
58 path = forms.CharField(
59 label=gettext_lazy("Path of generated MO file"),
60 required=False,
61 initial="{{ filename|stripext }}.mo",
62 help_text=gettext_lazy(
63 "If not specified, the location of the PO file will be used."
64 ),
65 )
66 fuzzy = forms.BooleanField(
67 label=gettext_lazy("Include strings needing editing"),
68 required=False,
69 help_text=gettext_lazy(
70 "Strings needing editing (fuzzy) are typically not ready for use as translations."
71 ),
72 )
74 def __init__(self, *args, **kwargs) -> None:
75 super().__init__(*args, **kwargs)
76 self.helper = FormHelper(self)
77 self.helper.layout = Layout(
78 Field("fuzzy"),
79 Field("path"),
80 ContextDiv(
81 template="addons/generatemo_help.html", context={"user": self.user}
82 ),
83 )
85 def test_render(self, value) -> None:
86 validate_render_translation(value)
88 def clean_path(self):
89 self.test_render(self.cleaned_data["path"])
90 validate_filename(self.cleaned_data["path"])
91 return self.cleaned_data["path"]
94class GenerateForm(BaseAddonForm):
95 filename = forms.CharField(
96 label=gettext_lazy("Name of generated file"), required=True
97 )
98 template = forms.CharField(
99 widget=forms.Textarea(),
100 label=gettext_lazy("Content of generated file"),
101 required=True,
102 )
104 def __init__(self, *args, **kwargs) -> None:
105 super().__init__(*args, **kwargs)
106 self.helper = FormHelper(self)
107 self.helper.layout = Layout(
108 Field("filename"),
109 Field("template"),
110 ContextDiv(
111 template="addons/generate_help.html", context={"user": self.user}
112 ),
113 )
115 def test_render(self, value) -> None:
116 validate_render_translation(value)
118 def clean_filename(self):
119 self.test_render(self.cleaned_data["filename"])
120 validate_filename(self.cleaned_data["filename"])
121 return self.cleaned_data["filename"]
123 def clean_template(self):
124 self.test_render(self.cleaned_data["template"])
125 return self.cleaned_data["template"]
128class GitSquashForm(BaseAddonForm):
129 squash = forms.ChoiceField(
130 label=gettext_lazy("Commit squashing"),
131 widget=forms.RadioSelect,
132 choices=(
133 ("all", gettext_lazy("All commits into one")),
134 ("language", gettext_lazy("Per language")),
135 ("file", gettext_lazy("Per file")),
136 ("author", gettext_lazy("Per author")),
137 ),
138 initial="all",
139 required=True,
140 )
141 append_trailers = forms.BooleanField(
142 label=gettext_lazy("Append trailers to squashed commit message"),
143 required=False,
144 initial=True,
145 help_text=gettext_lazy(
146 "Trailer lines are lines that look similar to RFC 822 e-mail "
147 "headers, at the end of the otherwise free-form part of a commit "
148 "message, such as 'Co-authored-by: …'."
149 ),
150 )
151 commit_message = forms.CharField(
152 label=gettext_lazy("Commit message"),
153 widget=forms.Textarea(),
154 required=False,
155 help_text=gettext_lazy(
156 "This commit message will be used instead of the combined commit "
157 "messages from the squashed commits."
158 ),
159 )
161 def __init__(self, *args, **kwargs) -> None:
162 super().__init__(*args, **kwargs)
163 self.helper = FormHelper(self)
164 self.helper.layout = Layout(
165 Field("squash"),
166 Field("append_trailers"),
167 Field("commit_message"),
168 ContextDiv(template="addons/squash_help.html", context={"user": self.user}),
169 )
172class RemoveForm(BaseAddonForm):
173 age = forms.IntegerField(
174 label=gettext_lazy("Days to keep"), min_value=0, initial=30, required=True
175 )
178class RemoveSuggestionForm(RemoveForm):
179 votes = forms.IntegerField(
180 label=gettext_lazy("Voting threshold"),
181 initial=0,
182 required=True,
183 help_text=gettext_lazy(
184 "Threshold for removal. This field has no effect with voting turned off."
185 ),
186 )
189class DiscoveryForm(BaseAddonForm):
190 match = forms.CharField(
191 label=gettext_lazy("Regular expression to match translation files against"),
192 required=True,
193 )
194 file_format = forms.ChoiceField(
195 label=gettext_lazy("File format"),
196 choices=FILE_FORMATS.get_choices(empty=True),
197 initial="",
198 required=True,
199 )
200 name_template = forms.CharField(
201 label=gettext_lazy("Customize the component name"),
202 initial="{{ component }}",
203 required=True,
204 )
205 base_file_template = forms.CharField(
206 label=gettext_lazy("Define the monolingual base filename"),
207 initial="",
208 required=False,
209 help_text=gettext_lazy("Leave empty for bilingual translation files."),
210 )
211 new_base_template = forms.CharField(
212 label=gettext_lazy("Define the base file for new translations"),
213 initial="",
214 required=False,
215 help_text=gettext_lazy(
216 "Filename of file used for creating new translations. "
217 "For gettext choose .pot file."
218 ),
219 )
220 intermediate_template = forms.CharField(
221 label=gettext_lazy("Intermediate language file"),
222 initial="",
223 required=False,
224 help_text=gettext_lazy(
225 "Filename of intermediate translation file. In most cases "
226 "this is a translation file provided by developers and is "
227 "used when creating actual source strings."
228 ),
229 )
231 language_regex = forms.CharField(
232 label=gettext_lazy("Language filter"),
233 max_length=200,
234 initial="^[^.]+$",
235 validators=[validate_re],
236 help_text=gettext_lazy(
237 "Regular expression to filter "
238 "translation files against when scanning for file mask."
239 ),
240 )
241 copy_addons = forms.BooleanField(
242 label=gettext_lazy(
243 "Clone add-ons from the main component to the newly created ones"
244 ),
245 required=False,
246 initial=True,
247 )
248 remove = forms.BooleanField(
249 label=gettext_lazy("Remove components for inexistent files"), required=False
250 )
251 confirm = forms.BooleanField(
252 label=gettext_lazy("I confirm the above matches look correct"),
253 required=False,
254 widget=forms.HiddenInput,
255 )
257 def __init__(self, *args, **kwargs) -> None:
258 super().__init__(*args, **kwargs)
259 self.helper = FormHelper(self)
260 self.helper.layout = Layout(
261 Field("match"),
262 Field("file_format"),
263 Field("name_template"),
264 Field("base_file_template"),
265 Field("new_base_template"),
266 Field("intermediate_template"),
267 Field("language_regex"),
268 Field("copy_addons"),
269 Field("remove"),
270 )
271 if self.is_bound:
272 # Perform form validation
273 self.full_clean()
274 # Show preview if form was submitted
275 if self.cleaned_data.get("preview"):
276 self.fields["confirm"].widget = forms.CheckboxInput()
277 self.helper.layout.insert(0, Field("confirm"))
278 created, matched, deleted, skipped = self.discovery.perform(
279 preview=True, remove=self.cleaned_data["remove"]
280 )
281 self.helper.layout.insert(
282 0,
283 ContextDiv(
284 template="addons/discovery_preview.html",
285 context={
286 "matches_created": created,
287 "matches_matched": matched,
288 "matches_deleted": deleted,
289 "matches_skipped": skipped,
290 "user": self.user,
291 },
292 ),
293 )
295 @cached_property
296 def discovery(self):
297 return ComponentDiscovery(
298 self._addon.instance.component,
299 **ComponentDiscovery.extract_kwargs(self.cleaned_data),
300 )
302 def clean(self) -> None:
303 if file_format := self.cleaned_data.get("file_format"):
304 is_monolingual = FILE_FORMATS[file_format].monolingual
305 if is_monolingual and not self.cleaned_data.get("base_file_template"):
306 raise forms.ValidationError(
307 {
308 "base_file_template": gettext(
309 "You can not use a monolingual translation without a base file."
310 )
311 }
312 )
313 if is_monolingual is False and self.cleaned_data["base_file_template"]:
314 raise forms.ValidationError(
315 {
316 "base_file_template": gettext(
317 "You can not use a base file for bilingual translation."
318 )
319 }
320 )
322 self.cleaned_data["preview"] = False
324 # There are some other errors or the form was loaded from db
325 if self.errors or not isinstance(self.data, QueryDict):
326 return
328 self.cleaned_data["preview"] = True
329 if not self.cleaned_data["confirm"]:
330 raise forms.ValidationError(
331 gettext("Please review and confirm the matched components.")
332 )
334 def clean_match(self):
335 match = self.cleaned_data["match"]
336 validate_re(match, ("component", "language"))
337 return match
339 @cached_property
340 def cleaned_match_re(self):
341 if "match" not in self.cleaned_data:
342 return None
343 try:
344 return re.compile(self.cleaned_data["match"])
345 except re.error:
346 return None
348 def test_render(self, value):
349 if self.cleaned_match_re is None:
350 matches = {"component": "test"}
351 else:
352 matches = dict.fromkeys(self.cleaned_match_re.groupindex, "test")
353 return validate_render(value, **matches)
355 def template_clean(self, name):
356 result = self.test_render(self.cleaned_data[name])
357 if result and result == self.cleaned_data[name]:
358 raise forms.ValidationError(
359 gettext("Please include component markup in the template.")
360 )
361 return self.cleaned_data[name]
363 def clean_name_template(self):
364 return self.template_clean("name_template")
366 def clean_base_file_template(self):
367 return self.template_clean("base_file_template")
369 def clean_new_base_template(self):
370 return self.template_clean("new_base_template")
372 def clean_intermediate_template(self):
373 return self.template_clean("intermediate_template")
376class AutoAddonForm(BaseAddonForm, AutoForm):
377 def __init__(self, user: User, addon, instance=None, **kwargs) -> None:
378 BaseAddonForm.__init__(self, user, addon)
379 AutoForm.__init__(
380 self, obj=addon.instance.component or addon.instance.project, **kwargs
381 )
384class BulkEditAddonForm(BaseAddonForm, BulkEditForm):
385 def __init__(self, user: User | None, addon, instance=None, **kwargs) -> None:
386 BaseAddonForm.__init__(self, user, addon)
387 obj: Project | Component | None = None
388 project: Project | None = None
389 if addon.instance.component:
390 obj = addon.instance.component
391 project = addon.instance.component.project
392 elif addon.instance.project:
393 obj = project = addon.instance.project
394 BulkEditForm.__init__(
395 self,
396 obj=obj,
397 project=project,
398 user=None,
399 **kwargs,
400 )
402 def serialize_form(self):
403 result = dict(self.cleaned_data)
404 # Need to convert to JSON serializable objects
405 result["add_labels"] = list(result["add_labels"].values_list("name", flat=True))
406 result["remove_labels"] = list(
407 result["remove_labels"].values_list("name", flat=True)
408 )
409 return result
412class CDNJSForm(BaseAddonForm):
413 threshold = forms.IntegerField(
414 label=gettext_lazy("Translation threshold"),
415 initial=0,
416 max_value=100,
417 min_value=0,
418 required=True,
419 help_text=gettext_lazy(
420 "The percentage of translated strings that must be present for translation to be included."
421 ),
422 )
423 css_selector = forms.CharField(
424 label=gettext_lazy("CSS selector"),
425 required=True,
426 initial=".l10n",
427 help_text=gettext_lazy("CSS selector to detect localizable elements."),
428 )
429 cookie_name = forms.CharField(
430 label=gettext_lazy("Language cookie name"),
431 required=False,
432 initial="",
433 help_text=gettext_lazy("Name of cookie which stores language preference."),
434 )
435 # This shadows files from the Form class
436 files = forms.CharField( # type: ignore[assignment]
437 widget=forms.Textarea(),
438 label=gettext_lazy("Extract strings from HTML files"),
439 required=False,
440 help_text=gettext_lazy(
441 "List of filenames in current repository or remote URLs to parse "
442 "for translatable strings."
443 ),
444 )
446 def __init__(self, *args, **kwargs) -> None:
447 super().__init__(*args, **kwargs)
448 self.helper = FormHelper(self)
449 self.helper.layout = Layout(
450 Field("threshold"),
451 Field("css_selector"),
452 Field("cookie_name"),
453 Field("files"),
454 )
455 if self.is_bound and self._addon.instance.pk:
456 self.helper.layout.insert(
457 0,
458 ContextDiv(
459 template="addons/cdnjs.html",
460 context={"url": self._addon.cdn_js_url, "user": self.user},
461 ),
462 )
464 def clean_css_selector(self):
465 try:
466 CSSSelector(self.cleaned_data["css_selector"], translator="html")
467 except Exception as error:
468 raise forms.ValidationError(
469 gettext("Could not parse CSS selector: %s") % error
470 ) from error
471 return self.cleaned_data["css_selector"]
474class TranslationLanguageChoiceField(CachedModelChoiceField):
475 def label_from_instance(self, obj):
476 return str(obj.language)
479class PseudolocaleAddonForm(BaseAddonForm):
480 source = TranslationLanguageChoiceField(
481 label=gettext_lazy("Source strings"),
482 required=True,
483 queryset=Translation.objects.none(),
484 )
485 target = TranslationLanguageChoiceField(
486 label=gettext_lazy("Target translation"),
487 required=True,
488 help_text=gettext_lazy("All strings in this translation will be overwritten"),
489 queryset=Translation.objects.none(),
490 )
491 # This shadows prefix from the Form class
492 prefix = forms.CharField( # type: ignore[assignment]
493 label=gettext_lazy("Prepended static text"),
494 required=False,
495 initial="",
496 )
497 var_prefix = forms.CharField(
498 label=gettext_lazy("Prepended variable text"),
499 required=False,
500 initial="",
501 )
502 suffix = forms.CharField(
503 label=gettext_lazy("Appended static text"),
504 required=False,
505 initial="",
506 )
507 var_suffix = forms.CharField(
508 label=gettext_lazy("Appended variable text"),
509 required=False,
510 initial="",
511 )
512 var_multiplier = forms.FloatField(
513 label=gettext_lazy("Variable text multiplier"),
514 required=False,
515 initial=0.1,
516 help_text=gettext_lazy(
517 "How many times to repeat the variable text depending on "
518 "the length of the source string."
519 ),
520 )
521 include_readonly = forms.BooleanField(
522 label=gettext_lazy("Include read-only strings"),
523 required=False,
524 )
526 def __init__(self, *args, **kwargs) -> None:
527 super().__init__(*args, **kwargs)
528 if self._addon.instance.component:
529 queryset = self._addon.instance.component.translation_set.all()
530 self.fields["source"].queryset = queryset
531 self.fields["target"].queryset = queryset
532 self.helper = FormHelper(self)
533 self.helper.layout = Layout(
534 Field("source"),
535 Field("include_readonly"),
536 Field("target"),
537 Field("prefix"),
538 Field("var_prefix"),
539 Field("suffix"),
540 Field("var_suffix"),
541 Field("var_multiplier"),
542 ContextDiv(
543 template="addons/pseudolocale.html",
544 ),
545 )
547 def clean(self) -> None:
548 if "source" not in self.cleaned_data or "target" not in self.cleaned_data:
549 return
550 if self.cleaned_data["source"] == self.cleaned_data["target"]:
551 raise forms.ValidationError(
552 gettext("The source and target have to be different languages.")
553 )
555 def serialize_form(self):
556 result = dict(self.cleaned_data)
557 # Need to convert to JSON serializable objects
558 result["source"] = result["source"].pk
559 result["target"] = result["target"].pk
560 return result
563class PropertiesSortAddonForm(BaseAddonForm):
564 case_sensitive = forms.BooleanField(
565 label=gettext_lazy("Enable case-sensitive key sorting"),
566 required=False,
567 initial=False,
568 )
571class ChangeBaseAddonForm(BaseAddonForm):
572 """Base form for Change-based addons."""
574 events = forms.MultipleChoiceField(
575 label=gettext_lazy("Change events"),
576 required=False,
577 widget=SortedSelectMultiple(),
578 choices=ActionEvents.choices,
579 )
582class BaseWebhooksAddonForm(ChangeBaseAddonForm):
583 """Form for webhook add-on configuration."""
585 webhook_url = WeblateServiceURLField(
586 label=gettext_lazy("Webhook URL"),
587 required=True,
588 )
590 field_order = [ # noqa: RUF012
591 "webhook_url",
592 "events",
593 ]
596class WebhooksAddonForm(BaseWebhooksAddonForm):
597 """Form for webhook add-on configuration."""
599 secret = forms.CharField(
600 label=gettext_lazy("Secret"),
601 validators=[
602 validate_base64_encoded_string,
603 ],
604 required=False,
605 help_text=gettext_lazy("A Base64 encoded string"),
606 )
608 field_order = [ # noqa: RUF012
609 "webhook_url",
610 "secret",
611 "events",
612 ]