Coverage for app/venv/lib/python3.14/site-packages/weblate/addons/forms.py: 42%

217 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

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

2# 

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

4 

5from __future__ import annotations 

6 

7import re 

8from typing import TYPE_CHECKING 

9 

10from crispy_forms.helper import FormHelper 

11from crispy_forms.layout import Field, Layout 

12from django import forms 

13from django.http import QueryDict 

14from django.utils.functional import cached_property 

15from django.utils.translation import gettext, gettext_lazy 

16from lxml.cssselect import CSSSelector 

17 

18from weblate.formats.models import FILE_FORMATS 

19from weblate.trans.actions import ActionEvents 

20from weblate.trans.discovery import ComponentDiscovery 

21from weblate.trans.forms import AutoForm, BulkEditForm 

22from weblate.trans.models import Translation 

23from weblate.utils.forms import ( 

24 CachedModelChoiceField, 

25 ContextDiv, 

26 SortedSelectMultiple, 

27 WeblateServiceURLField, 

28) 

29from weblate.utils.render import validate_render, validate_render_translation 

30from weblate.utils.validators import ( 

31 validate_base64_encoded_string, 

32 validate_filename, 

33 validate_re, 

34) 

35 

36if TYPE_CHECKING: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true

37 from weblate.auth.models import User 

38 from weblate.trans.models import Component, Project 

39 

40 

41class BaseAddonForm(forms.Form): 

42 def __init__( 

43 self, user: User | None, addon, instance=None, *args, **kwargs 

44 ) -> None: 

45 self._addon = addon 

46 self.user = user 

47 forms.Form.__init__(self, *args, **kwargs) 

48 

49 def serialize_form(self): 

50 return self.cleaned_data 

51 

52 def save(self): 

53 self._addon.configure(self.serialize_form()) 

54 return self._addon.instance 

55 

56 

57class GenerateMoForm(BaseAddonForm): 

58 path = forms.CharField( 

59 label=gettext_lazy("Path of generated MO file"), 

60 required=False, 

61 initial="{{ filename|stripext }}.mo", 

62 help_text=gettext_lazy( 

63 "If not specified, the location of the PO file will be used." 

64 ), 

65 ) 

66 fuzzy = forms.BooleanField( 

67 label=gettext_lazy("Include strings needing editing"), 

68 required=False, 

69 help_text=gettext_lazy( 

70 "Strings needing editing (fuzzy) are typically not ready for use as translations." 

71 ), 

72 ) 

73 

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

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

76 self.helper = FormHelper(self) 

77 self.helper.layout = Layout( 

78 Field("fuzzy"), 

79 Field("path"), 

80 ContextDiv( 

81 template="addons/generatemo_help.html", context={"user": self.user} 

82 ), 

83 ) 

84 

85 def test_render(self, value) -> None: 

86 validate_render_translation(value) 

87 

88 def clean_path(self): 

89 self.test_render(self.cleaned_data["path"]) 

90 validate_filename(self.cleaned_data["path"]) 

91 return self.cleaned_data["path"] 

92 

93 

94class GenerateForm(BaseAddonForm): 

95 filename = forms.CharField( 

96 label=gettext_lazy("Name of generated file"), required=True 

97 ) 

98 template = forms.CharField( 

99 widget=forms.Textarea(), 

100 label=gettext_lazy("Content of generated file"), 

101 required=True, 

102 ) 

103 

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

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

106 self.helper = FormHelper(self) 

107 self.helper.layout = Layout( 

108 Field("filename"), 

109 Field("template"), 

110 ContextDiv( 

111 template="addons/generate_help.html", context={"user": self.user} 

112 ), 

113 ) 

114 

115 def test_render(self, value) -> None: 

116 validate_render_translation(value) 

117 

118 def clean_filename(self): 

119 self.test_render(self.cleaned_data["filename"]) 

120 validate_filename(self.cleaned_data["filename"]) 

121 return self.cleaned_data["filename"] 

122 

123 def clean_template(self): 

124 self.test_render(self.cleaned_data["template"]) 

125 return self.cleaned_data["template"] 

126 

127 

