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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

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 

15 

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 

42 

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 

108 

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 

111 

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 

119 

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""" 

135 

136 

137class FieldDocsMixin(forms.Form): 

138 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None: 

139 raise NotImplementedError 

140 

141 

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) 

150 

151 

152class DateRangeField(forms.CharField): 

153 """Field for a date range input.""" 

154 

155 def __init__(self, **kwargs) -> None: 

156 super().__init__(**kwargs) 

157 

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 

177 

178 def validate(self, value) -> None: 

179 """Validate the date range values.""" 

180 if self.required: 

181 super().validate(value) 

182 

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 ) 

187 

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 

193 

194 

195class ChecksumField(forms.CharField): 

196 """Field for handling checksum IDs for translation.""" 

197 

198 def __init__(self, *args, **kwargs) -> None: 

199 kwargs["widget"] = forms.HiddenInput 

200 super().__init__(*args, **kwargs) 

201 

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 

210 

211 

212class FlagField(forms.CharField): 

213 default_validators = [validate_check_flags] # noqa: RUF012 

214 

215 

216class PluralTextarea(forms.Textarea): 

217 """Text-area extension which possibly handles plurals.""" 

218 

219 profile: Profile 

220 

221 def __init__(self, *args, **kwargs) -> None: 

222 self.is_source_plural: Literal[True] | None = None 

223 super().__init__(*args, **kwargs) 

224 

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 ) 

245 

246 groups = format_html_join( 

247 "\n", 

248 GROUP_TEMPLATE, 

249 [("", chars)], # Only one group. 

250 ) 

251 

252 return format_html(TOOLBAR_TEMPLATE, groups) 

253 

254 def get_rtl_toggle(self, language, fieldname): 

255 if language.direction != "rtl": 

256 return "" 

257 

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) 

293 

294 def get_toolbar(self, language, fieldname, unit, idx, source): 

295 """Return toolbar HTML code.""" 

296 profile = self.profile 

297 

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 ) 

319 

320 groups = format_html_join( 

321 "\n", 

322 GROUP_TEMPLATE, 

323 [("", chars)], # Only one group. 

324 ) 

325 

326 result = format_html(TOOLBAR_TEMPLATE, groups) 

327 

328 if language.direction == "rtl": 

329 result = format_html("{}{}", self.get_rtl_toolbar(fieldname), result) 

330 

331 return result 

332 

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 

356 

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 

367 

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] 

377 

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 ) 

401 

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 ) 

410 

411 return format_html_join("", "{}", ((v,) for v in ret)) 

412 

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] 

422 

423 

424class PluralField(forms.CharField): 

425 """ 

426 Renderer for the plural field. 

427 

428 The only difference from CharField is that it does not 

429 enforce the value to be a string. 

430 """ 

431 

432 def __init__(self, **kwargs) -> None: 

433 kwargs["label"] = "" 

434 super().__init__(widget=PluralTextarea, **kwargs) 

435 

436 def to_python(self, value): 

437 """Return list of strings as returned by PluralTextarea.""" 

438 return value 

439 

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 

445 

446 

447class ChecksumForm(forms.Form): 

448 """Form for handling checksum IDs for translation.""" 

449 

450 checksum = ChecksumField(required=True) 

451 

452 def __init__(self, unit_set, *args, **kwargs) -> None: 

453 self.unit_set = unit_set 

454 super().__init__(*args, **kwargs) 

455 

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 

460 

461 unit_set = self.unit_set 

462 

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 

471 

472 

473class UnitForm(forms.Form): 

474 def __init__(self, unit: Unit, *args, **kwargs) -> None: 

475 self.unit = unit 

476 super().__init__(*args, **kwargs) 

477 

478 

479class FuzzyField(forms.BooleanField): 

480 help_as_icon = True 

481 

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" 

490 

491 

492class TranslationForm(UnitForm): 

493 """Form used for translation of single string.""" 

494 

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 ) 

518 

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() 

570 

571 def clean(self) -> None: 

572 super().clean() 

573 

574 # Check required fields 

575 required = {"target", "contentsum", "translationsum"} 

576 if not required.issubset(self.cleaned_data): 

577 return 

578 

579 unit = self.unit 

580 

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 ) 

588 

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 ) 

599 

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 

615 

616 

617class ZenTranslationForm(TranslationForm): 

618 checksum = ChecksumField(required=True) 

619 

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 

632 

633 

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 ) 

643 

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 ) 

655 

656 

657class SimpleUploadForm(FieldDocsMixin, forms.Form): 

658 """Base form for uploading a file.""" 

659 

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 ) 

678 

679 def __init__(self, *args, **kwargs) -> None: 

680 super().__init__(*args, **kwargs) 

681 self.helper = FormHelper(self) 

682 self.helper.form_tag = False 

683 

684 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None: 

685 return ("user/files", f"upload-{field.name}") 

686 

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 ] 

693 

694 

695class UploadForm(SimpleUploadForm): 

696 """Uploading form with the option to overwrite current messages.""" 

697 

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 ) 

715 

716 

717class ExtraUploadForm(UploadForm): 

718 """Advanced upload form for users who can override authorship.""" 

719 

720 author_name = forms.CharField(label=gettext_lazy("Author name")) 

721 author_email = EmailField(label=gettext_lazy("Author e-mail")) 

722 

723 

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 

745 

746 

747class SearchForm(forms.Form): 

748 """Text-searching form.""" 

749 

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]] = {} 

755 

756 @staticmethod 

757 def get_initial(request: AuthenticatedHttpRequest): 

758 if "q" in request.GET: 

759 return {"q": request.GET["q"]} 

760 return None 

761 

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) 

779 

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 ) 

807 

808 def get_name(self): 

809 """Return verbose name for a search.""" 

810 return FILTERS.get_search_name(self.cleaned_data.get("q", "")) 

811 

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"] 

816 

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 

845 

846 def urlencode(self): 

847 return urlencode(self.items()) 

848 

849 def reset_offset(self): 

850 """ 

