Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/forms.py: 33%
1361 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 copy
8import json
9import re
10from collections import defaultdict
11from datetime import datetime
12from itertools import chain
13from secrets import token_hex
14from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
16import jsonschema
17from crispy_forms.bootstrap import InlineCheckboxes, InlineRadios, Tab, TabHolder
18from crispy_forms.helper import FormHelper
19from crispy_forms.layout import (
20 HTML,
21 Div,
22 Field,
23 Fieldset,
24 Layout,
25)
26from django import forms
27from django.conf import settings
28from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
29from django.core.validators import FileExtensionValidator, validate_slug
30from django.db.models import Count, Q
31from django.forms import model_to_dict
32from django.forms.utils import from_current_timezone
33from django.template.loader import render_to_string
34from django.urls import reverse
35from django.utils import timezone
36from django.utils.html import format_html, format_html_join
37from django.utils.http import urlencode
38from django.utils.safestring import mark_safe
39from django.utils.text import normalize_newlines, slugify
40from django.utils.translation import gettext, gettext_lazy
41from translation_finder import DiscoveryResult, discover
43from weblate.auth.models import Group, User
44from weblate.checks.flags import Flags
45from weblate.checks.models import CHECKS
46from weblate.checks.utils import highlight_string
47from weblate.configuration.models import Setting, SettingCategory
48from weblate.formats.models import EXPORTERS, FILE_FORMATS
49from weblate.lang.models import Language
50from weblate.machinery.models import MACHINERY
51from weblate.trans.actions import ActionEvents
52from weblate.trans.backups import ProjectBackup
53from weblate.trans.defines import (
54 BRANCH_LENGTH,
55 COMPONENT_NAME_LENGTH,
56 FILENAME_LENGTH,
57 REPO_LENGTH,
58)
59from weblate.trans.file_format_params import (
60 FILE_FORMATS_PARAMS,
61 get_params_for_file_format,
62 strip_unused_file_format_params,
63)
64from weblate.trans.filter import FILTERS
65from weblate.trans.models import (
66 Announcement,
67 Category,
68 Change,
69 CommitPolicyChoices,
70 Component,
71 Label,
72 Project,
73 Unit,
74 WorkflowSetting,
75)
76from weblate.trans.specialchars import RTL_CHARS_DATA, get_special_chars
77from weblate.trans.util import check_upload_method_permissions, is_repo_link
78from weblate.trans.validators import validate_check_flags
79from weblate.utils.antispam import is_spam
80from weblate.utils.files import FileUploadMethod
81from weblate.utils.forms import (
82 CachedModelMultipleChoiceField,
83 ColorWidget,
84 ContextDiv,
85 EmailField,
86 NormalizedNewlineCharField,
87 QueryField,
88 SearchField,
89 SortedSelect,
90 SortedSelectMultiple,
91 UserField,
92 WeblateDateInput,
93)
94from weblate.utils.hash import checksum_to_hash, hash_to_checksum
95from weblate.utils.html import format_html_join_comma
96from weblate.utils.state import (
97 STATE_APPROVED,
98 STATE_EMPTY,
99 STATE_FUZZY,
100 STATE_READONLY,
101 STATE_TRANSLATED,
102 StringState,
103 get_state_label,
104)
105from weblate.utils.validators import validate_file_extension
106from weblate.utils.views import get_sort_name
107from weblate.vcs.models import VCS_REGISTRY
109if TYPE_CHECKING: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 from django.db.models import Model, QuerySet
112 from weblate.accounts.models import Profile
113 from weblate.auth.models import AuthenticatedHttpRequest
114 from weblate.trans.mixins import BaseURLMixin, URLMixin
115 from weblate.trans.models import (
116 Translation,
117 )
118 from weblate.trans.models.translation import NewUnitParams
120BUTTON_TEMPLATE = """
121<button type="button" class="btn btn-default {0}" title="{1}" {2}>{3}</button>
122"""
123RADIO_TEMPLATE = """
124<label class="btn btn-default {0}" title="{1}">
125<input type="radio" name="{2}" value="{3}" {4}/>
126{5}
127</label>
128"""
129GROUP_TEMPLATE = """
130<div class="btn-group btn-group-xs" {0}>{1}</div>
131"""
132TOOLBAR_TEMPLATE = """
133<div class="btn-toolbar pull-right flip editor-toolbar">{0}</div>
134"""
137class FieldDocsMixin(forms.Form):
138 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
139 raise NotImplementedError
142class MarkdownTextarea(forms.Textarea):
143 def __init__(self, **kwargs) -> None:
144 kwargs["attrs"] = {
145 "dir": "auto",
146 "class": "markdown-editor highlight-editor",
147 "data-mode": "markdown",
148 }
149 super().__init__(**kwargs)
152class DateRangeField(forms.CharField):
153 """Field for a date range input."""
155 def __init__(self, **kwargs) -> None:
156 super().__init__(**kwargs)
158 def to_python(self, value):
159 """Convert the string input into data range values."""
160 value = super().to_python(value)
161 if value in self.empty_values:
162 return None
163 try:
164 start, end = value.split(" - ")
165 start_date = datetime.strptime(start, "%m/%d/%Y").replace( # noqa: DTZ007
166 hour=0, minute=0, second=0, microsecond=0
167 )
168 end_date = datetime.strptime(end, "%m/%d/%Y").replace( # noqa: DTZ007
169 hour=23, minute=59, second=59, microsecond=999999
170 )
171 return {
172 "start_date": from_current_timezone(start_date),
173 "end_date": from_current_timezone(end_date),
174 }
175 except ValueError as error:
176 raise ValidationError(gettext("Invalid date!")) from error
178 def validate(self, value) -> None:
179 """Validate the date range values."""
180 if self.required:
181 super().validate(value)
183 if value not in self.empty_values and value["start_date"] > value["end_date"]:
184 raise ValidationError(
185 gettext("The starting date has to be before the ending date.")
186 )
188 def clean(self, value):
189 """Produce a clean and validated date range values."""
190 value = self.to_python(value)
191 self.validate(value)
192 return value
195class ChecksumField(forms.CharField):
196 """Field for handling checksum IDs for translation."""
198 def __init__(self, *args, **kwargs) -> None:
199 kwargs["widget"] = forms.HiddenInput
200 super().__init__(*args, **kwargs)
202 def clean(self, value):
203 super().clean(value)
204 if not value:
205 return None
206 try:
207 return checksum_to_hash(value)
208 except ValueError as error:
209 raise ValidationError(gettext("Invalid checksum specified!")) from error
212class FlagField(forms.CharField):
213 default_validators = [validate_check_flags] # noqa: RUF012
216class PluralTextarea(forms.Textarea):
217 """Text-area extension which possibly handles plurals."""
219 profile: Profile
221 def __init__(self, *args, **kwargs) -> None:
222 self.is_source_plural: Literal[True] | None = None
223 super().__init__(*args, **kwargs)
225 def get_rtl_toolbar(self, fieldname):
226 # Special chars
227 chars = format_html_join(
228 "\n",
229 BUTTON_TEMPLATE,
230 (
231 (
232 "specialchar",
233 name,
234 format_html(
235 'data-value="{}"',
236 mark_safe( # noqa: S308
237 value.encode("ascii", "xmlcharrefreplace").decode("ascii")
238 ),
239 ),
240 char,
241 )
242 for name, char, value in RTL_CHARS_DATA
243 ),
244 )
246 groups = format_html_join(
247 "\n",
248 GROUP_TEMPLATE,
249 [("", chars)], # Only one group.
250 )
252 return format_html(TOOLBAR_TEMPLATE, groups)
254 def get_rtl_toggle(self, language, fieldname):
255 if language.direction != "rtl":
256 return ""
258 # RTL/LTR switch
259 rtl_name = f"rtl-{fieldname}"
260 rtl_switch = format_html_join(
261 "\n",
262 RADIO_TEMPLATE,
263 [
264 (
265 "direction-toggle active",
266 gettext("Toggle text direction"),
267 rtl_name,
268 "rtl",
269 mark_safe('checked="checked"'),
270 "RTL",
271 ),
272 (
273 "direction-toggle",
274 gettext("Toggle text direction"),
275 rtl_name,
276 "ltr",
277 "",
278 "LTR",
279 ),
280 ],
281 )
282 groups = format_html_join(
283 "\n",
284 GROUP_TEMPLATE,
285 [
286 (
287 mark_safe('data-toggle="buttons"'),
288 rtl_switch,
289 )
290 ], # Only one group.
291 )
292 return format_html(TOOLBAR_TEMPLATE, groups)
294 def get_toolbar(self, language, fieldname, unit, idx, source):
295 """Return toolbar HTML code."""
296 profile = self.profile
298 # Special chars
299 chars = format_html_join(
300 "\n",
301 BUTTON_TEMPLATE,
302 (
303 (
304 "specialchar",
305 name,
306 format_html(
307 'data-value="{}" tabindex="-1"',
308 mark_safe( # noqa: S308
309 value.encode("ascii", "xmlcharrefreplace").decode("ascii")
310 ),
311 ),
312 char,
313 )
314 for name, char, value in get_special_chars(
315 language, profile.special_chars, unit.source
316 )
317 ),
318 )
320 groups = format_html_join(
321 "\n",
322 GROUP_TEMPLATE,
323 [("", chars)], # Only one group.
324 )
326 result = format_html(TOOLBAR_TEMPLATE, groups)
328 if language.direction == "rtl":
329 result = format_html("{}{}", self.get_rtl_toolbar(fieldname), result)
331 return result
333 def render(self, name, value, attrs=None, renderer=None, **kwargs):
334 """Render all textareas with correct plural labels."""
335 unit = value
336 translation = unit.translation
337 lang_label = lang = translation.language
338 if self.is_source_plural:
339 plurals = translation.get_source_plurals()
340 values = plurals
341 else:
342 plurals = unit.get_source_plurals()
343 values = unit.get_target_plurals()
344 if "zen-mode" in self.attrs:
345 lang_label = format_html(
346 '<a class="language" href="{}" tabindex="-1">{}</a>',
347 unit.get_absolute_url(),
348 lang_label,
349 )
350 plural = translation.plural
351 placeables_set = set()
352 for text in plurals:
353 placeables_set.update(hl[2] for hl in highlight_string(text, unit))
354 placeables = list(placeables_set)
355 show_plural_labels = len(values) > 1 and not translation.component.is_multivalue
357 # Need to add extra class
358 attrs["class"] = "translation-editor form-control highlight-editor"
359 attrs["lang"] = lang.code
360 attrs["dir"] = lang.direction
361 attrs["rows"] = 3
362 attrs["data-max"] = unit.get_max_length()
363 attrs["data-mode"] = unit.edit_mode
364 attrs["data-placeables"] = "|".join(re.escape(pl) for pl in placeables if pl)
365 if unit.readonly:
366 attrs["readonly"] = 1
368 # Okay we have more strings
369 ret = []
370 base_id = f"id_{unit.checksum}"
371 for idx, val in enumerate(values):
372 # Generate ID
373 fieldname = f"{name}_{idx}"
374 fieldid = f"{base_id}_{idx}"
375 attrs["id"] = fieldid
376 source = plurals[1] if idx and len(plurals) > 1 else plurals[0]
378 # Render textare
379 textarea = super().render(fieldname, val, attrs, renderer, **kwargs)
380 # Label for plural
381 label = lang_label
382 if show_plural_labels:
383 label = format_html("{}, {}", label, plural.get_plural_label(idx))
384 elif translation.component.is_multivalue and idx > 0:
385 label = format_html("{}, {}", label, gettext("Alternative translation"))
386 ret.append(
387 render_to_string(
388 "snippets/editor.html",
389 {
390 "toolbar": self.get_toolbar(lang, fieldid, unit, idx, source),
391 "fieldid": fieldid,
392 "label": label,
393 "textarea": textarea,
394 "max_length": attrs["data-max"],
395 "length": len(val),
396 "source_length": len(source),
397 "rtl_toggle": self.get_rtl_toggle(lang, fieldid),
398 },
399 )
400 )
402 # Show plural formula for more strings
403 if show_plural_labels:
404 ret.append(
405 render_to_string(
406 "snippets/plural-formula.html",
407 {"plural": plural, "user": self.profile.user},
408 )
409 )
411 return format_html_join("", "{}", ((v,) for v in ret))
413 def value_from_datadict(self, data, files, name):
414 """Return processed plurals as a list."""
415 ret = []
416 for idx in range(10):
417 fieldname = f"{name}_{idx:d}"
418 if fieldname not in data:
419 break
420 ret.append(data.get(fieldname, ""))
421 return [normalize_newlines(r) for r in ret]
424class PluralField(forms.CharField):
425 """
426 Renderer for the plural field.
428 The only difference from CharField is that it does not
429 enforce the value to be a string.
430 """
432 def __init__(self, **kwargs) -> None:
433 kwargs["label"] = ""
434 super().__init__(widget=PluralTextarea, **kwargs)
436 def to_python(self, value):
437 """Return list of strings as returned by PluralTextarea."""
438 return value
440 def clean(self, value):
441 value = super().clean(value)
442 if not value or (self.required and not any(value)):
443 raise ValidationError(self.error_messages["required"], code="required")
444 return value
447class ChecksumForm(forms.Form):
448 """Form for handling checksum IDs for translation."""
450 checksum = ChecksumField(required=True)
452 def __init__(self, unit_set, *args, **kwargs) -> None:
453 self.unit_set = unit_set
454 super().__init__(*args, **kwargs)
456 def clean_checksum(self) -> str | None:
457 """Validate whether checksum is valid and fetches unit for it."""
458 if "checksum" not in self.cleaned_data:
459 return None
461 unit_set = self.unit_set
463 checksum = self.cleaned_data["checksum"]
464 try:
465 self.cleaned_data["unit"] = unit_set.filter(id_hash=checksum)[0]
466 except (Unit.DoesNotExist, IndexError) as error:
467 raise ValidationError(
468 gettext("The string you wanted to translate is no longer available.")
469 ) from error
470 return checksum
473class UnitForm(forms.Form):
474 def __init__(self, unit: Unit, *args, **kwargs) -> None:
475 self.unit = unit
476 super().__init__(*args, **kwargs)
479class FuzzyField(forms.BooleanField):
480 help_as_icon = True
482 def __init__(self, *args, **kwargs) -> None:
483 kwargs["label"] = gettext_lazy("Needs editing")
484 kwargs["help_text"] = gettext_lazy(
485 'Strings are usually marked as "Needs editing" after the source '
486 "string is updated, or when marked as such manually."
487 )
488 super().__init__(*args, **kwargs)
489 self.widget.attrs["class"] = "fuzzy_checkbox"
492class TranslationForm(UnitForm):
493 """Form used for translation of single string."""
495 contentsum = ChecksumField(required=True)
496 translationsum = ChecksumField(required=True)
497 target = PluralField(required=False)
498 fuzzy = FuzzyField(required=False)
499 review = forms.ChoiceField(
500 label=gettext_lazy("Review state"),
501 choices=[
502 (state, get_state_label(state, label, True))
503 for state, label in StringState.choices
504 if state not in {STATE_READONLY, STATE_EMPTY}
505 ],
506 required=False,
507 widget=forms.RadioSelect,
508 )
509 explanation = forms.CharField(
510 widget=MarkdownTextarea,
511 label=gettext_lazy("Explanation"),
512 help_text=gettext_lazy(
513 "Additional explanation to clarify meaning or usage of the string."
514 ),
515 max_length=1000,
516 required=False,
517 )
519 def __init__(self, user: User, unit: Unit, *args, **kwargs) -> None:
520 if unit is not None:
521 kwargs["initial"] = {
522 "checksum": unit.checksum,
523 "contentsum": hash_to_checksum(unit.content_hash),
524 "translationsum": hash_to_checksum(unit.get_target_hash()),
525 "target": unit,
526 "fuzzy": unit.fuzzy,
527 "review": unit.state,
528 "explanation": unit.explanation,
529 }
530 kwargs["auto_id"] = f"id_{unit.checksum}_%s"
531 super().__init__(unit, *args, **kwargs)
532 if unit.readonly:
533 self.fields["target"].widget.attrs["readonly"] = 1
534 # checkbox cannot be read-only, so hide it instead
535 self.fields["fuzzy"].widget = forms.HiddenInput()
536 self.fields["review"].choices = [
537 (state, label)
538 for state, label in StringState.choices
539 if state == STATE_READONLY
540 ]
541 self.user = user
542 self.fields["target"].widget.profile = user.profile
543 self.fields["review"].widget.attrs["class"] = "review_radio"
544 # Avoid failing validation on untranslated string
545 if args:
546 self.fields["review"].choices.append((STATE_EMPTY, ""))
547 self.helper = FormHelper()
548 self.helper.form_method = "post"
549 self.helper.form_tag = False
550 self.helper.disable_csrf = True
551 self.helper.layout = Layout(
552 Field("target"),
553 Field("fuzzy"),
554 Field("contentsum"),
555 Field("translationsum"),
556 InlineRadios("review"),
557 Field("explanation"),
558 )
559 if unit and user.has_perm("unit.review", unit.translation):
560 self.fields["fuzzy"].widget = forms.HiddenInput()
561 else:
562 self.fields["review"].widget = forms.HiddenInput()
563 if unit.translation.component.is_glossary:
564 if unit.is_source:
565 self.fields["explanation"].label = gettext("Source string explanation")
566 else:
567 self.fields["explanation"].label = gettext("Translation explanation")
568 else:
569 self.fields["explanation"].widget = forms.HiddenInput()
571 def clean(self) -> None:
572 super().clean()
574 # Check required fields
575 required = {"target", "contentsum", "translationsum"}
576 if not required.issubset(self.cleaned_data):
577 return
579 unit = self.unit
581 if self.cleaned_data["contentsum"] != unit.content_hash:
582 raise ValidationError(
583 gettext(
584 "The source string has changed meanwhile. "
585 "Please check your changes."
586 )
587 )
589 if self.cleaned_data["translationsum"] != unit.get_target_hash():
590 # Allow repeated edits by the same author
591 last_author = unit.get_last_content_change()[0]
592 if last_author != self.user:
593 raise ValidationError(
594 gettext(
595 "The translation of the string has changed meanwhile. "
596 "Please check your changes."
597 )
598 )
600 # Add extra margin to limit to allow XML tags which might
601 # be ignored for the length calculation. On the other side,
602 # we do not want to process arbitrarily long strings here.
603 max_length = 10 * (unit.get_max_length() + 100)
604 for text in self.cleaned_data["target"]:
605 if len(text) > max_length:
606 raise ValidationError(gettext("Translation text too long!"))
607 if self.user.has_perm(
608 "unit.review", unit.translation
609 ) and self.cleaned_data.get("review"):
610 self.cleaned_data["state"] = int(self.cleaned_data["review"])
611 elif self.cleaned_data["fuzzy"]:
612 self.cleaned_data["state"] = STATE_FUZZY
613 else:
614 self.cleaned_data["state"] = STATE_TRANSLATED
617class ZenTranslationForm(TranslationForm):
618 checksum = ChecksumField(required=True)
620 def __init__(self, user: User, unit, *args, **kwargs) -> None:
621 super().__init__(user, unit, *args, **kwargs)
622 self.helper.form_action = reverse(
623 "save_zen", kwargs={"path": unit.translation.get_url_path()}
624 )
625 self.helper.form_tag = True
626 self.helper.disable_csrf = False
627 self.helper.layout.append(Field("checksum"))
628 self.fields["target"].widget.attrs["zen-mode"] = True
629 if not user.has_perm("unit.edit", unit):
630 for field in ["target", "fuzzy", "review"]:
631 self.fields[field].widget.attrs["disabled"] = 1
634class DownloadForm(forms.Form):
635 q = QueryField()
636 format = forms.ChoiceField(
637 label=gettext_lazy("File format"),
638 choices=[],
639 initial="po",
640 required=True,
641 widget=forms.RadioSelect,
642 )
644 def __init__(self, translation: Translation, *args, **kwargs) -> None:
645 super().__init__(*args, **kwargs)
646 self.fields["format"].choices = [
647 (x.name, x.verbose) for x in EXPORTERS.values() if x.supports(translation)
648 ]
649 self.helper = FormHelper(self)
650 self.helper.form_tag = False
651 self.helper.layout = Layout(
652 SearchField("q"),
653 InlineRadios("format"),
654 )
657class SimpleUploadForm(FieldDocsMixin, forms.Form):
658 """Base form for uploading a file."""
660 file = forms.FileField(
661 label=gettext_lazy("File"), validators=[validate_file_extension]
662 )
663 method = forms.ChoiceField(
664 label=gettext_lazy("File upload mode"),
665 choices=FileUploadMethod.choices,
666 widget=forms.RadioSelect,
667 required=True,
668 )
669 fuzzy = forms.ChoiceField(
670 label=gettext_lazy('Processing of "Needs editing" strings'),
671 choices=(
672 ("", gettext_lazy("Do not import")),
673 ("process", gettext_lazy('Import as "Needs editing"')),
674 ("approve", gettext_lazy("Import as translated")),
675 ),
676 required=False,
677 )
679 def __init__(self, *args, **kwargs) -> None:
680 super().__init__(*args, **kwargs)
681 self.helper = FormHelper(self)
682 self.helper.form_tag = False
684 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
685 return ("user/files", f"upload-{field.name}")
687 def remove_translation_choice(self, value) -> None:
688 """Remove given file upload method from choices."""
689 choices = self.fields["method"].choices
690 self.fields["method"].choices = [
691 choice for choice in choices if choice[0] != value
692 ]
695class UploadForm(SimpleUploadForm):
696 """Uploading form with the option to overwrite current messages."""
698 conflicts = forms.ChoiceField(
699 label=gettext_lazy("Conflict handling"),
700 help_text=gettext_lazy(
701 "Whether to overwrite existing translations if the string is "
702 "already translated."
703 ),
704 choices=(
705 ("", gettext_lazy("Change only untranslated strings")),
706 ("replace-translated", gettext_lazy("Change translated strings")),
707 (
708 "replace-approved",
709 gettext_lazy("Change translated and approved strings"),
710 ),
711 ),
712 required=False,
713 initial="replace-translated",
714 )
717class ExtraUploadForm(UploadForm):
718 """Advanced upload form for users who can override authorship."""
720 author_name = forms.CharField(label=gettext_lazy("Author name"))
721 author_email = EmailField(label=gettext_lazy("Author e-mail"))
724def get_upload_form(user: User, translation: Translation, *args, **kwargs):
725 """Return correct upload form based on user permissions."""
726 if user.has_perm("upload.authorship", translation):
727 form = ExtraUploadForm
728 kwargs["initial"] = {"author_name": user.full_name, "author_email": user.email}
729 elif user.has_perm("upload.overwrite", translation):
730 form = UploadForm
731 else:
732 form = SimpleUploadForm
733 result = form(*args, **kwargs)
734 for method in [x[0] for x in result.fields["method"].choices]:
735 if not check_upload_method_permissions(user, translation, method):
736 result.remove_translation_choice(method)
737 # Remove approved choice for non review projects
738 if not user.has_perm("unit.review", translation) and form != SimpleUploadForm:
739 result.fields["conflicts"].choices = [
740 choice
741 for choice in result.fields["conflicts"].choices
742 if choice[0] != "approved"
743 ]
744 return result
747class SearchForm(forms.Form):
748 """Text-searching form."""
750 q = QueryField()
751 sort_by = forms.CharField(required=False, widget=forms.HiddenInput)
752 checksum = ChecksumField(required=False)
753 offset = forms.IntegerField(min_value=-1, required=False, widget=forms.HiddenInput)
754 offset_kwargs: ClassVar[dict[str, str]] = {}
756 @staticmethod
757 def get_initial(request: AuthenticatedHttpRequest):
758 if "q" in request.GET:
759 return {"q": request.GET["q"]}
760 return None
762 def __init__(
763 self,
764 *,
765 request: AuthenticatedHttpRequest,
766 language: Language | None = None,
767 show_builder=True,
768 obj: type[Model | BaseURLMixin] | None = None,
769 bootstrap_5=False,
770 **kwargs,
771 ) -> None:
772 """Generate choices for other components in the same project."""
773 self.user = request.user
774 self.language = language
775 sort_by = get_sort_name(request, obj)
776 self.sort_name = sort_by["name"]
777 self.sort_query = sort_by["query"]
778 super().__init__(**kwargs)
780 self.helper = FormHelper(self)
781 self.helper.disable_csrf = True
782 self.helper.form_tag = False
783 if bootstrap_5:
784 self.helper.template_pack = "bootstrap5"
785 self.helper.layout = Layout(
786 Div(
787 Field("offset", **self.offset_kwargs),
788 SearchField("q"),
789 Field("sort_by", template="snippets/sort-field.html"),
790 css_class="btn-toolbar",
791 role="toolbar",
792 ),
793 ContextDiv(
794 template="snippets/query-builder.html",
795 context={
796 "user": self.user,
797 "show_builder": show_builder,
798 "language": self.language,
799 "bootstrap_5": bootstrap_5,
800 },
801 ),
802 Field("checksum"),
803 # Add hidden submit button so that submission via enter works
804 # See https://stackoverflow.com/a/477699/225718
805 HTML('<input type="submit" hidden />'),
806 )
808 def get_name(self):
809 """Return verbose name for a search."""
810 return FILTERS.get_search_name(self.cleaned_data.get("q", ""))
812 def clean_offset(self):
813 if self.cleaned_data.get("offset") is None:
814 self.cleaned_data["offset"] = 1
815 return self.cleaned_data["offset"]
817 def items(self):
818 items = []
819 # Skip checksum and offset as these change
820 ignored = {"offset", "checksum"}
821 for param in sorted(self.cleaned_data):
822 value = self.cleaned_data[param]
823 # We don't care about empty values or ignored ones
824 if value is None or param in ignored:
825 continue
826 if isinstance(value, bool):
827 # Only store true values
828 if value:
829 items.append((param, "1"))
830 elif isinstance(value, int):
831 # Avoid storing 0 values
832 if value > 0:
833 items.append((param, str(value)))
834 elif isinstance(value, datetime):
835 # Convert date to string
836 items.append((param, value.date().isoformat()))
837 elif isinstance(value, list):
838 items.extend((param, val) for val in value)
839 elif isinstance(value, User):
840 items.append((param, value.username))
841 elif value:
842 # It should be a string here
843 items.append((param, value))
844 return items
846 def urlencode(self):
847 return urlencode(self.items())
849 def reset_offset(self):
850 """
851 Reset form offset.
853 This is needed to avoid issues when using the form as the default for
854 any new search.
855 """
856 data = copy.copy(self.data) # pylint: disable=access-member-before-definition
857 data["offset"] = "1"
858 data["checksum"] = ""
859 self.data = data
860 return self
863class PositionSearchForm(SearchForm):
864 offset = forms.IntegerField(min_value=-1, required=False)
865 offset_kwargs: ClassVar[dict[str, str]] = {
866 "template": "snippets/position-field.html"
867 }
870class MergeForm(UnitForm):
871 """Simple form for merging the translation of two units."""
873 merge = forms.IntegerField()
875 def clean(self):
876 super().clean()
877 if "merge" not in self.cleaned_data:
878 return None
879 unit = self.unit
880 translation = unit.translation
881 project = translation.component.project
882 try:
883 self.cleaned_data["merge_unit"] = merge_unit = Unit.objects.get(
884 pk=self.cleaned_data["merge"],
885 translation__component__project=project,
886 translation__language=translation.language,
887 )
888 except Unit.DoesNotExist as error:
889 raise ValidationError(
890 gettext("Could not find the merged string.")
891 ) from error
892 else:
893 # Compare in Python to ensure case sensitiveness on MySQL
894 if not translation.is_source and unit.source != merge_unit.source:
895 raise ValidationError(gettext("Could not find the merged string."))
896 return self.cleaned_data
899class RevertForm(UnitForm):
900 """Form for reverting edits."""
902 revert = forms.IntegerField()
904 def clean(self):
905 super().clean()
906 if "revert" not in self.cleaned_data:
907 return None
908 try:
909 self.cleaned_data["revert_change"] = Change.objects.get(
910 pk=self.cleaned_data["revert"], unit=self.unit
911 )
912 except Change.DoesNotExist as error:
913 raise ValidationError(
914 gettext("Could not find the reverted change.")
915 ) from error
916 return self.cleaned_data
919class AutoForm(forms.Form):
920 """Automatic translation form."""
922 mode = forms.ChoiceField(
923 label=gettext_lazy("Automatic translation mode"),
924 initial="suggest",
925 )
926 q = QueryField(
927 required=True,
928 initial="state:<translated",
929 help_text=gettext_lazy(
930 "Please note that translating all strings will "
931 "discard all existing translations."
932 ),
933 )
934 auto_source = forms.ChoiceField(
935 label=gettext_lazy("Source of automated translations"),
936 choices=[
937 ("others", gettext_lazy("Other translation components")),
938 ("mt", gettext_lazy("Machine translation")),
939 ],
940 initial="others",
941 widget=forms.RadioSelect,
942 )
943 component = forms.ChoiceField(
944 label=gettext_lazy("Component"),
945 required=False,
946 help_text=gettext_lazy(
947 "Turn on contribution to shared translation memory for the project to "
948 "get access to additional components."
949 ),
950 initial="",
951 )
952 engines = forms.MultipleChoiceField(
953 label=gettext_lazy("Machine translation engines"), choices=[], required=False
954 )
955 threshold = forms.IntegerField(
956 label=gettext_lazy("Score threshold"), initial=80, min_value=1, max_value=100
957 )
959 def __init__(
960 self, obj: Component | Project | None, user=None, *args, **kwargs
961 ) -> None:
962 """Generate choices for other components in the same project."""
963 auto_id = kwargs.pop("auto_id", "id_auto_%s")
964 super().__init__(*args, auto_id=auto_id, **kwargs)
965 self.obj = obj
966 self.project: Project | None = None
967 machinery_settings = {}
969 if isinstance(obj, Component): 969 ↛ 978line 969 didn't jump to line 978 because the condition on line 969 was always true
970 self.components = obj.project.component_set.filter(
971 source_language=obj.source_language
972 ) | Component.objects.filter(
973 source_language_id=obj.source_language_id,
974 project__contribute_shared_tm=True,
975 ).exclude(project=obj.project)
976 machinery_settings = obj.project.get_machinery_settings()
977 self.project = obj.project
978 elif isinstance(obj, Project):
979 self.components = obj.component_set.filter(
980 source_language_id__in=obj.source_language_ids
981 ) | Component.objects.filter(
982 source_language_id__in=obj.source_language_ids,
983 project__contribute_shared_tm=True,
984 ).exclude(project=obj)
985 machinery_settings = obj.get_machinery_settings()
986 self.project = obj
987 else:
988 # Site-wide add-ons
989 self.components = Component.objects.all()
990 machinery_settings = Setting.objects.get_settings_dict(SettingCategory.MT)
992 # Fetching first few entries is faster than doing a count query on possibly
993 # thousands of components
994 if len(self.components.values_list("id")[:30]) == 30: 994 ↛ 996line 994 didn't jump to line 996 because the condition on line 994 was never true
995 # Do not show choices when too many
996 self.fields["component"] = forms.CharField(
997 required=False,
998 label=gettext("Component"),
999 help_text=gettext(
1000 "Enter slug of a component to use as source, "
1001 "keep blank to use all components in the current project."
1002 ),
1003 )
1004 else:
1005 choices = [
1006 (s.id, str(s))
1007 for s in self.components.order_project().prefetch_related("project")
1008 ]
1010 self.fields["component"].choices = [
1011 ("", gettext("All components in current project")),
1012 *choices,
1013 ]
1015 engines = sorted(
1016 (
1017 MACHINERY[engine](setting)
1018 for engine, setting in machinery_settings.items()
1019 if engine in MACHINERY
1020 ),
1021 key=lambda engine: engine.name,
1022 )
1023 engine_ids = {engine.get_identifier() for engine in engines}
1024 self.fields["engines"].choices = [
1025 (engine.get_identifier(), engine.name) for engine in engines
1026 ]
1027 if "weblate" in engine_ids: 1027 ↛ 1030line 1027 didn't jump to line 1030 because the condition on line 1027 was always true
1028 self.fields["engines"].initial = "weblate"
1030 if "q" not in self.initial: 1030 ↛ 1033line 1030 didn't jump to line 1033 because the condition on line 1030 was always true
1031 self.initial["q"] = "state:<translated"
1033 choices = [
1034 ("suggest", gettext("Add as suggestion")),
1035 ("translate", gettext("Add as translation")),
1036 ("fuzzy", gettext('Add as "Needing edit"')),
1037 ]
1038 if user is not None and (user.has_perm("unit.review", obj) or obj is None): 1038 ↛ 1039line 1038 didn't jump to line 1039 because the condition on line 1038 was never true
1039 choices.append(("approved", gettext("Add as approved translation")))
1040 self.fields["mode"].choices = choices
1042 self.helper = FormHelper(self)
1043 self.helper.layout = Layout(
1044 Field("mode"),
1045 SearchField("q"),
1046 InlineRadios("auto_source"),
1047 Div("component", css_id="auto_source_others"),
1048 Div("engines", "threshold", css_id="auto_source_mt"),
1049 )
1051 def clean_component(self):
1052 component = self.cleaned_data["component"]
1053 if not component: 1053 ↛ 1055line 1053 didn't jump to line 1055 because the condition on line 1053 was always true
1054 return None
1055 if component.isdigit():
1056 try:
1057 result = self.components.get(pk=component)
1058 except Component.DoesNotExist as error:
1059 raise ValidationError(gettext("Component not found!")) from error
1060 elif "/" not in component:
1061 if self.project is None:
1062 raise ValidationError(gettext("Component not found!"))
1063 try:
1064 result = self.components.get(slug=component, project=self.project)
1065 except Component.DoesNotExist as error:
1066 raise ValidationError(gettext("Component not found!")) from error
1067 else:
1068 try:
1069 result = self.components.get_by_path(component)
1070 except Component.DoesNotExist as error:
1071 raise ValidationError(gettext("Component not found!")) from error
1072 if (
1073 isinstance(self.obj, Component)
1074 and result.source_language != self.obj.source_language
1075 ):
1076 raise ValidationError(
1077 gettext(
1078 "Source component needs to have same source language as target one."
1079 )
1080 )
1081 return result.pk
1084class CommentForm(forms.Form):
1085 """Simple commenting form."""
1087 scope = forms.ChoiceField(
1088 label=gettext_lazy("Scope"),
1089 help_text=gettext_lazy(
1090 "Is your comment specific to this translation, or generic for all of them?"
1091 ),
1092 choices=(
1093 (
1094 "report",
1095 gettext_lazy("Report issue with the source string"),
1096 ),
1097 (
1098 "global",
1099 gettext_lazy(
1100 "Source string comment, suggestions for changes to this string"
1101 ),
1102 ),
1103 (
1104 "translation",
1105 gettext_lazy("Translation comment, discussions with other translators"),
1106 ),
1107 ),
1108 )
1109 comment = NormalizedNewlineCharField(
1110 widget=MarkdownTextarea,
1111 label=gettext_lazy("New comment"),
1112 help_text=gettext_lazy("You can use Markdown and mention users by @username."),
1113 max_length=1000,
1114 )
1116 def __init__(self, translation: Translation, *args, **kwargs) -> None:
1117 super().__init__(*args, **kwargs)
1118 remove: set[str] = set()
1119 # Remove bug-report in case source review is not enabled
1120 if not translation.component.project.source_review:
1121 remove.add("report")
1122 # Remove translation comment when commenting on source
1123 if translation.is_source:
1124 remove.add("translation")
1125 self.fields["scope"].choices = [
1126 choice for choice in self.fields["scope"].choices if choice[0] not in remove
1127 ]
1130class LanguageCodeChoiceField(forms.ModelChoiceField):
1131 def to_python(self, value):
1132 # Add explicit validation here to avoid DataError on invalid input
1133 # such as: PostgreSQL text fields cannot contain NUL (0x00) bytes
1134 if value:
1135 validate_slug(value)
1136 return super().to_python(value)
1139class EngageForm(forms.Form):
1140 """Form to choose language for engagement widgets."""
1142 lang = LanguageCodeChoiceField(
1143 Language.objects.none(),
1144 empty_label=gettext_lazy("All languages"),
1145 required=False,
1146 to_field_name="code",
1147 )
1148 component = forms.ModelChoiceField(
1149 Component.objects.none(),
1150 required=False,
1151 empty_label=gettext_lazy("All components"),
1152 )
1154 def __init__(self, user: User, project, *args, **kwargs) -> None:
1155 """Dynamically generate choices for used languages in the project."""
1156 super().__init__(*args, **kwargs)
1158 self.fields["lang"].queryset = project.languages
1159 self.fields["component"].queryset = (
1160 project.component_set.filter_access(user).prefetch().order()
1161 )
1164class FullLanguageForm(forms.Form):
1165 """Form for requesting a new language."""
1167 lang = forms.MultipleChoiceField(
1168 label=gettext_lazy("Languages"), choices=[], widget=forms.SelectMultiple
1169 )
1170 project: Project
1172 def __init__(self, user: User, *args, **kwargs) -> None:
1173 super().__init__(*args, **kwargs)
1174 self.user = user
1175 languages = self.get_lang_objects()
1176 self.fields["lang"].choices = languages.as_choices(user=user)
1178 def get_lang_objects(self) -> QuerySet[Language]:
1179 raise NotImplementedError
1182class RestrictedLanguageForm(forms.Form):
1183 lang = forms.ChoiceField(
1184 label=gettext_lazy("Language"), choices=[], widget=forms.Select
1185 )
1187 def __init__(self, user: User, *args, **kwargs) -> None:
1188 super().__init__(user, *args, **kwargs)
1189 self.fields["lang"].choices = [
1190 ("", gettext("Please choose")),
1191 *self.fields["lang"].choices,
1192 ]
1194 def get_lang_objects(self) -> QuerySet[Language]:
1195 return super().get_lang_objects().filter_for_add(self.project)
1197 def clean_lang(self):
1198 # Compatibility with NewLanguageOwnerForm
1199 return [self.cleaned_data["lang"]]
1202class NewComponentLanguageOwnerForm(FullLanguageForm):
1203 def get_lang_objects(self) -> QuerySet[Language]:
1204 return self.component.get_all_available_languages()
1206 def __init__(self, user: User, component: Component, *args, **kwargs) -> None:
1207 self.component = component
1208 self.project = component.project
1209 super().__init__(user, *args, **kwargs)
1212class NewComponentLanguageForm(RestrictedLanguageForm, NewComponentLanguageOwnerForm):
1213 """Form for requesting a new language."""
1216class NewProjectLanguageOwnerForm(FullLanguageForm):
1217 """Form for adding a new language to all components in a project."""
1219 def get_lang_objects(self) -> QuerySet[Language]:
1220 # Get all child components
1221 components = self.project.components_user_can_add_new_language(self.user)
1222 components_count = components.count()
1224 # Count source and target languages
1225 source_languages = components.annotate(count=Count("id")).values_list(
1226 "source_language", "count"
1227 )
1228 target_languages = components.annotate(
1229 count=Count("translation__id")
1230 ).values_list("translation__language", "count")
1232 # Summarize language count
1233 language_counter: dict[int, int] = defaultdict(int)
1234 for language_id, count in chain(source_languages, target_languages):
1235 language_counter[language_id] += count
1237 languages_in_all_components = [
1238 language_id
1239 for language_id, count in language_counter.items()
1240 if count >= components_count
1241 ]
1243 # Exclude already existing languages from the list
1244 return Language.objects.exclude(id__in=languages_in_all_components)
1246 def __init__(self, user: User, project: Project, *args, **kwargs) -> None:
1247 self.project = project
1248 super().__init__(user, *args, **kwargs)
1251class NewProjectLanguageForm(RestrictedLanguageForm, NewProjectLanguageOwnerForm):
1252 pass
1255def get_new_project_language_form(
1256 request: AuthenticatedHttpRequest, project: Project
1257) -> type[NewProjectLanguageForm | NewProjectLanguageOwnerForm]:
1258 if not request.user.has_perm("translation.add", project):
1259 raise PermissionDenied
1260 if request.user.has_perm("translation.add_more", project):
1261 return NewProjectLanguageOwnerForm
1262 return NewProjectLanguageForm
1265def get_new_component_language_form(
1266 request: AuthenticatedHttpRequest, component: Component
1267) -> type[NewComponentLanguageOwnerForm | NewComponentLanguageForm]:
1268 """Return new language form for user."""
1269 if not request.user.has_perm("translation.add", component):
1270 raise PermissionDenied
1271 if request.user.has_perm("translation.add_more", component):
1272 return NewComponentLanguageOwnerForm
1273 return NewComponentLanguageForm
1276class ContextForm(FieldDocsMixin, forms.ModelForm):
1277 class Meta:
1278 model = Unit
1279 fields = ("explanation", "labels", "extra_flags")
1280 widgets = { # noqa: RUF012
1281 "labels": forms.CheckboxSelectMultiple(),
1282 "explanation": MarkdownTextarea,
1283 }
1285 doc_links: ClassVar[dict[str, str]] = {
1286 "explanation": ("admin/translating", "additional-explanation"),
1287 "labels": ("devel/translations", "labels"),
1288 "extra_flags": ("admin/translating", "additional-flags"),
1289 }
1291 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
1292 return self.doc_links[field.name]
1294 def __init__(self, data=None, instance=None, user=None, **kwargs) -> None:
1295 kwargs["initial"] = {"labels": list(instance.all_labels)}
1296 super().__init__(data=data, instance=instance, **kwargs)
1297 project = instance.translation.component.project
1298 self.fields["labels"].queryset = project.label_set.all()
1299 self.helper = FormHelper(self)
1300 self.helper.disable_csrf = True
1301 self.helper.form_tag = False
1302 self.helper.layout = Layout(
1303 Field("explanation"),
1304 Field("labels"),
1305 ContextDiv(
1306 template="snippets/labels_description.html",
1307 context={"project": project, "user": user},
1308 ),
1309 Field("extra_flags"),
1310 )
1311 self.user = user
1313 def save(self, commit=True):
1314 self.instance.update_explanation(
1315 self.cleaned_data["explanation"], self.user, save=False
1316 )
1317 self.instance.update_extra_flags(
1318 self.cleaned_data["extra_flags"], self.user, save=False
1319 )
1320 if commit:
1321 self.instance.save(same_content=True)
1322 self._save_m2m()
1323 return self.instance
1324 return super().save(commit)
1327class UserManageForm(forms.Form):
1328 user = UserField(
1329 label=gettext_lazy("User to add"),
1330 required=True,
1331 help_text=gettext_lazy(
1332 "Please type in an existing Weblate account name or e-mail address."
1333 ),
1334 )
1337class UserAddTeamForm(UserManageForm):
1338 make_admin = forms.BooleanField(
1339 required=False,
1340 initial=False,
1341 label=gettext_lazy("Team administrator"),
1342 help_text=gettext_lazy("Allow user to add or remove users from a team."),
1343 )
1346class UserBlockForm(forms.Form):
1347 user = UserField(
1348 label=gettext_lazy("User to block"),
1349 help_text=gettext_lazy(
1350 "Please type in an existing Weblate account name or e-mail address."
1351 ),
1352 )
1353 expiry = forms.ChoiceField(
1354 label=gettext_lazy("Block duration"),
1355 choices=(
1356 ("", gettext_lazy("Block the user until I unblock")),
1357 ("1", gettext_lazy("Block the user for one day")),
1358 ("7", gettext_lazy("Block the user for one week")),
1359 ("30", gettext_lazy("Block the user for one month")),
1360 ),
1361 required=False,
1362 )
1364 def __init__(self, *args, **kwargs) -> None:
1365 if "auto_id" not in kwargs:
1366 kwargs["auto_id"] = "id_block_%s"
1367 super().__init__(*args, **kwargs)
1370class ReportsForm(forms.Form):
1371 style = forms.ChoiceField(
1372 label=gettext_lazy("Report format"),
1373 help_text=gettext_lazy("Choose a file format for the report"),
1374 choices=(
1375 ("rst", gettext_lazy("reStructuredText")),
1376 ("json", gettext_lazy("JSON")),
1377 ("html", gettext_lazy("HTML")),
1378 ),
1379 )
1380 period = DateRangeField(
1381 label=gettext_lazy("Report period"),
1382 required=True,
1383 )
1384 language = forms.ChoiceField(
1385 label=gettext_lazy("Language"),
1386 choices=[("", gettext_lazy("All languages"))],
1387 required=False,
1388 )
1389 sort_by = forms.ChoiceField(
1390 label=gettext_lazy("Sort by"),
1391 choices=[
1392 ("count", gettext_lazy("Strings translated")),
1393 ("date_joined", gettext_lazy("Date joined")),
1394 ],
1395 required=False,
1396 )
1397 sort_order = forms.ChoiceField(
1398 label=gettext_lazy("Sort order"),
1399 choices=[
1400 ("descending", gettext_lazy("Descending")),
1401 ("ascending", gettext_lazy("Ascending")),
1402 ],
1403 )
1405 def __init__(self, scope: dict[str, Model], *args, **kwargs) -> None:
1406 super().__init__(*args, **kwargs)
1407 self.helper = FormHelper(self)
1408 self.helper.form_tag = False
1409 self.helper.layout = Layout(
1410 Field("style"),
1411 Field("period"),
1412 Field("language"),
1413 Field("sort_by"),
1414 Field("sort_order"),
1415 )
1416 if not scope:
1417 languages = Language.objects.have_translation()
1418 elif "project" in scope:
1419 languages = Language.objects.filter(
1420 translation__component__project=scope["project"]
1421 ).distinct()
1422 elif "category" in scope:
1423 languages = Language.objects.filter(
1424 translation__component__category=scope["category"]
1425 ).distinct()
1426 elif "component" in scope:
1427 languages = Language.objects.filter(
1428 translation__component=scope["component"]
1429 ).exclude(pk=scope["component"].source_language_id)
1430 else:
1431 msg = f"Invalid scope: {scope}"
1432 raise ValueError(msg)
1433 self.fields["language"].choices += languages.as_choices()
1435 def clean(self) -> None:
1436 super().clean()
1437 # Invalid value, skip rest of the validation
1438 if "period" not in self.cleaned_data:
1439 return
1442class CleanRepoMixin:
1443 def clean_repo(self):
1444 repo = self.cleaned_data.get("repo")
1445 if not repo or not is_repo_link(repo) or "/" not in repo[10:]:
1446 return repo
1447 try:
1448 obj = Component.objects.get_linked(repo)
1449 except Component.DoesNotExist:
1450 return repo
1451 if not self.request.user.has_perm("component.edit", obj):
1452 raise ValidationError(
1453 gettext("You do not have permission to access this component.")
1454 )
1455 return repo
1458class SettingsBaseForm(CleanRepoMixin, forms.ModelForm):
1459 """Component base form."""
1461 class Meta:
1462 model = Component
1463 fields = [] # noqa: RUF012
1465 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1466 super().__init__(*args, **kwargs)
1467 self.request = request
1468 self.helper = FormHelper()
1469 self.helper.form_tag = False
1472class SelectChecksWidget(SortedSelectMultiple):
1473 def __init__(self, attrs=None, choices=()) -> None:
1474 choices = CHECKS.get_choices()
1475 super().__init__(attrs=attrs, choices=choices)
1477 def value_from_datadict(self, data, files, name):
1478 value = super().value_from_datadict(data, files, name)
1479 if isinstance(value, str):
1480 return json.loads(value)
1481 return value
1483 def format_value(self, value):
1484 value = super().format_value(value)
1485 if isinstance(value, str):
1486 return value
1487 return json.dumps(value)
1490class SelectChecksField(forms.JSONField):
1491 def to_python(self, value):
1492 if value in self.empty_values:
1493 return []
1494 return super().to_python(value)
1496 def bound_data(self, data, initial):
1497 if data is None:
1498 return []
1499 if isinstance(data, list):
1500 data = json.dumps(data)
1501 return super().bound_data(data, initial)
1504class FormParamsWidget(forms.MultiWidget):
1505 template_name = "bootstrap3/labelled_multiwidget.html"
1506 subwidget_class = "file-format-param"
1508 def __init__(
1509 self,
1510 widgets: dict[str, forms.Widget],
1511 fields_order: list[tuple[str, str]],
1512 attrs=None,
1513 ) -> None:
1514 self.fields_order = fields_order
1515 super().__init__(widgets, attrs)
1517 def decompress(self, value: dict) -> list[Any]:
1518 initial_params: dict[str, Any] = {}
1519 for param_class in FILE_FORMATS_PARAMS:
1520 param = param_class()
1521 initial_params[param.get_identifier()] = param.get_field_kwargs().get(
1522 "initial"
1523 )
1524 value = {**initial_params, **(value or {})}
1525 return [value.get(param_name) for param_name in self.fields_order]
1527 def get_context(self, *args, **kwargs) -> dict[str, Any]:
1528 context = super().get_context(*args, **kwargs)
1529 context["subwidget_class"] = self.subwidget_class
1530 return context
1533class FormParamsField(forms.MultiValueField):
1534 def __init__(self, encoder=None, decoder=None, **kwargs) -> None:
1535 fields = []
1536 subwidgets = {}
1538 self.fields_order: list[tuple] = []
1539 for file_param in FILE_FORMATS_PARAMS:
1540 field = file_param().get_field()
1541 fields.append(field)
1542 subwidgets[file_param.get_identifier()] = field.widget
1543 self.fields_order.append(file_param.get_identifier())
1545 widget = FormParamsWidget(subwidgets, self.fields_order)
1546 super().__init__(fields, widget=widget, require_all_fields=False, **kwargs)
1548 def compress(self, data_list) -> dict:
1549 compressed_value: dict[str, Any] = {}
1550 if data_list:
1551 update_data = {
1552 param_name: value
1553 for param_name, value in zip(self.fields_order, data_list, strict=False)
1554 if value is not None
1555 }
1556 compressed_value.update(update_data)
1557 return compressed_value
1560class ComponentDocsMixin(FieldDocsMixin):
1561 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
1562 return ("admin/projects", f"component-{field.name.replace('_', '-')}")
1565class ProjectDocsMixin(FieldDocsMixin):
1566 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
1567 return ("admin/projects", f"project-{field.name.replace('_', '-')}")
1570class SpamCheckMixin(forms.Form):
1571 spam_fields: ClassVar[tuple[str, ...]]
1573 def clean(self) -> None:
1574 data = self.cleaned_data
1575 check_values: list[str] = [
1576 data[field] for field in self.spam_fields if field in data
1577 ]
1578 if is_spam(self.request, check_values):
1579 raise ValidationError(
1580 gettext("This submission has been identified as spam!")
1581 )
1584class ComponentAntispamMixin(SpamCheckMixin):
1585 spam_fields = ("agreement",)
1588class ProjectAntispamMixin(SpamCheckMixin):
1589 spam_fields = ("web", "instructions")
1592class ComponentSettingsForm(
1593 SettingsBaseForm, ComponentDocsMixin, ComponentAntispamMixin
1594):
1595 """Component settings form."""
1597 class Meta:
1598 model = Component
1599 fields = (
1600 "name",
1601 "report_source_bugs",
1602 "license",
1603 "agreement",
1604 "hide_glossary_matches",
1605 "allow_translation_propagation",
1606 "contribute_project_tm",
1607 "enable_suggestions",
1608 "suggestion_voting",
1609 "suggestion_autoaccept",
1610 "priority",
1611 "check_flags",
1612 "enforced_checks",
1613 "commit_message",
1614 "add_message",
1615 "delete_message",
1616 "merge_message",
1617 "addon_message",
1618 "pull_message",
1619 "vcs",
1620 "repo",
1621 "branch",
1622 "push",
1623 "push_branch",
1624 "repoweb",
1625 "push_on_commit",
1626 "commit_pending_age",
1627 "merge_style",
1628 "file_format",
1629 "file_format_params",
1630 "edit_template",
1631 "new_lang",
1632 "language_code_style",
1633 "source_language",
1634 "new_base",
1635 "filemask",
1636 "screenshot_filemask",
1637 "template",
1638 "intermediate",
1639 "language_regex",
1640 "key_filter",
1641 "secondary_language",
1642 "variant_regex",
1643 "restricted",
1644 "auto_lock_error",
1645 "links",
1646 "manage_units",
1647 "is_glossary",
1648 "glossary_color",
1649 )
1650 widgets = { # noqa: RUF012
1651 "enforced_checks": SelectChecksWidget,
1652 "source_language": SortedSelect,
1653 "secondary_language": SortedSelect,
1654 "language_code_style": SortedSelect,
1655 }
1656 field_classes = { # noqa: RUF012
1657 "enforced_checks": SelectChecksField,
1658 "file_format_params": FormParamsField,
1659 }
1661 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1662 super().__init__(request, *args, **kwargs)
1663 if self.hide_restricted:
1664 self.fields["restricted"].widget = forms.HiddenInput()
1665 self.fields["links"].queryset = request.user.managed_projects.exclude(
1666 pk=self.instance.project.pk
1667 )
1669 self.helper.layout = Layout(
1670 TabHolder(
1671 Tab(
1672 gettext("Basic"),
1673 Fieldset(
1674 gettext("Name"),
1675 "name",
1676 ContextDiv(
1677 template="snippets/settings-organize.html",
1678 context={
1679 "object": self.instance,
1680 "type": "component",
1681 },
1682 ),
1683 ),
1684 Fieldset(gettext("License"), "license", "agreement"),
1685 Fieldset(gettext("Upstream links"), "report_source_bugs"),
1686 Fieldset(
1687 gettext("Listing and access"),
1688 "priority",
1689 "restricted",
1690 "links",
1691 ),
1692 Fieldset(
1693 gettext("Glossary"),
1694 "is_glossary",
1695 "glossary_color",
1696 ),
1697 css_id="basic",
1698 ),
1699 Tab(
1700 gettext("Translation"),
1701 Fieldset(
1702 gettext("Suggestions"),
1703 "enable_suggestions",
1704 "suggestion_voting",
1705 "suggestion_autoaccept",
1706 ),
1707 Fieldset(
1708 gettext("Translation settings"),
1709 "hide_glossary_matches",
1710 "allow_translation_propagation",
1711 "contribute_project_tm",
1712 "manage_units",
1713 "check_flags",
1714 "variant_regex",
1715 "enforced_checks",
1716 "secondary_language",
1717 ),
1718 css_id="translation",
1719 ),
1720 Tab(
1721 gettext("Version control"),
1722 Fieldset(
1723 gettext("Locations"),
1724 Div(template="trans/repo_help.html"),
1725 "vcs",
1726 "repo",
1727 "branch",
1728 "push",
1729 "push_branch",
1730 "repoweb",
1731 ),
1732 Fieldset(
1733 gettext("Version control settings"),
1734 "push_on_commit",
1735 "commit_pending_age",
1736 "merge_style",
1737 "auto_lock_error",
1738 ),
1739 css_id="vcs",
1740 ),
1741 Tab(
1742 gettext("Commit messages"),
1743 ContextDiv(
1744 template="trans/messages_help.html",
1745 context={"user": request.user},
1746 ),
1747 "commit_message",
1748 "add_message",
1749 "delete_message",
1750 "merge_message",
1751 "addon_message",
1752 "pull_message",
1753 css_id="messages",
1754 ),
1755 Tab(
1756 gettext("Files"),
1757 Fieldset(
1758 gettext("Translation files"),
1759 "file_format",
1760 "filemask",
1761 "language_regex",
1762 "key_filter",
1763 "source_language",
1764 "file_format_params",
1765 ),
1766 Fieldset(
1767 gettext("Monolingual translations"),
1768 "template",
1769 "edit_template",
1770 "intermediate",
1771 ),
1772 Fieldset(
1773 gettext("Adding new languages"),
1774 "new_base",
1775 "new_lang",
1776 "language_code_style",
1777 ),
1778 Fieldset(
1779 gettext("Screenshots"),
1780 "screenshot_filemask",
1781 ),
1782 css_id="files",
1783 ),
1784 template="layout/pills.html",
1785 )
1786 )
1787 vcses: set[str] = VCS_REGISTRY.git_based
1788 if self.instance.vcs not in vcses:
1789 vcses = (self.instance.vcs,)
1790 self.fields["vcs"].choices = [
1791 c for c in self.fields["vcs"].choices if c[0] in vcses
1792 ]
1794 @property
1795 def hide_restricted(self) -> bool:
1796 user = self.request.user
1797 if user.is_superuser:
1798 return False
1799 if settings.OFFER_HOSTING:
1800 return True
1801 return not any(
1802 "component.edit" in permissions
1803 for permissions, _langs in user.component_permissions[self.instance.pk]
1804 )
1806 def clean(self) -> None:
1807 data = self.cleaned_data
1808 if self.hide_restricted:
1809 data["restricted"] = self.instance.restricted
1811 if "file_format_params" in data:
1812 data["file_format_params"] = strip_unused_file_format_params(
1813 data["file_format"], data["file_format_params"]
1814 )
1817class ComponentCreateForm(SettingsBaseForm, ComponentDocsMixin, ComponentAntispamMixin):
1818 """Component creation form."""
1820 source_component = forms.ModelChoiceField(
1821 queryset=Component.objects.none(),
1822 required=False,
1823 widget=forms.HiddenInput(),
1824 )
1826 class Meta:
1827 model = Component
1828 fields = [ # noqa: RUF012
1829 "project",
1830 "category",
1831 "name",
1832 "slug",
1833 "vcs",
1834 "repo",
1835 "branch",
1836 "push",
1837 "push_branch",
1838 "repoweb",
1839 "file_format",
1840 "file_format_params",
1841 "filemask",
1842 "template",
1843 "edit_template",
1844 "intermediate",
1845 "new_lang",
1846 "new_base",
1847 "license",
1848 "language_code_style",
1849 "language_regex",
1850 "key_filter",
1851 "source_language",
1852 "is_glossary",
1853 ]
1854 widgets = { # noqa: RUF012
1855 "source_language": SortedSelect,
1856 "language_code_style": SortedSelect,
1857 }
1858 field_classes = { # noqa: RUF012
1859 "file_format_params": FormParamsField,
1860 }
1862 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1863 if (
1864 source_component_text := request.GET.get("source_component")
1865 ) and "file_format" in kwargs["initial"]:
1866 source_component = Component.objects.get(pk=int(source_component_text))
1867 if source_component.file_format_params:
1868 kwargs["initial"]["file_format_params"] = {
1869 k: v
1870 for k, v in source_component.file_format_params.items()
1871 if k
1872 in [
1873 param.get_identifier()
1874 for param in get_params_for_file_format(
1875 kwargs["initial"]["file_format"]
1876 )
1877 ]
1878 }
1879 super().__init__(request, *args, **kwargs)
1881 def clean(self) -> None:
1882 super().clean()
1883 data = self.cleaned_data
1885 if "file_format_params" in data:
1886 data["file_format_params"] = strip_unused_file_format_params(
1887 data["file_format"], data["file_format_params"]
1888 )
1891class ComponentNameForm(ComponentDocsMixin, ComponentAntispamMixin):
1892 name = forms.CharField(
1893 label=Component.name.field.verbose_name,
1894 max_length=COMPONENT_NAME_LENGTH,
1895 help_text=Component.name.field.help_text,
1896 )
1897 slug = forms.SlugField(
1898 label=Component.slug.field.verbose_name,
1899 max_length=COMPONENT_NAME_LENGTH,
1900 help_text=Component.slug.field.help_text,
1901 )
1902 is_glossary = forms.BooleanField(
1903 label=Component.is_glossary.field.verbose_name,
1904 help_text=Component.is_glossary.field.help_text,
1905 required=False,
1906 )
1908 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1909 super().__init__(*args, **kwargs)
1910 self.helper = FormHelper()
1911 self.helper.form_tag = False
1912 self.request = request
1915class ComponentSelectForm(ComponentNameForm):
1916 component = forms.ModelChoiceField(
1917 queryset=Component.objects.none(),
1918 label=gettext_lazy("Component"),
1919 help_text=gettext_lazy("Select an existing component configuration to copy."),
1920 )
1922 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1923 if "instance" in kwargs:
1924 kwargs.pop("instance")
1925 if "auto_id" not in kwargs:
1926 kwargs["auto_id"] = "id_existing_%s"
1927 super().__init__(request, *args, **kwargs)
1930class ComponentBranchForm(ComponentSelectForm):
1931 branch = forms.ChoiceField(label=gettext_lazy("Repository branch"))
1933 instance = None
1935 def __init__(self, *args, **kwargs) -> None:
1936 kwargs["auto_id"] = "id_branch_%s"
1937 super().__init__(*args, **kwargs)
1938 self.branch_data: dict[int, list[str]] = {}
1940 def clean_component(self):
1941 component = self.cleaned_data["component"]
1942 self.fields["branch"].choices = [(x, x) for x in self.branch_data[component.pk]]
1943 return component
1945 def clean(self) -> None:
1946 form_fields = ("branch", "slug", "name")
1947 data = self.cleaned_data
1948 component = data.get("component")
1949 if not component or any(field not in data for field in form_fields):
1950 return
1951 kwargs = model_to_dict(component, exclude=["id", "links"])
1952 # We need a object, not integer here
1953 kwargs["source_language"] = component.source_language
1954 kwargs["project"] = component.project
1955 kwargs["category"] = component.category
1956 for field in form_fields:
1957 kwargs[field] = data[field]
1958 self.instance = Component(**kwargs)
1959 try:
1960 self.instance.full_clean()
1961 except ValidationError as error:
1962 # Can not raise directly, as this will contain errors
1963 # from fields not present here
1964 result: dict[str, list[str]] = {NON_FIELD_ERRORS: []}
1965 for key, value in error.message_dict.items():
1966 if key in self.fields:
1967 result[key] = value
1968 else:
1969 result[NON_FIELD_ERRORS].extend(value)
1970 raise ValidationError(error.messages) from error
1973class ComponentProjectForm(ComponentNameForm):
1974 project = forms.ModelChoiceField(
1975 queryset=Project.objects.none(), label=gettext_lazy("Project")
1976 )
1977 category = forms.ModelChoiceField(
1978 queryset=Category.objects.all(),
1979 label=gettext_lazy("Category"),
1980 widget=forms.HiddenInput,
1981 blank=True,
1982 required=False,
1983 )
1984 source_language = forms.ModelChoiceField(
1985 widget=SortedSelect,
1986 label=Component.source_language.field.verbose_name,
1987 help_text=Component.source_language.field.help_text,
1988 queryset=Language.objects.all(),
1989 )
1990 instance: Project
1992 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
1993 if "instance" in kwargs:
1994 kwargs.pop("instance")
1995 super().__init__(request, *args, **kwargs)
1996 # It might be overridden based on preset project
1997 self.fields["source_language"].initial = Language.objects.default_language
1998 self.request = request
1999 self.helper = FormHelper()
2000 self.helper.form_tag = False
2002 def clean(self) -> None:
2003 if "project" not in self.cleaned_data:
2004 return
2006 project = self.cleaned_data["project"]
2007 name = self.cleaned_data.get("name", "")
2008 slug = self.cleaned_data.get("slug", "")
2009 category = self.cleaned_data.get("category")
2011 fake = Component(project=project, category=category, name=name, slug=slug)
2012 fake.clean_unique_together()
2013 # Check if category is from this project
2014 fake.clean_category()
2017class ComponentScratchCreateForm(ComponentProjectForm):
2018 file_format = forms.ChoiceField(
2019 label=gettext_lazy("File format"),
2020 initial="po-mono",
2021 choices=FILE_FORMATS.get_choices(
2022 cond=lambda x: bool(x.new_translation) or hasattr(x, "update_bilingual")
2023 ),
2024 )
2025 file_format_params = FormParamsField()
2027 def __init__(self, *args, **kwargs) -> None:
2028 kwargs["auto_id"] = "id_scratchcreate_%s"
2029 super().__init__(*args, **kwargs)
2032class ComponentZipCreateForm(ComponentProjectForm):
2033 zipfile = forms.FileField(
2034 label=gettext_lazy("ZIP file containing translations"),
2035 validators=[FileExtensionValidator(allowed_extensions=["zip"])],
2036 widget=forms.FileInput(attrs={"accept": ".zip,application/zip"}),
2037 )
2039 field_order = [ # noqa: RUF012
2040 "zipfile",
2041 "project",
2042 "name",
2043 "slug",
2044 ]
2046 def __init__(self, *args, **kwargs) -> None:
2047 kwargs["auto_id"] = "id_zipcreate_%s"
2048 super().__init__(*args, **kwargs)
2051class ComponentDocCreateForm(ComponentProjectForm):
2052 docfile = forms.FileField(
2053 label=gettext_lazy("Document to translate"),
2054 validators=[validate_file_extension],
2055 )
2057 target_language = forms.ModelChoiceField(
2058 widget=SortedSelect,
2059 label=gettext_lazy("Target language"),
2060 help_text=gettext_lazy("Target language of the document for bilingual files"),
2061 queryset=Language.objects.all(),
2062 required=False,
2063 )
2064 field_order = [ # noqa: RUF012
2065 "docfile",
2066 "project",
2067 "name",
2068 "slug",
2069 ]
2071 def __init__(self, *args, **kwargs) -> None:
2072 kwargs["auto_id"] = "id_doccreate_%s"
2073 super().__init__(*args, **kwargs)
2076class ComponentInitCreateForm(CleanRepoMixin, ComponentProjectForm):
2077 """Component creation form."""
2079 vcs = forms.ChoiceField(
2080 label=Component.vcs.field.verbose_name,
2081 help_text=Component.vcs.field.help_text,
2082 choices=VCS_REGISTRY.get_choices(exclude={"local"}),
2083 initial=settings.DEFAULT_VCS,
2084 )
2085 repo = forms.CharField(
2086 label=Component.repo.field.verbose_name,
2087 max_length=REPO_LENGTH,
2088 help_text=Component.repo.field.help_text,
2089 )
2090 branch = forms.CharField(
2091 label=Component.branch.field.verbose_name,
2092 max_length=BRANCH_LENGTH,
2093 help_text=Component.branch.field.help_text,
2094 required=False,
2095 )
2096 instance: Component # type: ignore[assignment]
2098 def clean_instance(self, data) -> None:
2099 params = copy.copy(data)
2100 if "discovery" in params:
2101 params.pop("discovery")
2103 instance = Component(**params)
2104 instance.clean_fields(
2105 exclude=(
2106 "filemask",
2107 "screenshot_filemask",
2108 "template",
2109 "file_format",
2110 "license",
2111 )
2112 )
2113 instance.validate_unique()
2114 instance.clean_unique_together()
2115 instance.clean_repo()
2116 instance.clean_category()
2117 self.instance = instance
2119 # Create linked repos automatically
2120 repo = instance.suggest_repo_link()
2121 if repo:
2122 data["repo"] = repo
2123 data["branch"] = ""
2124 self.clean_instance(data)
2126 def clean(self) -> None:
2127 self.clean_instance(self.cleaned_data)
2130class ComponentDiscoverForm(ComponentInitCreateForm):
2131 discovery = forms.ChoiceField(
2132 label=gettext_lazy("Choose translation files to import"),
2133 choices=[("manual", gettext_lazy("Specify configuration manually"))],
2134 required=True,
2135 widget=forms.RadioSelect,
2136 )
2137 filemask = forms.CharField(
2138 label=Component.filemask.field.verbose_name,
2139 max_length=FILENAME_LENGTH,
2140 required=False,
2141 widget=forms.HiddenInput,
2142 )
2143 template = forms.CharField(
2144 label=Component.template.field.verbose_name,
2145 max_length=FILENAME_LENGTH,
2146 required=False,
2147 widget=forms.HiddenInput,
2148 )
2150 def render_choice(self, value: DiscoveryResult) -> str:
2151 context = cast("dict[str, str]", value.data.copy())
2152 try:
2153 format_cls = FILE_FORMATS[value["file_format"]]
2154 context["file_format_name"] = format_cls.name
2155 context["valid"] = True
2156 except KeyError:
2157 context["file_format_name"] = value["file_format"]
2158 context["valid"] = False
2159 context["origin"] = value.meta["origin"]
2160 return render_to_string("trans/discover-choice.html", context)
2162 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
2163 super().__init__(request, *args, **kwargs)
2164 # Hide all fields with exception of discovery
2165 for field, value in self.fields.items():
2166 if field == "discovery":
2167 continue
2168 value.widget = forms.HiddenInput()
2169 # Allow all VCS now (to handle zip file upload case)
2170 self.fields["vcs"].choices = VCS_REGISTRY.get_choices()
2171 self.discovered = self.perform_discovery(request, kwargs)
2172 # Can not use .extend here as it does not update widget
2173 self.fields["discovery"].choices += [
2174 (i, self.render_choice(value)) for i, value in enumerate(self.discovered)
2175 ]
2177 def perform_discovery(self, request: AuthenticatedHttpRequest, kwargs):
2178 if "data" in kwargs and "create_discovery" in request.session:
2179 discovered = []
2180 for i, data in enumerate(request.session["create_discovery"]):
2181 item = DiscoveryResult(data)
2182 item.meta = request.session["create_discovery_meta"][i]
2183 discovered.append(item)
2184 return discovered
2185 try:
2186 self.clean_instance(kwargs["initial"])
2187 discovered = self.discover()
2188 if not discovered:
2189 discovered = self.discover(eager=True)
2190 except ValidationError:
2191 discovered = []
2192 request.session["create_discovery"] = [x.data for x in discovered]
2193 request.session["create_discovery_meta"] = [x.meta for x in discovered]
2194 return discovered
2196 def discover(self, eager: bool = False):
2197 return discover(
2198 self.instance.full_path,
2199 source_language=self.instance.source_language.code,
2200 eager=eager,
2201 hint=self.instance.filemask,
2202 )
2204 def clean(self) -> None:
2205 super().clean()
2206 discovery = self.cleaned_data.get("discovery")
2207 if discovery and discovery != "manual":
2208 self.cleaned_data.update(self.discovered[int(discovery)])
2211class ComponentRenameForm(SettingsBaseForm, ComponentDocsMixin):
2212 """Component rename form."""
2214 class Meta:
2215 model = Component
2216 fields = ["name", "slug", "project", "category"] # noqa: RUF012
2218 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
2219 super().__init__(request, *args, **kwargs)
2220 self.fields["project"].queryset = request.user.managed_projects
2221 self.fields["category"].queryset = self.instance.project.category_set.all()
2224class CategoryRenameForm(SettingsBaseForm):
2225 """Category rename form."""
2227 class Meta:
2228 model = Category
2229 fields = ["name", "slug", "project", "category"] # noqa: RUF012
2231 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
2232 super().__init__(request, *args, **kwargs)
2233 self.fields["project"].queryset = request.user.managed_projects
2234 self.fields["category"].queryset = self.instance.project.category_set.exclude(
2235 pk=self.instance.pk
2236 )
2239class AddCategoryForm(SettingsBaseForm):
2240 class Meta:
2241 model = Category
2242 fields = ["name", "slug"] # noqa: RUF012
2244 def __init__(
2245 self, request: AuthenticatedHttpRequest, parent, *args, **kwargs
2246 ) -> None:
2247 self.parent = parent
2248 super().__init__(request, *args, **kwargs)
2250 def clean(self) -> None:
2251 if isinstance(self.parent, Category):
2252 self.instance.category = self.parent
2253 self.instance.project = self.parent.project
2254 else:
2255 self.instance.project = self.parent
2256 super().clean()
2259class ProjectSettingsForm(SettingsBaseForm, ProjectDocsMixin, ProjectAntispamMixin):
2260 """Project settings form."""
2262 class Meta:
2263 model = Project
2264 fields = (
2265 "name",
2266 "web",
2267 "instructions",
2268 "set_language_team",
2269 "use_shared_tm",
2270 "contribute_shared_tm",
2271 "autoclean_tm",
2272 "enable_hooks",
2273 "language_aliases",
2274 "secondary_language",
2275 "access_control",
2276 "enforced_2fa",
2277 "translation_review",
2278 "source_review",
2279 "commit_policy",
2280 "check_flags",
2281 )
2282 widgets = { # noqa: RUF012
2283 "access_control": forms.RadioSelect,
2284 "instructions": MarkdownTextarea,
2285 "language_aliases": forms.TextInput,
2286 "secondary_language": SortedSelect,
2287 }
2289 def clean(self) -> None:
2290 data = self.cleaned_data
2291 if settings.OFFER_HOSTING:
2292 data["contribute_shared_tm"] = data["use_shared_tm"]
2294 # ACCESS_PUBLIC = 0, so the condition can not be simplified to not data["access_control"]
2295 if (
2296 "access_control" not in data
2297 or data["access_control"] is None
2298 or data["access_control"] == "" # noqa: PLC1901
2299 ):
2300 data["access_control"] = self.instance.access_control
2301 access = data["access_control"]
2303 self.changed_access = access != self.instance.access_control
2305 if self.changed_access and not self.user_can_change_access:
2306 raise ValidationError(
2307 {
2308 "access_control": gettext(
2309 "You do not have permission to change project access control."
2310 )
2311 }
2312 )
2313 if self.changed_access and self.instance.needs_license(access):
2314 unlicensed = self.instance.component_set.filter(license="")
2315 if unlicensed:
2316 raise ValidationError(
2317 {
2318 "access_control": gettext(
2319 "You must specify a license for these components "
2320 "to make them publicly accessible: %s"
2321 )
2322 % format_html_join_comma(
2323 '<a href="{}">{}</a>',
2324 (
2325 (component.get_absolute_url(), component.name)
2326 for component in unlicensed
2327 ),
2328 )
2329 }
2330 )
2332 if (
2333 data.get("commit_policy") != self.instance.commit_policy
2334 and data.get("commit_policy") == CommitPolicyChoices.APPROVED_ONLY
2335 and not data.get("translation_review", self.instance.translation_review)
2336 ):
2337 raise ValidationError(
2338 {
2339 "commit_policy": gettext(
2340 "Approved-only commit policy requires translation reviews to be enabled. "
2341 "Please enable translation reviews first or choose a different commit policy."
2342 )
2343 }
2344 )
2346 if (
2347 data.get("translation_review") != self.instance.translation_review
2348 and not data.get("translation_review")
2349 and data.get("commit_policy", self.instance.commit_policy)
2350 == CommitPolicyChoices.APPROVED_ONLY
2351 ):
2352 raise ValidationError(
2353 {
2354 "translation_review": gettext(
2355 "Translation reviews are required for approved-only commit policy. "
2356 "Please choose a different commit policy before disabling translation reviews."
2357 )
2358 }
2359 )
2361 def save(self, commit: bool = True) -> None:
2362 super().save(commit=commit)
2363 if self.changed_access:
2364 self.instance.change_set.create(
2365 action=ActionEvents.ACCESS_EDIT,
2366 user=self.user,
2367 details={"access_control": self.instance.access_control},
2368 )
2370 def __init__(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None:
2371 super().__init__(request, *args, **kwargs)
2372 self.user = request.user
2373 self.user_can_change_access = request.user.has_perm(
2374 "billing:project.permissions", self.instance
2375 )
2376 self.changed_access = False
2377 self.helper.form_tag = False
2378 if not self.user_can_change_access:
2379 disabled = {"disabled": True}
2380 self.fields["access_control"].required = False
2381 self.fields["access_control"].help_text = gettext(
2382 "You do not have permission to change project access control."
2383 )
2384 else:
2385 disabled = {}
2386 self.helper.layout = Layout(
2387 TabHolder(
2388 Tab(
2389 gettext("Basic"),
2390 "name",
2391 ContextDiv(
2392 template="snippets/settings-organize.html",
2393 context={
2394 "object": self.instance,
2395 "type": "project",
2396 },
2397 ),
2398 "web",
2399 "instructions",
2400 css_id="basic",
2401 ),
2402 Tab(
2403 gettext("Access"),
2404 InlineRadios(
2405 "access_control",
2406 template="%s/layout/radioselect_access.html",
2407 **disabled,
2408 ),
2409 "enforced_2fa",
2410 css_id="access",
2411 ),
2412 Tab(
2413 gettext("Workflow"),
2414 "set_language_team",
2415 "use_shared_tm",
2416 "contribute_shared_tm",
2417 "autoclean_tm",
2418 "check_flags",
2419 "enable_hooks",
2420 "language_aliases",
2421 "secondary_language",
2422 "translation_review",
2423 "source_review",
2424 "commit_policy",
2425 ContextDiv(
2426 template="snippets/project-workflow-settings.html",
2427 context={
2428 "object": self.instance,
2429 "project_languages": self.instance.project_languages.preload(),
2430 "custom_workflows": set(
2431 self.instance.workflowsetting_set.values_list(
2432 "language_id", flat=True
2433 )
2434 ),
2435 },
2436 ),
2437 css_id="workflow",
2438 ),
2439 Tab(
2440 gettext("Components"),
2441 ContextDiv(
2442 template="snippets/project-component-settings.html",
2443 context={"object": self.instance, "user": request.user},
2444 ),
2445 css_id="components",
2446 ),
2447 template="layout/pills.html",
2448 )
2449 )
2451 if settings.OFFER_HOSTING:
2452 self.fields["contribute_shared_tm"].widget = forms.HiddenInput()
2453 self.fields["use_shared_tm"].help_text = gettext(
2454 "Uses and contributes to the pool of shared translations "
2455 "between projects."
2456 )
2457 self.fields["access_control"].choices = [
2458 choice
2459 for choice in self.fields["access_control"].choices
2460 if choice[0] != Project.ACCESS_CUSTOM
2461 ]
2464class ProjectRenameForm(SettingsBaseForm, ProjectDocsMixin):
2465 """Project renaming form."""
2467 class Meta:
2468 model = Project
2469 fields = ["name", "slug"] # noqa: RUF012
2472class BillingMixin(forms.Form):
2473 # This is fake field with is either hidden or configured
2474 # in the view
2475 billing = forms.ModelChoiceField(
2476 label=gettext_lazy("Billing"),
2477 queryset=User.objects.none(),
2478 required=True,
2479 empty_label=None,
2480 )
2483class ProjectCreateForm(
2484 BillingMixin, SettingsBaseForm, ProjectDocsMixin, ProjectAntispamMixin
2485):
2486 """Project creation form."""
2488 class Meta:
2489 model = Project
2490 fields = ("name", "slug", "web", "instructions")
2493class ProjectImportCreateForm(ProjectCreateForm):
2494 class Meta:
2495 model = Project
2496 fields = ("name", "slug")
2498 def __init__(
2499 self, request: AuthenticatedHttpRequest, projectbackup, *args, **kwargs
2500 ) -> None:
2501 kwargs["initial"] = {
2502 "name": projectbackup.data["project"]["name"],
2503 "slug": projectbackup.data["project"]["slug"],
2504 }
2505 super().__init__(request, *args, **kwargs)
2506 self.projectbackup = projectbackup
2507 self.helper.layout = Layout(
2508 ContextDiv(
2509 template="trans/project_import_info.html",
2510 context={"projectbackup": projectbackup},
2511 ),
2512 Field("name"),
2513 Field("slug"),
2514 Field("billing"),
2515 )
2518class ProjectImportForm(BillingMixin, forms.Form):
2519 """Component base form."""
2521 zipfile = forms.FileField(
2522 label=gettext_lazy("ZIP file containing project backup"),
2523 validators=[FileExtensionValidator(allowed_extensions=["zip"])],
2524 widget=forms.FileInput(attrs={"accept": ".zip,application/zip"}),
2525 )
2527 def __init__(
2528 self, request: AuthenticatedHttpRequest, projectbackup=None, *args, **kwargs
2529 ) -> None:
2530 kwargs.pop("instance", None)
2531 super().__init__(*args, **kwargs)
2532 self.request = request
2533 self.helper = FormHelper()
2534 self.helper.form_tag = False
2535 self.helper.layout = Layout(
2536 Field("zipfile"),
2537 Field("billing"),
2538 )
2540 def clean_zipfile(self):
2541 zipfile = self.cleaned_data["zipfile"]
2542 backup = ProjectBackup(fileio=zipfile)
2543 try:
2544 backup.validate()
2545 except jsonschema.exceptions.ValidationError as error:
2546 version = backup.data.get("metadata", {}).get("version", "unknown")
2547 raise ValidationError(
2548 gettext(
2549 "Could not load project backup: The backup is from an incompatible version (%(version)s). Please upgrade your Weblate instance."
2550 )
2551 % {"version": version}
2552 ) from error
2553 except Exception as error:
2554 raise ValidationError(
2555 gettext("Could not load project backup: %s") % error
2556 ) from error
2557 self.cleaned_data["projectbackup"] = backup
2558 return zipfile
2561class ReplaceForm(forms.Form):
2562 q = QueryField(
2563 required=False,
2564 help_text=gettext_lazy("Optional additional filter applied to the strings"),
2565 )
2566 path = forms.CharField(widget=forms.HiddenInput, required=False)
2567 search = forms.CharField(
2568 label=gettext_lazy("Search string"),
2569 min_length=1,
2570 required=True,
2571 strip=False,
2572 help_text=gettext_lazy("Case-sensitive string to search for and replace."),
2573 )
2574 replacement = forms.CharField(
2575 label=gettext_lazy("Replacement string"),
2576 min_length=1,
2577 required=True,
2578 strip=False,
2579 )
2581 def __init__(self, obj: URLMixin, data: dict | None = None) -> None:
2582 super().__init__(
2583 data=data, auto_id="id_replace_%s", initial={"path": obj.full_slug}
2584 )
2585 self.helper = FormHelper(self)
2586 self.helper.form_tag = False
2587 self.helper.layout = Layout(
2588 SearchField("q"),
2589 Field("path"),
2590 Field("search"),
2591 Field("replacement"),
2592 Div(template="snippets/replace-help.html"),
2593 )
2596class ReplaceConfirmForm(forms.Form):
2597 units = forms.ModelMultipleChoiceField(queryset=Unit.objects.none(), required=False)
2598 confirm = forms.BooleanField(required=True, initial=True, widget=forms.HiddenInput)
2600 def __init__(self, units, *args, **kwargs) -> None:
2601 super().__init__(*args, **kwargs)
2602 self.fields["units"].queryset = units
2605class MatrixLanguageForm(forms.Form):
2606 """Form for requesting a new language."""
2608 lang = forms.MultipleChoiceField(
2609 label=gettext_lazy("Languages"), choices=[], widget=forms.SelectMultiple
2610 )
2612 def __init__(self, component, *args, **kwargs) -> None:
2613 super().__init__(*args, **kwargs)
2614 languages = Language.objects.filter(translation__component=component).exclude(
2615 pk=component.source_language_id
2616 )
2617 self.fields["lang"].choices = languages.as_choices()
2620class NewUnitBaseForm(forms.Form):
2621 variant = forms.ModelChoiceField(
2622 Unit.objects.none(),
2623 widget=forms.HiddenInput,
2624 required=False,
2625 )
2627 def __init__(
2628 self,
2629 translation: Translation,
2630 user: User,
2631 data: dict | None = None,
2632 initial: dict | None = None,
2633 is_source_plural: Literal[True] | None = None,
2634 auto_id: str = "id_%s",
2635 ) -> None:
2636 super().__init__(data=data, initial=initial, auto_id=auto_id)
2637 self.translation = translation
2638 self.user = user
2639 self.is_source_plural = is_source_plural
2640 self.patch_fields()
2642 def patch_fields(self) -> None:
2643 self.fields["variant"].queryset = self.translation.unit_set.all()
2645 def clean(self) -> None:
2646 try:
2647 data = self.as_kwargs()
2648 except KeyError:
2649 # Probably the validation of some fields has failed
2650 return
2651 self.translation.validate_new_unit_data(**data)
2653 def get_glossary_flags(self) -> str:
2654 return ""
2656 def as_kwargs(self) -> NewUnitParams:
2657 flags = Flags()
2658 flags.merge(self.get_glossary_flags())
2659 variant = self.cleaned_data.get("variant")
2660 if variant:
2661 flags.set_value("variant", variant.source)
2662 return {
2663 "context": self.cleaned_data.get("context", ""),
2664 "source": self.cleaned_data["source"],
2665 "target": self.cleaned_data.get("target"),
2666 "extra_flags": flags.format(),
2667 "explanation": self.cleaned_data.get("explanation", ""),
2668 "auto_context": self.cleaned_data.get("auto_context", False),
2669 }
2672class NewMonolingualUnitForm(NewUnitBaseForm):
2673 context = forms.CharField(
2674 label=gettext_lazy("Translation key"),
2675 help_text=gettext_lazy(
2676 "Key used to identify the string in the translation file. "
2677 "File-format specific rules might apply."
2678 ),
2679 required=True,
2680 )
2681 source = PluralField(
2682 label=gettext_lazy("Source language text"),
2683 help_text=gettext_lazy(
2684 "You can edit this later, as with any other string in the source language."
2685 ),
2686 required=True,
2687 )
2689 def patch_fields(self) -> None:
2690 super().patch_fields()
2691 self.fields["source"].widget.profile = self.user.profile
2692 self.fields["source"].widget.is_source_plural = self.is_source_plural
2693 self.fields["source"].initial = Unit(translation=self.translation, id_hash=0)
2696class NewBilingualSourceUnitForm(NewUnitBaseForm):
2697 context = forms.CharField(
2698 label=gettext_lazy("Context"),
2699 help_text=gettext_lazy("Optional context to clarify the source strings."),
2700 required=False,
2701 )
2702 auto_context = forms.BooleanField(
2703 required=False,
2704 initial=True,
2705 label=gettext_lazy(
2706 "Auto-adjust context when an identical string already exists."
2707 ),
2708 )
2709 source = PluralField(
2710 label=gettext_lazy("Source string"),
2711 required=True,
2712 )
2714 def patch_fields(self) -> None:
2715 super().patch_fields()
2716 self.fields["context"].label = self.translation.component.context_label
2717 self.fields["source"].widget.profile = self.user.profile
2718 self.fields["source"].widget.is_source_plural = self.is_source_plural
2719 self.fields["source"].initial = Unit(
2720 translation=self.translation.component.source_translation, id_hash=0
2721 )
2724class NewBilingualUnitForm(NewBilingualSourceUnitForm):
2725 target = PluralField(
2726 label=gettext_lazy("Translated string"),
2727 help_text=gettext_lazy(
2728 "You can edit this later, as with any other string in the translation."
2729 ),
2730 required=True,
2731 )
2733 def patch_fields(self) -> None:
2734 super().patch_fields()
2735 self.fields["target"].widget.profile = self.user.profile
2736 self.fields["target"].widget.is_source_plural = self.is_source_plural
2737 self.fields["target"].initial = Unit(translation=self.translation, id_hash=0)
2740class GlossaryAddMixin(NewUnitBaseForm):
2741 terminology = forms.BooleanField(
2742 label=gettext_lazy("Terminology"),
2743 help_text=gettext_lazy("String will be part of the glossary in all languages"),
2744 required=False,
2745 )
2746 forbidden = forms.BooleanField(
2747 label=gettext_lazy("Forbidden translation"),
2748 help_text=gettext_lazy(
2749 "Mark this option for translations that should not be used."
2750 ),
2751 required=False,
2752 )
2753 read_only = forms.BooleanField(
2754 label=gettext_lazy("Untranslatable term"),
2755 help_text=gettext_lazy(
2756 "Mark this option if the sentence should stay as in the source language, without change."
2757 ),
2758 required=False,
2759 )
2761 def can_edit_terminology(self) -> bool:
2762 return (
2763 self.user.has_perm("glossary.terminology", self.translation)
2764 or self.translation.is_source
2765 )
2767 def patch_fields(self) -> None:
2768 super().patch_fields()
2769 if not self.can_edit_terminology():
2770 self.fields["terminology"].widget = forms.HiddenInput()
2772 def get_glossary_flags(self) -> str:
2773 result = []
2774 if self.cleaned_data.get("terminology"):
2775 result.append("terminology")
2776 if self.cleaned_data.get("forbidden"):
2777 result.append("forbidden")
2778 if self.cleaned_data.get("read_only"):
2779 result.append("read-only")
2780 return ", ".join(result)
2782 def clean(self) -> None:
2783 if not self.can_edit_terminology() and self.cleaned_data.get("terminology"):
2784 raise ValidationError(
2785 gettext("You do not have permission to create terminology.")
2786 )
2787 super().clean()
2790class NewBilingualGlossarySourceUnitForm(GlossaryAddMixin, NewBilingualSourceUnitForm):
2791 def patch_fields(self) -> None:
2792 super().patch_fields()
2793 self.fields["terminology"].initial = True
2796class NewBilingualGlossaryUnitForm(GlossaryAddMixin, NewBilingualUnitForm):
2797 pass
2800def get_new_unit_form(
2801 translation: Translation,
2802 user: User,
2803 data: dict | None = None,
2804 initial: dict | None = None,
2805 is_source_plural: Literal[True] | None = None,
2806):
2807 if translation.component.has_template():
2808 return NewMonolingualUnitForm(
2809 translation,
2810 user,
2811 data=data,
2812 initial=initial,
2813 is_source_plural=is_source_plural,
2814 )
2815 if translation.component.is_glossary:
2816 if translation.is_source:
2817 return NewBilingualGlossarySourceUnitForm(
2818 translation,
2819 user,
2820 data=data,
2821 initial=initial,
2822 is_source_plural=is_source_plural,
2823 )
2824 return NewBilingualGlossaryUnitForm(
2825 translation,
2826 user,
2827 data=data,
2828 initial=initial,
2829 is_source_plural=is_source_plural,
2830 )
2831 if translation.is_source:
2832 return NewBilingualSourceUnitForm(
2833 translation,
2834 user,
2835 data=data,
2836 initial=initial,
2837 is_source_plural=is_source_plural,
2838 )
2839 return NewBilingualUnitForm(
2840 translation,
2841 user,
2842 data=data,
2843 initial=initial,
2844 is_source_plural=is_source_plural,
2845 )
2848class BulkEditForm(forms.Form):
2849 q = QueryField(required=True)
2850 state = forms.ChoiceField(
2851 label=gettext_lazy("State to set"),
2852 choices=[(-1, gettext_lazy("Do not change"))],
2853 )
2854 path = forms.CharField(widget=forms.HiddenInput, required=False)
2855 add_flags = FlagField(
2856 label=gettext_lazy("Translation flags to add"), required=False
2857 )
2858 remove_flags = FlagField(
2859 label=gettext_lazy("Translation flags to remove"), required=False
2860 )
2861 add_labels = CachedModelMultipleChoiceField(
2862 queryset=Label.objects.none(),
2863 label=gettext_lazy("Labels to add"),
2864 widget=forms.CheckboxSelectMultiple(),
2865 required=False,
2866 )
2867 remove_labels = CachedModelMultipleChoiceField(
2868 queryset=Label.objects.none(),
2869 label=gettext_lazy("Labels to remove"),
2870 widget=forms.CheckboxSelectMultiple(),
2871 required=False,
2872 )
2874 def __init__(
2875 self, user: User | None, obj: URLMixin | None, *args, **kwargs
2876 ) -> None:
2877 project = kwargs.pop("project", None)
2878 bootstrap_5 = kwargs.pop("bootstrap_5", False)
2879 kwargs["auto_id"] = "id_bulk_%s"
2880 if obj is not None:
2881 kwargs["initial"] = {"path": obj.full_slug}
2882 super().__init__(*args, **kwargs)
2883 labels = Label.objects.all() if project is None else project.label_set.all()
2884 if labels:
2885 self.fields["remove_labels"].queryset = labels
2886 self.fields["add_labels"].queryset = labels
2888 excluded = {STATE_EMPTY, STATE_READONLY}
2889 show_review = True
2890 if user is not None and not user.has_perm("unit.review", obj):
2891 show_review = False
2892 excluded.add(STATE_APPROVED)
2894 # Filter offered states
2895 choices = self.fields["state"].choices
2897 # Special case for list_addons
2898 if isinstance(obj, Component) and obj.pk == -1:
2899 show_review = False
2900 choices.extend(
2901 (state, get_state_label(state, label, show_review))
2902 for state, label in StringState.choices
2903 if state not in excluded
2904 )
2905 self.fields["state"].choices = choices
2907 self.helper = FormHelper(self)
2908 self.helper.form_tag = False
2909 self.helper.layout = Layout(
2910 Div(template="snippets/bulk-help.html"),
2911 SearchField("q"),
2912 Field("path"),
2913 Field("state"),
2914 Field("add_flags"),
2915 Field("remove_flags"),
2916 )
2917 if labels:
2918 self.helper.layout.append(InlineCheckboxes("add_labels"))
2919 self.helper.layout.append(InlineCheckboxes("remove_labels"))
2920 if bootstrap_5:
2921 self.helper.template_pack = "bootstrap5"
2924class ContributorAgreementForm(forms.Form):
2925 confirm = forms.BooleanField(
2926 label=gettext_lazy("I accept the contributor license agreement"), required=True
2927 )
2928 next = forms.CharField(required=False, widget=forms.HiddenInput)
2931class BaseDeleteForm(forms.Form):
2932 confirm = forms.CharField(required=True)
2933 warning_template = ""
2935 def __init__(self, obj, *args, **kwargs) -> None:
2936 super().__init__(*args, **kwargs)
2937 self.obj = obj
2938 self.helper = FormHelper(self)
2939 self.helper.layout = Layout(
2940 ContextDiv(
2941 template=self.warning_template,
2942 css_class="form-group",
2943 context=self.get_template_context(obj),
2944 ),
2945 Field("confirm"),
2946 )
2947 self.helper.form_tag = False
2949 def get_template_context(self, obj):
2950 return {"object": obj}
2952 def clean(self) -> None:
2953 if self.cleaned_data.get("confirm") != self.obj.full_slug:
2954 raise ValidationError(
2955 gettext("The slug does not match the one marked for deletion!")
2956 )
2959class TranslationDeleteForm(BaseDeleteForm):
2960 confirm = forms.CharField(
2961 label=gettext_lazy("Removal confirmation"),
2962 help_text=gettext_lazy(
2963 "Please type in the full slug of the translation to confirm."
2964 ),
2965 required=True,
2966 )
2967 warning_template = "trans/delete-translation.html"
2969 def get_template_context(self, obj):
2970 context = super().get_template_context(obj)
2971 context["languages_addon"] = any(
2972 addon.name == "weblate.consistency.languages"
2973 for addon in obj.component.addons_cache["__all__"]
2974 )
2975 return context
2978class ComponentDeleteForm(BaseDeleteForm):
2979 confirm = forms.CharField(
2980 label=gettext_lazy("Removal confirmation"),
2981 help_text=gettext_lazy(
2982 "Please type in the full slug of the component to confirm."
2983 ),
2984 required=True,
2985 )
2986 warning_template = "trans/delete-component.html"
2989class ProjectDeleteForm(BaseDeleteForm):
2990 confirm = forms.CharField(
2991 label=gettext_lazy("Removal confirmation"),
2992 help_text=gettext_lazy("Please type in the slug of the project to confirm."),
2993 required=True,
2994 )
2995 warning_template = "trans/delete-project.html"
2997 def get_template_context(self, obj):
2998 context = super().get_template_context(obj)
2999 components = obj.component_set.order()
3000 context["components"] = components
3001 context["components_count"] = components.count()
3002 return context
3005class CategoryDeleteForm(BaseDeleteForm):
3006 confirm = forms.CharField(
3007 label=gettext_lazy("Removal confirmation"),
3008 help_text=gettext_lazy("Please type in the slug of the category to confirm."),
3009 required=True,
3010 )
3011 warning_template = "trans/delete-category.html"
3014class ProjectLanguageDeleteForm(BaseDeleteForm):
3015 confirm = forms.CharField(
3016 label=gettext_lazy("Removal confirmation"),
3017 help_text=gettext_lazy(
3018 "Please type in the slug of the project and language to confirm."
3019 ),
3020 required=True,
3021 )
3022 warning_template = "trans/delete-project-language.html"
3025class CategoryLanguageDeleteForm(BaseDeleteForm):
3026 confirm = forms.CharField(
3027 label=gettext_lazy("Removal confirmation"),
3028 help_text=gettext_lazy(
3029 "Please type in the slug of the category and language to confirm."
3030 ),
3031 required=True,
3032 )
3033 warning_template = "trans/delete-category-language.html"
3036class AnnouncementForm(forms.ModelForm):
3037 """Announcement posting form."""
3039 class Meta:
3040 model = Announcement
3041 fields = ["message", "severity", "expiry", "notify"] # noqa: RUF012
3042 widgets = { # noqa: RUF012
3043 "expiry": WeblateDateInput(),
3044 "message": MarkdownTextarea,
3045 }
3048class ChangesForm(forms.Form):
3049 action = forms.MultipleChoiceField(
3050 label=gettext_lazy("Action"),
3051 required=False,
3052 widget=SortedSelectMultiple,
3053 choices=ActionEvents.choices,
3054 )
3055 user = UserField(
3056 label=gettext_lazy("Author username"), required=False, help_text=None
3057 )
3058 exclude_user = UserField(
3059 label=gettext_lazy("Exclude author (username)"), required=False, help_text=None
3060 )
3061 period = DateRangeField(
3062 label=gettext_lazy("Date range"),
3063 required=False,
3064 )
3066 def items(self):
3067 items = []
3068 for param in sorted(self.cleaned_data):
3069 value = self.cleaned_data[param]
3070 # We don't care about empty values
3071 if not value:
3072 continue
3073 if (
3074 isinstance(value, dict)
3075 and "start_date" in value
3076 and "end_date" in value
3077 ):
3078 # Convert date to string
3079 start_date = value["start_date"].strftime("%m/%d/%Y")
3080 end_date = value["end_date"].strftime("%m/%d/%Y")
3081 items.append((param, f"{start_date} - {end_date}"))
3082 elif isinstance(value, User):
3083 items.append((param, value.username))
3084 elif isinstance(value, list):
3085 items.extend((param, part) for part in value)
3086 else:
3087 # It should be a string here
3088 items.append((param, value))
3089 return items
3091 def urlencode(self):
3092 return urlencode(self.items())
3095class LabelForm(forms.ModelForm):
3096 class Meta:
3097 model = Label
3098 fields = ("name", "description", "color", "project")
3099 widgets = { # noqa: RUF012
3100 "color": ColorWidget(),
3101 "project": forms.HiddenInput(),
3102 }
3104 def clean_project(self):
3105 # Ignore any passed value, override by current one
3106 return self.project
3108 def __init__(self, project: Project, *args, **kwargs) -> None:
3109 kwargs["initial"] = {"project": project}
3110 super().__init__(*args, **kwargs)
3111 self.project = project
3112 self.helper = FormHelper(self)
3113 self.helper.form_tag = False
3116class ProjectTokenCreateForm(forms.ModelForm):
3117 class Meta:
3118 model = User
3119 fields = ["full_name", "date_expires"] # noqa: RUF012
3120 widgets = { # noqa: RUF012
3121 "date_expires": WeblateDateInput(),
3122 }
3124 def __init__(self, project, *args, **kwargs) -> None:
3125 self.project = project
3126 super().__init__(*args, **kwargs)
3128 def save(self, *args, **kwargs):
3129 self.instance.is_bot = True
3130 base_name = name = f"bot-{self.project.slug}-{slugify(self.instance.full_name)}"
3131 while User.objects.filter(
3132 Q(username=name) | Q(email=f"{name}@bots.noreply.weblate.org")
3133 ).exists():
3134 name = f"{base_name}-{token_hex(2)}"
3135 self.instance.username = name
3136 self.instance.email = f"{name}@bots.noreply.weblate.org"
3137 result = super().save(*args, **kwargs)
3138 self.project.add_user(self.instance, "Administration")
3139 return result
3141 def clean_expires(self):
3142 expires = self.cleaned_data["expires"]
3143 expires = expires.replace(hour=23, minute=59, second=59, microsecond=999999)
3144 if expires < timezone.now():
3145 raise forms.ValidationError(gettext("Expiry cannot be in the past."))
3146 return expires
3149class ProjectGroupDeleteForm(forms.Form):
3150 group = forms.ModelChoiceField(
3151 Group.objects.none(),
3152 widget=forms.HiddenInput,
3153 required=True,
3154 )
3156 def __init__(self, project, *args, **kwargs) -> None:
3157 self.project = project
3158 super().__init__(*args, **kwargs)
3159 self.fields["group"].queryset = project.defined_groups.all()
3162class ProjectUserGroupForm(UserManageForm):
3163 groups = forms.ModelMultipleChoiceField(
3164 Group.objects.none(),
3165 widget=forms.CheckboxSelectMultiple,
3166 label=gettext_lazy("Teams"),
3167 required=False,
3168 )
3170 def __init__(self, project, *args, **kwargs) -> None:
3171 self.project = project
3172 super().__init__(*args, **kwargs)
3173 self.fields["user"].widget = forms.HiddenInput()
3174 self.fields["groups"].queryset = project.defined_groups.all()
3177class ProjectFilterForm(forms.Form):
3178 owned = UserField(required=False)
3179 watched = UserField(required=False)
3182class WorkflowSettingForm(FieldDocsMixin, forms.ModelForm):
3183 enable = forms.BooleanField(
3184 label=gettext_lazy("Customize translation workflow for this language"),
3185 help_text=gettext_lazy(
3186 "The translation workflow is configured at project and component. "
3187 "By enabling customization here, you override these settings for this language."
3188 ),
3189 required=False,
3190 initial=False,
3191 )
3193 class Meta:
3194 model = WorkflowSetting
3195 fields = [ # noqa: RUF012
3196 "translation_review",
3197 "enable_suggestions",
3198 "suggestion_voting",
3199 "suggestion_autoaccept",
3200 ]
3202 def __init__(
3203 self,
3204 data=None,
3205 files=None,
3206 *,
3207 instance: WorkflowSetting | None = None,
3208 prefix=None,
3209 initial=None,
3210 project: Project | None = None,
3211 **kwargs,
3212 ) -> None:
3213 if instance is not None:
3214 # The enable field indicates presence of the instance,
3215 # untoggling it removes it
3216 initial = {"enable": True}
3217 elif project is not None:
3218 # Default review setting based on the project one
3219 initial = {"translation_review": project.translation_review}
3221 self.project = project
3222 self.instance = instance
3223 super().__init__(
3224 data, files, instance=instance, initial=initial, prefix="workflow", **kwargs
3225 )
3226 if self.project:
3227 enable_field = self.fields["enable"]
3228 enable_field.label = gettext(
3229 "Customize translation workflow for this language in this project"
3230 )
3231 enable_field.help_text = gettext(
3232 "The translation workflow is configured at project, component, and language. "
3233 "By enabling customization here, you override these settings for this language in this project."
3234 )
3236 self.helper = FormHelper(self)
3237 self.helper.form_tag = False
3238 self.helper.layout = Layout(
3239 Field("enable"),
3240 Div(
3241 Field("translation_review"),
3242 Field("enable_suggestions"),
3243 Field("suggestion_voting"),
3244 Field("suggestion_autoaccept"),
3245 css_id="workflow-enable-target",
3246 ),
3247 )
3249 def clean_translation_review(self) -> bool | None:
3250 if "translation_review" not in self.cleaned_data:
3251 return None
3252 translation_review = self.cleaned_data["translation_review"]
3253 if not self.project:
3254 return translation_review
3255 if translation_review and not self.project.enable_review:
3256 msg = "Please turn on reviews on the project first."
3257 raise ValidationError(msg)
3258 return translation_review
3260 def save(self, commit: bool = True):
3261 if self.cleaned_data["enable"]:
3262 return super().save(commit=commit)
3263 if self.instance and self.instance.pk:
3264 self.instance.delete()
3265 self.instance = None
3266 return self.instance
3268 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None:
3269 if field.name == "enable":
3270 return ("workflows", "workflow-customization")
3271 return None