128class GitSquashForm(BaseAddonForm): 

129 squash = forms.ChoiceField( 

130 label=gettext_lazy("Commit squashing"), 

131 widget=forms.RadioSelect, 

132 choices=( 

133 ("all", gettext_lazy("All commits into one")), 

134 ("language", gettext_lazy("Per language")), 

135 ("file", gettext_lazy("Per file")), 

136 ("author", gettext_lazy("Per author")), 

137 ), 

138 initial="all", 

139 required=True, 

140 ) 

141 append_trailers = forms.BooleanField( 

142 label=gettext_lazy("Append trailers to squashed commit message"), 

143 required=False, 

144 initial=True, 

145 help_text=gettext_lazy( 

146 "Trailer lines are lines that look similar to RFC 822 e-mail " 

147 "headers, at the end of the otherwise free-form part of a commit " 

148 "message, such as 'Co-authored-by: …'." 

149 ), 

150 ) 

151 commit_message = forms.CharField( 

152 label=gettext_lazy("Commit message"), 

153 widget=forms.Textarea(), 

154 required=False, 

155 help_text=gettext_lazy( 

156 "This commit message will be used instead of the combined commit " 

157 "messages from the squashed commits." 

158 ), 

159 ) 

160 

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

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

163 self.helper = FormHelper(self) 

164 self.helper.layout = Layout( 

165 Field("squash"), 

166 Field("append_trailers"), 

167 Field("commit_message"), 

168 ContextDiv(template="addons/squash_help.html", context={"user": self.user}), 

169 ) 

170 

171 

172class RemoveForm(BaseAddonForm): 

173 age = forms.IntegerField( 

174 label=gettext_lazy("Days to keep"), min_value=0, initial=30, required=True 

175 ) 

176 

177 

178class RemoveSuggestionForm(RemoveForm): 

179 votes = forms.IntegerField( 

180 label=gettext_lazy("Voting threshold"), 

181 initial=0, 

182 required=True, 

183 help_text=gettext_lazy( 

184 "Threshold for removal. This field has no effect with voting turned off." 

185 ), 

186 ) 

187 

188 

189class DiscoveryForm(BaseAddonForm): 

190 match = forms.CharField( 

191 label=gettext_lazy("Regular expression to match translation files against"), 

192 required=True, 

193 ) 

194 file_format = forms.ChoiceField( 

195 label=gettext_lazy("File format"), 

196 choices=FILE_FORMATS.get_choices(empty=True), 

197 initial="", 

198 required=True, 

199 ) 

200 name_template = forms.CharField( 

201 label=gettext_lazy("Customize the component name"), 

202 initial="{{ component }}", 

203 required=True, 

204 ) 

205 base_file_template = forms.CharField( 

206 label=gettext_lazy("Define the monolingual base filename"), 

207 initial="", 

208 required=False, 

209 help_text=gettext_lazy("Leave empty for bilingual translation files."), 

210 ) 

211 new_base_template = forms.CharField( 

212 label=gettext_lazy("Define the base file for new translations"), 

213 initial="", 

214 required=False, 

215 help_text=gettext_lazy( 

216 "Filename of file used for creating new translations. " 

217 "For gettext choose .pot file." 

218 ), 

219 ) 

220 intermediate_template = forms.CharField( 

221 label=gettext_lazy("Intermediate language file"), 

222 initial="", 

223 required=False, 

224 help_text=gettext_lazy( 

225 "Filename of intermediate translation file. In most cases " 

226 "this is a translation file provided by developers and is " 

227 "used when creating actual source strings." 

228 ), 

229 ) 

230 

231 language_regex = forms.CharField( 

232 label=gettext_lazy("Language filter"), 

233 max_length=200, 

234 initial="^[^.]+$", 

235 validators=[validate_re], 

236 help_text=gettext_lazy( 

237 "Regular expression to filter " 

238 "translation files against when scanning for file mask." 

239 ), 

240 ) 

241 copy_addons = forms.BooleanField( 

242 label=gettext_lazy( 

243 "Clone add-ons from the main component to the newly created ones" 

244 ), 

245 required=False, 

246 initial=True, 

247 ) 

