Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/create.py: 19%

415 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 

4from __future__ import annotations 

5 

6import json 

7import os 

8import subprocess 

9from contextlib import suppress 

10from typing import TYPE_CHECKING 

11from zipfile import BadZipfile 

12 

13from django.conf import settings 

14from django.contrib.auth.decorators import login_required 

15from django.forms import HiddenInput 

16from django.shortcuts import redirect 

17from django.urls import reverse 

18from django.utils.decorators import method_decorator 

19from django.utils.functional import cached_property 

20from django.utils.http import urlencode 

21from django.utils.translation import gettext 

22from django.views.generic.edit import CreateView 

23 

24from weblate.trans.backups import ProjectBackup 

25from weblate.trans.forms import ( 

26 ComponentBranchForm, 

27 ComponentCreateForm, 

28 ComponentDiscoverForm, 

29 ComponentDocCreateForm, 

30 ComponentInitCreateForm, 

31 ComponentScratchCreateForm, 

32 ComponentSelectForm, 

33 ComponentZipCreateForm, 

34 ProjectCreateForm, 

35 ProjectImportCreateForm, 

36 ProjectImportForm, 

37) 

38from weblate.trans.models import Category, Component, Project 

39from weblate.trans.tasks import perform_update 

40from weblate.trans.util import get_clean_env 

41from weblate.utils import messages 

42from weblate.utils.errors import report_error 

43from weblate.utils.licenses import LICENSE_URLS 

44from weblate.utils.ratelimit import session_ratelimit_post 

45from weblate.utils.views import create_component_from_doc, create_component_from_zip 

46from weblate.vcs.models import VCS_REGISTRY 

47 

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

49 from django.forms import Form 

50 

51 from weblate.auth.models import AuthenticatedHttpRequest 

52 from weblate.trans.forms import ( 

53 ComponentProjectForm, 

54 ) 

55 from weblate.trans.models.component import ComponentQuerySet 

56 

57SESSION_CREATE_KEY = "session_component" 

58 

59 

60class BaseCreateView(CreateView): 

61 request: AuthenticatedHttpRequest 

62 

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

64 super().__init__(**kwargs) 

65 self.has_billing = "weblate.billing" in settings.INSTALLED_APPS 

66 

67 def get_form_kwargs(self): 

68 kwargs = super().get_form_kwargs() 

69 kwargs["request"] = self.request 

70 return kwargs 

71 

72 def form_invalid(self, form): 

73 messages.error( 

74 self.request, 

75 gettext( 

76 "The supplied configuration is incorrect. Please check the errors below.", 

77 ), 

78 ) 

79 return super().form_invalid(form) 

80 

81 

82@method_decorator(login_required, name="dispatch") 

83@method_decorator(session_ratelimit_post("project"), name="dispatch") 

84class CreateProject(BaseCreateView): 

85 model = Project 

86 object: Project 

87 form_class: type[Form] = ProjectCreateForm 

88 billings = None 

89 

90 def get_form(self, form_class=None): 

91 form = super().get_form(form_class) 

92 if "billing" in form.fields: 

93 billing_field = form.fields["billing"] 

94 if self.has_billing: 

95 billing_field.queryset = self.billings 

96 with suppress(ValueError, KeyError): 

97 billing_field.initial = int(self.request.GET["billing"]) 

98 billing_field.required = not self.request.user.is_superuser 

99 if self.request.user.is_superuser: 

100 billing_field.empty_label = "-- without billing --" 

101 else: 

102 billing_field.required = False 

103 billing_field.widget = HiddenInput() 

104 return form 

105 

106 def form_valid(self, form): 

107 result = super().form_valid(form) 

108 if self.has_billing and form.cleaned_data["billing"]: 

109 billing = form.cleaned_data["billing"] 

110 else: 

111 billing = None 

112 self.object.post_create(self.request.user, billing) 

113 return result 

114 

115 def can_create(self): 

116 return (self.has_billing and self.billings) or self.request.user.has_perm( 

117 "project.add" 

118 ) 

119 

120 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

121 if not self.can_create(): 

122 return redirect("create-project") 

123 return super().post(request, *args, **kwargs) 

124 

125 def get_context_data(self, **kwargs): 

126 kwargs = super().get_context_data(**kwargs) 

127 kwargs["can_create"] = self.can_create() 

128 kwargs["import_form"] = self.get_form(ProjectImportForm) 

129 if self.has_billing: 

130 from weblate.billing.models import Billing 

131 

132 kwargs["user_billings"] = Billing.objects.for_user( 

133 self.request.user 

134 ).exists() 