851 Reset form offset. 

852 

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 

861 

862 

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 } 

868 

869 

870class MergeForm(UnitForm): 

871 """Simple form for merging the translation of two units.""" 

872 

873 merge = forms.IntegerField() 

874 

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 

897 

898 

899class RevertForm(UnitForm): 

900 """Form for reverting edits.""" 

901 

902 revert = forms.IntegerField() 

903 

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 

917 

918 

919class AutoForm(forms.Form): 

920 """Automatic translation form.""" 

921 

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 ) 

958 

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 = {} 

968 

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) 

991 

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 ] 

1009 

1010 self.fields["component"].choices = [ 

1011 ("", gettext("All components in current project")), 

1012 *choices, 

1013 ] 

1014 

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" 

1029 

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" 

1032 

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 

1041 

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 ) 

1050 

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 

1082 

1083 

1084class CommentForm(forms.Form): 

1085 """Simple commenting form.""" 

1086 

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 ) 

1115 

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 ] 

1128 

1129 

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) 

1137 

1138 

1139class EngageForm(forms.Form): 

1140 """Form to choose language for engagement widgets.""" 

1141 

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 ) 

1153 

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) 

1157 

1158 self.fields["lang"].queryset = project.languages 

1159 self.fields["component"].queryset = ( 

1160 project.component_set.filter_access(user).prefetch().order() 

1161 ) 

1162 

1163 

1164class FullLanguageForm(forms.Form): 

1165 """Form for requesting a new language.""" 

1166 

1167 lang = forms.MultipleChoiceField( 

1168 label=gettext_lazy("Languages"), choices=[], widget=forms.SelectMultiple 

1169 ) 

1170 project: Project 

1171 

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) 

1177 

1178 def get_lang_objects(self) -> QuerySet[Language]: 

1179 raise NotImplementedError 

1180 

1181 

1182class RestrictedLanguageForm(forms.Form): 

1183 lang = forms.ChoiceField( 

1184 label=gettext_lazy("Language"), choices=[], widget=forms.Select 

1185 ) 

1186 

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 ] 

1193 

1194 def get_lang_objects(self) -> QuerySet[Language]: 

1195 return super().get_lang_objects().filter_for_add(self.project) 

1196 

1197 def clean_lang(self): 

1198 # Compatibility with NewLanguageOwnerForm 

1199 return [self.cleaned_data["lang"]] 

1200 

1201 

1202class NewComponentLanguageOwnerForm(FullLanguageForm): 

1203 def get_lang_objects(self) -> QuerySet[Language]: 

1204 return self.component.get_all_available_languages() 

1205 

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) 

1210 

1211 

1212class NewComponentLanguageForm(RestrictedLanguageForm, NewComponentLanguageOwnerForm): 

1213 """Form for requesting a new language.""" 

1214 

1215 

1216class NewProjectLanguageOwnerForm(FullLanguageForm): 

1217 """Form for adding a new language to all components in a project.""" 

1218 

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() 

1223 

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") 

1231 

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 

1236 