248 remove = forms.BooleanField( 

249 label=gettext_lazy("Remove components for inexistent files"), required=False 

250 ) 

251 confirm = forms.BooleanField( 

252 label=gettext_lazy("I confirm the above matches look correct"), 

253 required=False, 

254 widget=forms.HiddenInput, 

255 ) 

256 

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

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

259 self.helper = FormHelper(self) 

260 self.helper.layout = Layout( 

261 Field("match"), 

262 Field("file_format"), 

263 Field("name_template"), 

264 Field("base_file_template"), 

265 Field("new_base_template"), 

266 Field("intermediate_template"), 

267 Field("language_regex"), 

268 Field("copy_addons"), 

269 Field("remove"), 

270 ) 

271 if self.is_bound: 

272 # Perform form validation 

273 self.full_clean() 

274 # Show preview if form was submitted 

275 if self.cleaned_data.get("preview"): 

276 self.fields["confirm"].widget = forms.CheckboxInput() 

277 self.helper.layout.insert(0, Field("confirm")) 

278 created, matched, deleted, skipped = self.discovery.perform( 

279 preview=True, remove=self.cleaned_data["remove"] 

280 ) 

281 self.helper.layout.insert( 

282 0, 

283 ContextDiv( 

284 template="addons/discovery_preview.html", 

285 context={ 

286 "matches_created": created, 

287 "matches_matched": matched, 

288 "matches_deleted": deleted, 

289 "matches_skipped": skipped, 

290 "user": self.user, 

291 }, 

292 ), 

293 ) 

294 

295 @cached_property 

296 def discovery(self): 

297 return ComponentDiscovery( 

298 self._addon.instance.component, 

299 **ComponentDiscovery.extract_kwargs(self.cleaned_data), 

300 ) 

301 

302 def clean(self) -> None: 

303 if file_format := self.cleaned_data.get("file_format"): 

304 is_monolingual = FILE_FORMATS[file_format].monolingual 

305 if is_monolingual and not self.cleaned_data.get("base_file_template"): 

306 raise forms.ValidationError( 

307 { 

308 "base_file_template": gettext( 

309 "You can not use a monolingual translation without a base file." 

310 ) 

311 } 

312 ) 

313 if is_monolingual is False and self.cleaned_data["base_file_template"]: 

314 raise forms.ValidationError( 

315 { 

316 "base_file_template": gettext( 

317 "You can not use a base file for bilingual translation." 

318 ) 

319 } 

320 ) 

321 

322 self.cleaned_data["preview"] = False 

323 

324 # There are some other errors or the form was loaded from db 

325 if self.errors or not isinstance(self.data, QueryDict): 

326 return 

327 

328 self.cleaned_data["preview"] = True 

329 if not self.cleaned_data["confirm"]: 

330 raise forms.ValidationError( 

331 gettext("Please review and confirm the matched components.") 

332 ) 

333 

334 def clean_match(self): 

335 match = self.cleaned_data["match"] 

336 validate_re(match, ("component", "language")) 

337 return match 

338 

339 @cached_property 

340 def cleaned_match_re(self): 

341 if "match" not in self.cleaned_data: 

342 return None 

343 try: 

344 return re.compile(self.cleaned_data["match"]) 

345 except re.error: 

346 return None 

347 

348 def test_render(self, value): 

349 if self.cleaned_match_re is None: 

350 matches = {"component": "test"} 

351 else: 

352 matches = dict.fromkeys(self.cleaned_match_re.groupindex, "test") 

353 return validate_render(value, **matches) 

354 

355 def template_clean(self, name): 

356 result = self.test_render(self.cleaned_data[name]) 

357 if result and result == self.cleaned_data[name]: 

358 raise forms.ValidationError( 

359 gettext("Please include component markup in the template.") 

360 ) 

361 return self.cleaned_data[name] 

362 

363 def clean_name_template(self): 

364 return self.template_clean("name_template") 

365 

366 def clean_base_file_template(self): 

367 return self.template_clean("base_file_template") 

368 

369 def clean_new_base_template(self): 