135 return kwargs 

136 

137 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

138 if self.has_billing: 

139 from weblate.billing.models import Billing 

140 

141 self.billings = Billing.objects.for_user_within_limits(request.user) 

142 return super().dispatch(request, *args, **kwargs) 

143 

144 def get_success_url(self) -> str: 

145 return f"{super().get_success_url()}#components" 

146 

147 

148class ImportProject(CreateProject): 

149 form_class = ProjectImportForm 

150 template_name = "trans/project_import.html" 

151 

152 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

153 if "import_project" in request.session and os.path.exists( 

154 request.session["import_project"] 

155 ): 

156 if "zipfile" in request.FILES: 

157 # Delete previous (stale) import data 

158 del request.session["import_project"] 

159 request.session.pop("import_billing", None) 

160 self.projectbackup = None 

161 else: 

162 self.projectbackup = ProjectBackup(request.session["import_project"]) 

163 # The backup is already validated at this point, 

164 # but we need to load the info. 

165 self.projectbackup.validate() 

166 else: 

167 request.session.pop("import_project", None) 

168 request.session.pop("import_billing", None) 

169 self.projectbackup = None 

170 super().setup(request, *args, **kwargs) 

171 

172 def get_form(self, form_class=None): 

173 form = super().get_form(form_class) 

174 if "billing" in form.fields and self.has_billing: 

175 from weblate.billing.models import Billing 

176 

177 billing = self.request.session.get("import_billing") 

178 if billing: 

179 form.fields["billing"].initial = Billing.objects.get(pk=billing) 

180 return form 

181 

182 def get_form_class(self): 

183 """Return the form class to use.""" 

184 if self.projectbackup: 

185 return ProjectImportCreateForm 

186 return self.form_class 

187 

188 def get_form_kwargs(self): 

189 kwargs = super().get_form_kwargs() 

190 if self.projectbackup: 

191 kwargs["projectbackup"] = self.projectbackup 

192 return kwargs 

193 

194 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

195 if "zipfile" in request.FILES and self.projectbackup: 

196 # Delete previous (stale) import data 

197 os.unlink(self.projectbackup.filename) 

198 del self.request.session["import_project"] 

199 self.request.session.pop("import_billing", None) 

200 self.projectbackup = None 

201 return super().post(request, *args, **kwargs) 

202 

203 def form_valid(self, form): 

204 if isinstance(form, ProjectImportForm): 

205 # Save current zip to the import dir 

206 self.request.session["import_project"] = form.cleaned_data[ 

207 "projectbackup" 

208 ].store_for_import() 

209 if form.cleaned_data["billing"]: 

210 self.request.session["import_billing"] = form.cleaned_data["billing"].pk 

211 return redirect("create-project-import") 

212 # Perform actual import 

213 project = self.projectbackup.restore( 

214 project_name=form.cleaned_data["name"], 

215 project_slug=form.cleaned_data["slug"], 

216 user=self.request.user, 

217 billing=form.cleaned_data["billing"], 

218 ) 

219 del self.request.session["import_project"] 

220 return redirect(project) 

221 

222 

223@method_decorator(login_required, name="dispatch") 

224class CreateComponent(BaseCreateView): 

225 model = Component 

226 projects = None 

227 stage = None 

228 selected_project = None 

229 selected_category = None 

230 basic_fields = ("repo", "name", "slug", "vcs", "source_language") 

231 empty_form = False 

232 form_class: type[ComponentProjectForm] = ComponentInitCreateForm 

233 origin = "vcs" 

234 object: Component 

235 duplicate_existing_component: int | None = None 

236 

237 def get_form_class(self): 

238 """Return the form class to use.""" 

239 if self.stage == "create": 

240 return ComponentCreateForm 

241 if self.stage == "discover": 

242 return ComponentDiscoverForm 

243 return self.form_class 

244 

245 def get_form_kwargs(self): 

246 result = super().get_form_kwargs() 

247 if self.request.method != "POST": 

248 if self.initial: 

249 # When going from other form (for example ZIP import) 

250 result.pop("data", None) 

251 result.pop("files", None) 

252 if self.has_all_fields() and not self.empty_form: 

253 if SESSION_CREATE_KEY in self.request.session: 

254 result["data"] = self.request.session[SESSION_CREATE_KEY] 

255 else: 

256 result["data"] = self.request.GET 

257 return result 

258 

259 def get_success_url(self): 

260 return reverse("show_progress", kwargs={"path": self.object.get_url_path()}) 

261 

262 def warn_outdated(self, form) -> None: 

263 linked = form.instance.linked_component 

264 if linked: 