1237 languages_in_all_components = [ 

1238 language_id 

1239 for language_id, count in language_counter.items() 

1240 if count >= components_count 

1241 ] 

1242 

1243 # Exclude already existing languages from the list 

1244 return Language.objects.exclude(id__in=languages_in_all_components) 

1245 

1246 def __init__(self, user: User, project: Project, *args, **kwargs) -> None: 

1247 self.project = project 

1248 super().__init__(user, *args, **kwargs) 

1249 

1250 

1251class NewProjectLanguageForm(RestrictedLanguageForm, NewProjectLanguageOwnerForm): 

1252 pass 

1253 

1254 

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 

1263 

1264 

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 

1274 

1275 

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 } 

1284 

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 } 

1290 

1291 def get_field_doc(self, field: forms.Field) -> tuple[str, str] | None: 

1292 return self.doc_links[field.name] 

1293 

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 

1312 

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) 

1325 

1326 

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 ) 

1335 

1336 

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 ) 

1344 

1345 

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 ) 

1363 

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) 

1368 

1369 

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 ) 

1404 

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() 

1434 

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 

1440 

1441 

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 

1456 

1457 

1458class SettingsBaseForm(CleanRepoMixin, forms.ModelForm): 

1459 """Component base form.""" 

1460 

1461 class Meta: 

1462 model = Component 

1463 fields = [] # noqa: RUF012 

1464 

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 

1470 

1471 

1472class SelectChecksWidget(SortedSelectMultiple): 

1473 def __init__(self, attrs=None, choices=()) -> None: 

1474 choices = CHECKS.get_choices() 

1475 super().__init__(attrs=attrs, choices=choices) 

1476 

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 

1482 

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) 

1488 

1489 

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) 

1495 

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) 

1502 

1503 

1504class FormParamsWidget(forms.MultiWidget): 

1505 template_name = "bootstrap3/labelled_multiwidget.html" 

1506 subwidget_class = "file-format-param" 

1507 

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) 

1516 

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] 

1526 

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 

1531 

1532 

1533class FormParamsField(forms.MultiValueField): 

1534 def __init__(self, encoder=None, decoder=None, **kwargs) -> None: 

1535 fields = [] 

1536 subwidgets = {} 

1537 

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()) 

1544 

1545 widget = FormParamsWidget(subwidgets, self.fields_order) 

1546 super().__init__(fields, widget=widget, require_all_fields=False, **kwargs) 

1547 

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 

1558 

1559 

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('_', '-')}") 

1563 

1564 

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('_', '-')}") 

1568 

1569 

1570class SpamCheckMixin(forms.Form): 

1571 spam_fields: ClassVar[tuple[str, ...]] 

1572 

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 ) 

1582 

1583 

1584class ComponentAntispamMixin(SpamCheckMixin): 

1585 spam_fields = ("agreement",) 

1586 

1587 

1588class ProjectAntispamMixin(SpamCheckMixin): 

1589 spam_fields = ("web", "instructions") 

1590 

1591 

1592class ComponentSettingsForm( 

1593 SettingsBaseForm, ComponentDocsMixin, ComponentAntispamMixin 

1594): 

1595 """Component settings form.""" 

1596 

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 } 

1660 

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 ) 

1668 

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 ] 

1793 

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 ) 

1805 

1806 def clean(self) -> None: 

1807 data = self.cleaned_data 

1808 if self.hide_restricted: 

1809 data["restricted"] = self.instance.restricted 

1810 

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 ) 

1815 

1816 

1817class ComponentCreateForm(SettingsBaseForm, ComponentDocsMixin, ComponentAntispamMixin): 

1818 """Component creation form.""" 

1819 

1820 source_component = forms.ModelChoiceField( 

1821 queryset=Component.objects.none(), 

1822 required=False, 

1823 widget=forms.HiddenInput(), 

1824 ) 

1825 

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 } 

1861 

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) 

1880 

1881 def clean(self) -> None: 

1882 super().clean() 

1883 data = self.cleaned_data 

1884 

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 ) 

1889 

1890 

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 ) 

1907 

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 

1913 

1914 

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 ) 

1921 

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) 

1928 

1929 

1930class ComponentBranchForm(ComponentSelectForm): 

1931 branch = forms.ChoiceField(label=gettext_lazy("Repository branch")) 

1932 

1933 instance = None 

1934 

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]] = {} 

1939 

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 

1944 

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 

1971 

1972 

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 

1991 

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 

2001 

2002 def clean(self) -> None: 

2003 if "project" not in self.cleaned_data: 

2004 return 

2005 

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") 

2010 

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() 