370 return self.template_clean("new_base_template") 

371 

372 def clean_intermediate_template(self): 

373 return self.template_clean("intermediate_template") 

374 

375 

376class AutoAddonForm(BaseAddonForm, AutoForm): 

377 def __init__(self, user: User, addon, instance=None, **kwargs) -> None: 

378 BaseAddonForm.__init__(self, user, addon) 

379 AutoForm.__init__( 

380 self, obj=addon.instance.component or addon.instance.project, **kwargs 

381 ) 

382 

383 

384class BulkEditAddonForm(BaseAddonForm, BulkEditForm): 

385 def __init__(self, user: User | None, addon, instance=None, **kwargs) -> None: 

386 BaseAddonForm.__init__(self, user, addon) 

387 obj: Project | Component | None = None 

388 project: Project | None = None 

389 if addon.instance.component: 

390 obj = addon.instance.component 

391 project = addon.instance.component.project 

392 elif addon.instance.project: 

393 obj = project = addon.instance.project 

394 BulkEditForm.__init__( 

395 self, 

396 obj=obj, 

397 project=project, 

398 user=None, 

399 **kwargs, 

400 ) 

401 

402 def serialize_form(self): 

403 result = dict(self.cleaned_data) 

404 # Need to convert to JSON serializable objects 

405 result["add_labels"] = list(result["add_labels"].values_list("name", flat=True)) 

406 result["remove_labels"] = list( 

407 result["remove_labels"].values_list("name", flat=True) 

408 ) 

409 return result 

410 

411 

412class CDNJSForm(BaseAddonForm): 

413 threshold = forms.IntegerField( 

414 label=gettext_lazy("Translation threshold"), 

415 initial=0, 

416 max_value=100, 

417 min_value=0, 

418 required=True, 

419 help_text=gettext_lazy( 

420 "The percentage of translated strings that must be present for translation to be included." 

421 ), 

422 ) 

423 css_selector = forms.CharField( 

424 label=gettext_lazy("CSS selector"), 

425 required=True, 

426 initial=".l10n", 

427 help_text=gettext_lazy("CSS selector to detect localizable elements."), 

428 ) 

429 cookie_name = forms.CharField( 

430 label=gettext_lazy("Language cookie name"), 

431 required=False, 

432 initial="", 

433 help_text=gettext_lazy("Name of cookie which stores language preference."), 

434 ) 

435 # This shadows files from the Form class 

436 files = forms.CharField( # type: ignore[assignment] 

437 widget=forms.Textarea(), 

438 label=gettext_lazy("Extract strings from HTML files"), 

439 required=False, 

440 help_text=gettext_lazy( 

441 "List of filenames in current repository or remote URLs to parse " 

442 "for translatable strings." 

443 ), 

444 ) 

445 

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

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

448 self.helper = FormHelper(self) 

449 self.helper.layout = Layout( 

450 Field("threshold"), 

451 Field("css_selector"), 

452 Field("cookie_name"), 

453 Field("files"), 

454 ) 

455 if self.is_bound and self._addon.instance.pk: 

456 self.helper.layout.insert( 

457 0, 

458 ContextDiv( 

459 template="addons/cdnjs.html", 

460 context={"url": self._addon.cdn_js_url, "user": self.user}, 

461 ), 

462 ) 

463 

464 def clean_css_selector(self): 

465 try: 

466 CSSSelector(self.cleaned_data["css_selector"], translator="html") 

467 except Exception as error: 

468 raise forms.ValidationError( 

469 gettext("Could not parse CSS selector: %s") % error 

470 ) from error 

471 return self.cleaned_data["css_selector"] 

472 

473 

474class TranslationLanguageChoiceField(CachedModelChoiceField): 

475 def label_from_instance(self, obj): 

476 return str(obj.language) 

477 

478 

479class PseudolocaleAddonForm(BaseAddonForm): 

480 source = TranslationLanguageChoiceField( 

481 label=gettext_lazy("Source strings"), 

482 required=True, 

483 queryset=Translation.objects.none(), 

484 ) 