265 perform_update.delay("Component", linked.pk, auto=True) 

266 if linked.repo_needs_merge(): 

267 messages.warning( 

268 self.request, 

269 gettext( 

270 "The repository is outdated, you might not get " 

271 "expected results until you update it." 

272 ), 

273 ) 

274 

275 def detect_license(self, form) -> None: 

276 """Automatic license detection based on licensee.""" 

277 try: 

278 process_result = subprocess.run( 

279 ["licensee", "detect", "--json", form.instance.full_path], 

280 text=True, 

281 capture_output=True, 

282 env=get_clean_env(), 

283 check=True, 

284 ) 

285 except FileNotFoundError: 

286 return 

287 except (OSError, subprocess.CalledProcessError) as error: 

288 if getattr(error, "returncode", 0) != 1: 

289 report_error("Failed licensee invocation") 

290 return 

291 result = json.loads(process_result.stdout) 

292 for license_data in result["licenses"]: 

293 spdx_id = license_data["spdx_id"] 

294 for license_id in (f"{spdx_id}-or-later", f"{spdx_id}-only", spdx_id): 

295 if license_id in LICENSE_URLS: 

296 self.initial["license"] = license_id 

297 messages.info( 

298 self.request, 

299 gettext( 

300 "Detected license as %s, please check whether it is correct." 

301 ) 

302 % license_id, 

303 ) 

304 return 

305 

306 def form_valid(self, form): 

307 if self.stage == "create": 

308 form.instance.manage_units = ( 

309 bool(form.instance.template) or form.instance.file_format == "tbx" 

310 ) 

311 if self.duplicate_existing_component and ( 

312 source_component := form.cleaned_data["source_component"] 

313 ): 

314 fields_to_duplicate = [ 

315 "agreement", 

316 "merge_style", 

317 "commit_message", 

318 "add_message", 

319 "delete_message", 

320 "merge_message", 

321 "addon_message", 

322 "pull_message", 

323 ] 

324 for field in fields_to_duplicate: 

325 setattr(form.instance, field, getattr(source_component, field)) 

326 

327 result = super().form_valid(form) 

328 self.object.post_create(self.request.user, origin=self.origin) 

329 return result 

330 if self.stage == "discover": 

331 # Move to create 

332 self.initial = form.cleaned_data 

333 self.stage = "create" 

334 self.request.method = "GET" 

335 self.warn_outdated(form) 

336 self.detect_license(form) 

337 return self.get(self.request) 

338 # Move to discover 

339 self.stage = "discover" 

340 self.request.method = "GET" 

341 self.initial = form.cleaned_data 

342 self.warn_outdated(form) 

343 return self.get(self.request) 

344 

345 def get_form(self, form_class=None, empty=False): 

346 self.empty_form = empty 

347 form = super().get_form(form_class) 

348 if "project" in form.fields: 

349 project_field = form.fields["project"] 

350 category_field = form.fields["category"] 

351 project_field.queryset = self.projects 

352 category_field.queryset = Category.objects.filter(project__in=self.projects) 

353 project_field.empty_label = None 

354 if self.selected_project: 

355 project_field.initial = self.selected_project 

356 with suppress(IndexError): 

357 form.fields["source_language"].initial = Component.objects.filter( 

358 project=self.selected_project 

359 )[0].source_language_id 

360 if self.selected_category: 

361 category_field.initial = self.selected_category 

362 self.empty_form = False 

363 if "source_component" in form.fields and self.duplicate_existing_component: 

364 self.components = Component.objects.filter( 

365 pk=self.duplicate_existing_component 

366 ) 

367 form.fields["source_component"].queryset = self.components 

368 form.initial["source_component"] = self.duplicate_existing_component 

369 return form 

370 

371 def get_context_data(self, **kwargs): 

372 kwargs = super().get_context_data(**kwargs) 

373 kwargs["projects"] = self.projects 

374 kwargs["stage"] = self.stage 

375 return kwargs 

376 

377 def fetch_params(self, request: AuthenticatedHttpRequest) -> None: 

378 try: 

379 self.selected_project = int( 

380 request.POST.get("project", request.GET.get("project", "")) 

381 ) 

382 except ValueError: 

383 self.selected_project = None 

384 try: 

385 self.selected_category = int( 

386 request.POST.get("category", request.GET.get("category", "")) 

387 ) 

388 except ValueError: 

389 self.selected_category = None 

390 if request.user.is_superuser: 

391 self.projects = Project.objects.order() 

392 elif self.has_billing: 

393 from weblate.billing.models import Billing 

394 