2015 

2016 

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() 

2026 

2027 def __init__(self, *args, **kwargs) -> None: 

2028 kwargs["auto_id"] = "id_scratchcreate_%s" 

2029 super().__init__(*args, **kwargs) 

2030 

2031 

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 ) 

2038 

2039 field_order = [ # noqa: RUF012 

2040 "zipfile", 

2041 "project", 

2042 "name", 

2043 "slug", 

2044 ] 

2045 

2046 def __init__(self, *args, **kwargs) -> None: 

2047 kwargs["auto_id"] = "id_zipcreate_%s" 

2048 super().__init__(*args, **kwargs) 

2049 

2050 

2051class ComponentDocCreateForm(ComponentProjectForm): 

2052 docfile = forms.FileField( 

2053 label=gettext_lazy("Document to translate"), 

2054 validators=[validate_file_extension], 

2055 ) 

2056 

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 ] 

2070 

2071 def __init__(self, *args, **kwargs) -> None: 

2072 kwargs["auto_id"] = "id_doccreate_%s" 

2073 super().__init__(*args, **kwargs) 

2074 

2075 

2076class ComponentInitCreateForm(CleanRepoMixin, ComponentProjectForm): 

2077 """Component creation form.""" 

2078 

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] 

2097 

2098 def clean_instance(self, data) -> None: 

2099 params = copy.copy(data) 

2100 if "discovery" in params: 

2101 params.pop("discovery") 

2102 

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 

2118 

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) 

2125 

2126 def clean(self) -> None: 

2127 self.clean_instance(self.cleaned_data) 

2128 

2129 

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 ) 

2149 

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) 

2161 

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 ] 

2176 

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 

2195 

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 ) 

2203 

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)]) 

2209 

2210 

2211class ComponentRenameForm(SettingsBaseForm, ComponentDocsMixin): 

2212 """Component rename form.""" 

2213 

2214 class Meta: 

2215 model = Component 

2216 fields = ["name", "slug", "project", "category"] # noqa: RUF012 

2217 

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() 

2222 

2223 

2224class CategoryRenameForm(SettingsBaseForm): 

2225 """Category rename form.""" 

2226 

2227 class Meta: 

2228 model = Category 

2229 fields = ["name", "slug", "project", "category"] # noqa: RUF012 

2230 

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 ) 

2237 

2238 

2239class AddCategoryForm(SettingsBaseForm): 

2240 class Meta: 

2241 model = Category 

2242 fields = ["name", "slug"] # noqa: RUF012 

2243 

2244 def __init__( 

2245 self, request: AuthenticatedHttpRequest, parent, *args, **kwargs 

2246 ) -> None: 

2247 self.parent = parent 

2248 super().__init__(request, *args, **kwargs) 

2249 

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() 

2257 

2258 

2259class ProjectSettingsForm(SettingsBaseForm, ProjectDocsMixin, ProjectAntispamMixin): 

2260 """Project settings form.""" 

2261 

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 } 

2288 

2289 def clean(self) -> None: 

2290 data = self.cleaned_data 

2291 if settings.OFFER_HOSTING: 

2292 data["contribute_shared_tm"] = data["use_shared_tm"] 

2293 

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"] 

2302 

2303 self.changed_access = access != self.instance.access_control 

2304 

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 ) 

2331 

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 ) 

2345 

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 ) 

2360 

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 ) 

2369 

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 ) 

2450 

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 ] 

2462 

2463 

2464class ProjectRenameForm(SettingsBaseForm, ProjectDocsMixin): 

2465 """Project renaming form.""" 

2466 

2467 class Meta: 

2468 model = Project 

2469 fields = ["name", "slug"] # noqa: RUF012 

2470 

2471 

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 ) 

2481 

2482 

2483class ProjectCreateForm( 

2484 BillingMixin, SettingsBaseForm, ProjectDocsMixin, ProjectAntispamMixin 

2485): 

2486 """Project creation form.""" 

2487 

2488 class Meta: 

2489 model = Project 

2490 fields = ("name", "slug", "web", "instructions") 

2491 

2492 

2493class ProjectImportCreateForm(ProjectCreateForm): 

2494 class Meta: 

2495 model = Project 

2496 fields = ("name", "slug") 

2497 

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 ) 

2516 

2517 

2518class ProjectImportForm(BillingMixin, forms.Form): 

2519 """Component base form.""" 

2520 

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 ) 

2526 

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 ) 

2539 

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 

2559 

2560 

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 ) 

2580 

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 ) 

2594 

2595 

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) 

2599 