485 target = TranslationLanguageChoiceField( 

486 label=gettext_lazy("Target translation"), 

487 required=True, 

488 help_text=gettext_lazy("All strings in this translation will be overwritten"), 

489 queryset=Translation.objects.none(), 

490 ) 

491 # This shadows prefix from the Form class 

492 prefix = forms.CharField( # type: ignore[assignment] 

493 label=gettext_lazy("Prepended static text"), 

494 required=False, 

495 initial="", 

496 ) 

497 var_prefix = forms.CharField( 

498 label=gettext_lazy("Prepended variable text"), 

499 required=False, 

500 initial="", 

501 ) 

502 suffix = forms.CharField( 

503 label=gettext_lazy("Appended static text"), 

504 required=False, 

505 initial="", 

506 ) 

507 var_suffix = forms.CharField( 

508 label=gettext_lazy("Appended variable text"), 

509 required=False, 

510 initial="", 

511 ) 

512 var_multiplier = forms.FloatField( 

513 label=gettext_lazy("Variable text multiplier"), 

514 required=False, 

515 initial=0.1, 

516 help_text=gettext_lazy( 

517 "How many times to repeat the variable text depending on " 

518 "the length of the source string." 

519 ), 

520 ) 

521 include_readonly = forms.BooleanField( 

522 label=gettext_lazy("Include read-only strings"), 

523 required=False, 

524 ) 

525 

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

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

528 if self._addon.instance.component: 

529 queryset = self._addon.instance.component.translation_set.all() 

530 self.fields["source"].queryset = queryset 

531 self.fields["target"].queryset = queryset 

532 self.helper = FormHelper(self) 

533 self.helper.layout = Layout( 

534 Field("source"), 

535 Field("include_readonly"), 

536 Field("target"), 

537 Field("prefix"), 

538 Field("var_prefix"), 

539 Field("suffix"), 

540 Field("var_suffix"), 

541 Field("var_multiplier"), 

542 ContextDiv( 

543 template="addons/pseudolocale.html", 

544 ), 

545 ) 

546 

547 def clean(self) -> None: 

548 if "source" not in self.cleaned_data or "target" not in self.cleaned_data: 

549 return 

550 if self.cleaned_data["source"] == self.cleaned_data["target"]: 

551 raise forms.ValidationError( 

552 gettext("The source and target have to be different languages.") 

553 ) 

554 

555 def serialize_form(self): 

556 result = dict(self.cleaned_data) 

557 # Need to convert to JSON serializable objects 

558 result["source"] = result["source"].pk 

559 result["target"] = result["target"].pk 

560 return result 

561 

562 

563class PropertiesSortAddonForm(BaseAddonForm): 

564 case_sensitive = forms.BooleanField( 

565 label=gettext_lazy("Enable case-sensitive key sorting"), 

566 required=False, 

567 initial=False, 

568 ) 

569 

570 

571class ChangeBaseAddonForm(BaseAddonForm): 

572 """Base form for Change-based addons.""" 

573 

574 events = forms.MultipleChoiceField( 

575 label=gettext_lazy("Change events"), 

576 required=False, 

577 widget=SortedSelectMultiple(), 

578 choices=ActionEvents.choices, 

579 ) 

580 

581 

582class BaseWebhooksAddonForm(ChangeBaseAddonForm): 

583 """Form for webhook add-on configuration.""" 

584 

585 webhook_url = WeblateServiceURLField( 

586 label=gettext_lazy("Webhook URL"), 

587 required=True, 

588 ) 

589 

590 field_order = [ # noqa: RUF012 

591 "webhook_url", 

592 "events", 

593 ] 

594 

595 

596class WebhooksAddonForm(BaseWebhooksAddonForm): 

597 """Form for webhook add-on configuration.""" 

598 

599 secret = forms.CharField( 

600 label=gettext_lazy("Secret"), 

601 validators=[ 

602 validate_base64_encoded_string, 

603 ], 

604 required=False, 

605 help_text=gettext_lazy("A Base64 encoded string"), 

606 ) 

607 

608 field_order = [ # noqa: RUF012 

609 "webhook_url", 

610 "secret", 

611 "events", 

612 ]