395 self.projects = request.user.managed_projects.filter( 

396 billing__in=Billing.objects.get_valid() 

397 ).order() 

398 else: 

399 self.projects = request.user.managed_projects 

400 self.initial = {} 

401 session_data = {} 

402 if SESSION_CREATE_KEY in request.GET and SESSION_CREATE_KEY in request.session: 

403 session_data = request.session[SESSION_CREATE_KEY] 

404 for field in self.basic_fields: 

405 if field in session_data: 

406 self.initial[field] = session_data[field] 

407 elif field in request.GET: 

408 self.initial[field] = request.GET[field] 

409 

410 try: 

411 self.duplicate_existing_component = int(request.GET.get("source_component")) 

412 except (ValueError, TypeError): 

413 self.duplicate_existing_component = None 

414 

415 def has_all_fields(self): 

416 session_data = {} 

417 if ( 

418 SESSION_CREATE_KEY in self.request.GET 

419 and SESSION_CREATE_KEY in self.request.session 

420 ): 

421 session_data = self.request.session[SESSION_CREATE_KEY] 

422 return self.stage == "init" and all( 

423 field in session_data or field in self.request.GET 

424 for field in self.basic_fields 

425 ) 

426 

427 def dispatch(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

428 if "new_base" in request.POST: 

429 self.stage = "create" 

430 elif "discovery" in request.POST: 

431 self.stage = "discover" 

432 else: 

433 self.stage = "init" 

434 

435 self.fetch_params(request) 

436 

437 # Proceed to post if all params are present 

438 if self.has_all_fields(): 

439 return self.post(request, *args, **kwargs) 

440 

441 return super().dispatch(request, *args, **kwargs) 

442 

443 

444class CreateFromZip(CreateComponent): 

445 form_class = ComponentZipCreateForm 

446 origin = "zip" 

447 

448 def form_valid(self, form): 

449 if self.stage != "init": 

450 return super().form_valid(form) 

451 

452 try: 

453 create_component_from_zip(form.cleaned_data) 

454 except (BadZipfile, OSError): 

455 form.add_error("zipfile", gettext("Could not parse uploaded ZIP file.")) 

456 return self.form_invalid(form) 

457 

458 # Move to discover phase 

459 self.stage = "discover" 

460 self.initial = form.cleaned_data 

461 self.initial["vcs"] = "local" 

462 self.initial["repo"] = "local:" 

463 self.initial["branch"] = "main" 

464 self.initial.pop("zipfile") 

465 self.request.method = "GET" 

466 return self.get(self.request) 

467 

468 

469class CreateFromDoc(CreateComponent): 

470 form_class = ComponentDocCreateForm 

471 origin = "document" 

472 

473 def form_valid(self, form): 

474 if self.stage != "init": 

475 return super().form_valid(form) 

476 

477 fake = create_component_from_doc( 

478 form.cleaned_data, 

479 form.cleaned_data.pop("docfile"), 

480 form.cleaned_data.pop("target_language", None), 

481 ) 

482 # Move to discover phase 

483 self.stage = "discover" 

484 self.initial = form.cleaned_data 

485 self.initial["vcs"] = "local" 

486 self.initial["repo"] = "local:" 

487 self.initial["branch"] = "main" 

488 self.initial["template"] = fake.template 

489 self.initial["filemask"] = fake.filemask 

490 

491 self.request.method = "GET" 

492 return self.get(self.request) 

493 

494 

495def component_branches(repo: str) -> set[str]: 

496 return set(Component.objects.filter(repo=repo).values_list("branch", flat=True)) 

497 

498 

499class CreateComponentSelection(CreateComponent): 

500 template_name = "trans/component_create.html" 

501 

502 components: ComponentQuerySet 

503 origin: str | None = None 

504 duplicate_existing_component: int | None = None 

505 

506 @cached_property 

507 def branch_data(self): 

508 result = {} 

509 existing_branches: dict[str, set[str]] = {} 

510 for component in self.components: 

511 repo = component.repo 

512 if repo not in existing_branches: 

513 existing_branches[repo] = component_branches(repo) 

514 branches = [ 

515 branch 

516 for branch in component.repository.list_remote_branches() 

517 if branch != component.branch and branch not in existing_branches[repo] 

518 ] 

519 if branches: 

520 result[component.pk] = branches 

521 return result 

522 

523 def fetch_params(self, request: AuthenticatedHttpRequest) -> None: 

524 super().fetch_params(request) 

525 self.components = ( 

526 Component.objects.filter_access(request.user) 

527 .with_repo() 

528 .prefetch() 

529 .filter(project__in=self.projects) 

530 .order_project() 

531 ) 

532 if self.selected_project: 

533 self.components = self.components.filter(project__pk=self.selected_project) 

534 self.origin = request.POST.get("origin") 

535 

536 try: 

537 self.duplicate_existing_component = int(request.GET.get("component")) 

538 except (ValueError, TypeError): 

539 self.duplicate_existing_component = None 

540 self.initial = {} 

541 if self.duplicate_existing_component: 

542 source_component = Component.objects.get( 

543 pk=self.duplicate_existing_component 

544 ) 

545 self.initial |= { 

546 "component": source_component, 

547 "is_glossary": source_component.is_glossary, 

548 } 

549 

550 def get_context_data(self, **kwargs): 

551 kwargs = super().get_context_data(**kwargs) 

552 kwargs["components"] = self.components 

553 kwargs["selected_project"] = self.selected_project 

554 kwargs["existing_form"] = self.get_form(ComponentSelectForm, empty=True) 

555 kwargs["branch_form"] = self.get_form(ComponentBranchForm, empty=True) 

556 kwargs["branch_data"] = json.dumps(self.branch_data) 

557 kwargs["full_form"] = self.get_form(ComponentInitCreateForm, empty=True) 

558 if "local" in VCS_REGISTRY: 

559 kwargs["zip_form"] = self.get_form(ComponentZipCreateForm, empty=True) 

560 kwargs["scratch_form"] = self.get_form( 

561 ComponentScratchCreateForm, empty=True 

562 ) 

563 kwargs["doc_form"] = self.get_form(ComponentDocCreateForm, empty=True) 

564 if self.origin == "branch": 

565 kwargs["branch_form"] = kwargs["form"] 

566 elif self.origin == "scratch": 

567 kwargs["scratch_form"] = kwargs["form"] 

568 else: 

569 kwargs["existing_form"] = kwargs["form"] 

570 return kwargs 

571 

572 def get_form(self, form_class=None, empty=False): 

573 form = super().get_form(form_class, empty=empty) 

574 if isinstance(form, ComponentBranchForm): 

575 form.fields["component"].queryset = Component.objects.filter( 

576 pk__in=self.branch_data.keys() 

577 ).order_project() 

578 form.branch_data = self.branch_data 

579 elif isinstance(form, ComponentSelectForm): 

580 if self.duplicate_existing_component: 

581 self.components |= Component.objects.filter_access( 

582 self.request.user 

583 ).filter(pk=self.duplicate_existing_component) 

584 form.fields["component"].queryset = self.components 

585 return form 

586 

587 def get_form_class(self): 

588 if self.origin == "branch": 

589 return ComponentBranchForm 

590 if self.origin == "scratch": 

591 return ComponentScratchCreateForm 

592 return ComponentSelectForm 

593 

594 def redirect_create(self, **kwargs): 

595 # Store params in session 

596 self.request.session[SESSION_CREATE_KEY] = kwargs 

597 

598 return redirect( 

599 "{}?{}".format( 

600 reverse("create-component-vcs"), urlencode({SESSION_CREATE_KEY: 1}) 

601 ) 

602 ) 

603 

604 def form_valid(self, form): 

605 if self.origin == "scratch": 

606 project = form.cleaned_data["project"] 

607 component = project.scratch_create_component(**form.cleaned_data) 

608 component.post_create(self.request.user, origin="scratch") 

609 return redirect( 

610 reverse("show_progress", kwargs={"path": component.get_url_path()}) 

611 ) 

612 component = form.cleaned_data["component"] 

613 if self.origin == "existing": 

614 return self.redirect_create( 

615 repo=component.repo or component.get_repo_link_url(), 

616 project=component.project.pk, 

617 category=component.category.pk if component.category else "", 

618 name=form.cleaned_data["name"], 

619 slug=form.cleaned_data["slug"], 

620 is_glossary=form.cleaned_data["is_glossary"], 

621 vcs=component.vcs, 

622 source_language=component.source_language.pk, 

623 license=component.license, 

624 source_component=component.pk, 

625 ) 

626 if self.origin == "branch": 

627 form.instance.save() 

628 form.instance.post_create(self.request.user, origin="branch") 

629 return redirect( 

630 reverse("show_progress", kwargs={"path": form.instance.get_url_path()}) 

631 ) 

632 

633 return redirect("create-component") 

634 

635 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override] 

636 if self.origin == "vcs": 

637 kwargs = {} 

638 if self.selected_project: 

639 kwargs["project"] = self.selected_project 

640 return self.redirect_create(**kwargs) 

641 return super().post(request, *args, **kwargs)