2600 def __init__(self, units, *args, **kwargs) -> None: 

2601 super().__init__(*args, **kwargs) 

2602 self.fields["units"].queryset = units 

2603 

2604 

2605class MatrixLanguageForm(forms.Form): 

2606 """Form for requesting a new language.""" 

2607 

2608 lang = forms.MultipleChoiceField( 

2609 label=gettext_lazy("Languages"), choices=[], widget=forms.SelectMultiple 

2610 ) 

2611 

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() 

2618 

2619 

2620class NewUnitBaseForm(forms.Form): 

2621 variant = forms.ModelChoiceField( 

2622 Unit.objects.none(), 

2623 widget=forms.HiddenInput, 

2624 required=False, 

2625 ) 

2626 

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() 

2641 

2642 def patch_fields(self) -> None: 

2643 self.fields["variant"].queryset = self.translation.unit_set.all() 

2644 

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) 

2652 

2653 def get_glossary_flags(self) -> str: 

2654 return "" 

2655 

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 } 

2670 

2671 

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 ) 

2688 

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) 

2694 

2695 

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 ) 

2713 

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 ) 

2722 

2723 

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 ) 

2732 

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) 

2738 

2739 

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 ) 

2760 

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 ) 

2766 

2767 def patch_fields(self) -> None: 

2768 super().patch_fields() 

2769 if not self.can_edit_terminology(): 

2770 self.fields["terminology"].widget = forms.HiddenInput() 

2771 

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) 

2781 

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() 

2788 

2789 

2790class NewBilingualGlossarySourceUnitForm(GlossaryAddMixin, NewBilingualSourceUnitForm): 

2791 def patch_fields(self) -> None: 

2792 super().patch_fields() 

2793 self.fields["terminology"].initial = True 

2794 

2795 

2796class NewBilingualGlossaryUnitForm(GlossaryAddMixin, NewBilingualUnitForm): 

2797 pass 

2798 

2799 

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 ) 

2846 

2847 

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 ) 

2873 

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 

2887 

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) 

2893 

2894 # Filter offered states 

2895 choices = self.fields["state"].choices 

2896 

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 

2906 

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" 

2922 

2923 

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) 

2929 

2930 

2931class BaseDeleteForm(forms.Form): 

2932 confirm = forms.CharField(required=True) 

2933 warning_template = "" 

2934 

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 

2948 

2949 def get_template_context(self, obj): 

2950 return {"object": obj} 

2951 

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 ) 

2957 

2958 

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" 

2968 

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 

2976 

2977 

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" 

2987 

2988 

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" 

2996 

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 

3003 

3004 

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" 

3012 

3013 

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" 

3023 

3024 

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" 

3034 

3035 

3036class AnnouncementForm(forms.ModelForm): 

3037 """Announcement posting form.""" 

3038 

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 } 

3046 

3047 

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 ) 

3065 

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 

3090 

3091 def urlencode(self): 

3092 return urlencode(self.items()) 

3093 

3094 

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 } 

3103 

3104 def clean_project(self): 

3105 # Ignore any passed value, override by current one 

3106 return self.project 

3107 

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 

3114 

3115 

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 } 

3123 

3124 def __init__(self, project, *args, **kwargs) -> None: 

3125 self.project = project 

3126 super().__init__(*args, **kwargs) 

3127 

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 

3140 

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 

3147 

3148 

3149class ProjectGroupDeleteForm(forms.Form): 

3150 group = forms.ModelChoiceField( 

3151 Group.objects.none(), 

3152 widget=forms.HiddenInput, 

3153 required=True, 

3154 ) 

3155 

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() 

3160 

3161 

3162class ProjectUserGroupForm(UserManageForm): 

3163 groups = forms.ModelMultipleChoiceField( 

3164 Group.objects.none(), 

3165 widget=forms.CheckboxSelectMultiple, 

3166 label=gettext_lazy("Teams"), 

3167 required=False, 

3168 ) 

3169 

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() 

3175 

3176 

3177class ProjectFilterForm(forms.Form): 

3178 owned = UserField(required=False) 

3179 watched = UserField(required=False) 

3180 

3181 

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 ) 

3192 

3193 class Meta: 

3194 model = WorkflowSetting 

3195 fields = [ # noqa: RUF012 

3196 "translation_review", 

3197 "enable_suggestions", 

3198 "suggestion_voting", 

3199 "suggestion_autoaccept", 

3200 ] 

3201 

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} 

3220 

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 ) 

3235 

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 ) 

3248 

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 

3259 

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 

3267 

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