Coverage for app/venv/lib/python3.14/site-packages/weblate/wladmin/views.py: 23%

341 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 sys 

8from typing import TYPE_CHECKING, Any, Literal, cast 

9from urllib.parse import quote 

10 

11from django.conf import settings 

12from django.core.cache import cache 

13from django.core.checks import run_checks 

14from django.core.exceptions import PermissionDenied 

15from django.core.mail import send_mail 

16from django.db import transaction 

17from django.db.models import Count 

18from django.http import Http404, HttpResponse 

19from django.shortcuts import redirect, render 

20from django.urls import reverse 

21from django.utils import timezone 

22from django.utils.decorators import method_decorator 

23from django.utils.html import format_html 

24from django.utils.translation import gettext, gettext_lazy 

25from django.views.decorators.http import require_POST 

26from django.views.generic import ListView 

27from django.views.generic.edit import FormMixin 

28from requests.exceptions import HTTPError, Timeout 

29 

30from weblate.accounts.forms import AdminUserSearchForm, ContactForm 

31from weblate.accounts.views import UserList, get_initial_contact 

32from weblate.auth.decorators import management_access 

33from weblate.auth.forms import AdminInviteUserForm, SitewideTeamForm 

34from weblate.auth.models import ( 

35 Group, 

36 Invitation, 

37 User, 

38) 

39from weblate.configuration.models import Setting, SettingCategory 

40from weblate.configuration.views import CustomCSSView 

41from weblate.trans.forms import AnnouncementForm 

42from weblate.trans.models import Alert, Announcement, Component, Project 

43from weblate.trans.util import redirect_param 

44from weblate.utils import messages 

45from weblate.utils.cache import measure_cache_latency 

46from weblate.utils.celery import get_queue_stats 

47from weblate.utils.db import measure_database_latency 

48from weblate.utils.errors import report_error 

49from weblate.utils.stats import prefetch_stats 

50from weblate.utils.tasks import database_backup, settings_backup 

51from weblate.utils.version import GIT_LINK, GIT_REVISION 

52from weblate.utils.views import show_form_errors 

53from weblate.utils.zammad import ZammadError, submit_zammad_ticket 

54from weblate.vcs.ssh import ( 

55 add_host_key, 

56 can_generate_key, 

57 generate_ssh_key, 

58 get_all_key_data, 

59 get_host_keys, 

60 get_key_data_raw, 

61) 

62from weblate.wladmin.forms import ( 

63 ActivateForm, 

64 AppearanceForm, 

65 BackupForm, 

66 BackupSelectionForm, 

67 SSHAddForm, 

68 TestMailForm, 

69) 

70from weblate.wladmin.models import BackupService, ConfigurationError, SupportStatus 

71from weblate.wladmin.tasks import backup_service, support_status_update 

72 

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

74 from django.db.models import QuerySet 

75 from django.http.request import QueryDict 

76 from django_stubs_ext import StrOrPromise 

77 

78 from weblate.auth.models import ( 

79 AuthenticatedHttpRequest, 

80 GroupQuerySet, 

81 ) 

82 from weblate.vcs.ssh import ( 

83 KeyType, 

84 ) 

85 

86MENU: tuple[tuple[str, str, StrOrPromise], ...] = ( 

87 ("index", "manage", gettext_lazy("Weblate status")), 

88 ("backups", "manage-backups", gettext_lazy("Backups")), 

89 ("memory", "manage-memory", gettext_lazy("Translation memory")), 

90 ("performance", "manage-performance", gettext_lazy("Performance report")), 

91 ("ssh", "manage-ssh", gettext_lazy("SSH keys")), 

92 ("alerts", "manage-alerts", gettext_lazy("Alerts")), 

93 ("repos", "manage-repos", gettext_lazy("Repositories")), 

94 ("users", "manage-users", gettext_lazy("Users")), 

95 ("teams", "manage-teams", gettext_lazy("Teams")), 

96 ("appearance", "manage-appearance", gettext_lazy("Appearance")), 

97 ("tools", "manage-tools", gettext_lazy("Tools")), 

98 ("machinery", "manage-machinery", gettext_lazy("Automatic suggestions")), 

99 ("addons", "manage-addons", gettext_lazy("Add-ons")), 

100) 

101if "weblate.billing" in settings.INSTALLED_APPS: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 MENU += (("billing", "manage-billing", gettext_lazy("Billing")),) 

103 

104 

105@management_access 

106def manage(request: AuthenticatedHttpRequest) -> HttpResponse: 

107 support = SupportStatus.objects.get_current() 

108 initial = None 

109 activation_code = request.GET.get("activation") 

110 if activation_code and len(activation_code) < 400: 

111 initial = {"secret": activation_code} 

112 support_form = None 

113 if support.name != "community": 

114 support_form = ContactForm( 

115 request=request, 

116 hide_captcha=request.user.is_authenticated, 

117 initial=get_initial_contact(request), 

118 ) 

119 

120 return render( 

121 request, 

122 "manage/index.html", 

123 { 

124 "menu_items": MENU, 

125 "menu_page": "index", 

126 "support": support, 

127 "activate_form": ActivateForm(initial=initial), 

128 "support_form": support_form, 

129 "git_revision_link": GIT_LINK, 

130 "git_revision": GIT_REVISION, 

131 }, 

132 ) 

133 

134 

135@management_access 

136@require_POST 

137def support_form(request: AuthenticatedHttpRequest) -> HttpResponse: 

138 support = SupportStatus.objects.get_current() 

139 if support.name == "community": 

140 raise PermissionDenied 

141 form = ContactForm( 

142 request=request, 

143 hide_captcha=request.user.is_authenticated, 

144 data=request.POST, 

145 ) 

146 if not form.is_valid(): 

147 show_form_errors(request, form) 

148 else: 

149 try: 

150 url, number = submit_zammad_ticket( 

151 title=form.cleaned_data["subject"], 

152 body=form.cleaned_data["message"], 

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

154 email=form.cleaned_data["email"], 

155 zammad_url="https://care.weblate.org", 

156 ) 

157 messages.success( 

158 request, 

159 format_html( 

160 """{} <a href="{}">{}</a>""", 

161 gettext("Customer care ticket created:"), 

162 url, 

163 number, 

164 ), 

165 ) 

166 except ZammadError as error: 

167 messages.error(request, str(error)) 

168 return redirect("manage") 

169 

170 

171def send_test_mail(email: str) -> None: 

172 send_mail( 

173 subject=f"Test e-mail from Weblate on {timezone.now()}", 

174 message="It works.", 

175 recipient_list=[email], 

176 from_email=None, 

177 ) 

178 

179 

180@management_access 

181def tools(request: AuthenticatedHttpRequest) -> HttpResponse: 

182 email_form = TestMailForm(initial={"email": request.user.email}) 

183 announce_form = AnnouncementForm() 

184 

185 if request.method == "POST": 

186 if "email" in request.POST: 

187 email_form = TestMailForm(request.POST) 

188 if email_form.is_valid(): 

189 try: 

190 send_test_mail(**email_form.cleaned_data) 

191 messages.success(request, gettext("Test e-mail sent.")) 

192 except Exception as error: 

193 report_error("E-mail sending failed") 

194 messages.error( 

195 request, gettext("Could not send test e-mail: %s") % error 

196 ) 

197 return redirect("manage-tools") 

198 

199 if "sentry" in request.POST: 

200 report_error("Test message", message=True, level="info") 

201 return redirect("manage-tools") 

202 

203 if "message" in request.POST: 

204 announce_form = AnnouncementForm(request.POST) 

205 if announce_form.is_valid(): 

206 Announcement.objects.create( 

207 user=request.user, **announce_form.cleaned_data 

208 ) 

209 

210 return render( 

211 request, 

212 "manage/tools.html", 

213 { 

214 "menu_items": MENU, 

215 "menu_page": "tools", 

216 "email_form": email_form, 

217 "announce_form": announce_form, 

218 }, 

219 ) 

220 

221 

222@management_access 

223@require_POST 

224@transaction.atomic 

225def discovery(request: AuthenticatedHttpRequest) -> HttpResponse: 

226 support = SupportStatus.objects.get_current(for_update=True) 

227 

228 if not support.discoverable and settings.SITE_TITLE == "Weblate": 

229 messages.error( 

230 request, 

231 gettext( 

232 "Please change SITE_TITLE in settings to make your Weblate easy to recognize in discover." 

233 ), 

234 ) 

235 elif support.secret and support.enabled: 

236 support.discoverable = not support.discoverable 

237 support.save(update_fields=["discoverable"]) 

238 support_status_update.delay() 

239 

240 return redirect("manage") 

241 

242 

243@management_access 

244@require_POST 

245@transaction.atomic 

246def activate(request: AuthenticatedHttpRequest) -> HttpResponse: 

247 support: SupportStatus | None = None 

248 if "refresh" in request.POST: 

249 support = SupportStatus.objects.get_current(for_update=True) 

250 elif "unlink" in request.POST: 

251 SupportStatus.objects.select_for_update().filter(enabled=True).update( 

252 enabled=False 

253 ) 

254 else: 

255 form = ActivateForm(request.POST) 

256 if form.is_valid(): 

257 support = SupportStatus(**form.cleaned_data) 

258 else: 

259 show_form_errors(request, form) 

260 

261 if support is not None: 

262 try: 

263 support.refresh() 

264 except Timeout: 

265 report_error("Activation timeout") 

266 messages.error( 

267 request, 

268 gettext( 

269 "Could not activate your installation. Please try again later." 

270 ), 

271 ) 

272 except HTTPError as error: 

273 report_error("Activation error") 

274 if error.response.status_code == 404: 

275 messages.error( 

276 request, 

277 gettext( 

278 "Could not activate your installation. " 

279 "Please ensure your activation token is correct." 

280 ), 

281 ) 

282 else: 

283 messages.error( 

284 request, 

285 gettext( 

286 "Could not activate your installation. Please try again later." 

287 ), 

288 ) 

289 except Exception as error: 

290 report_error("Activation error") 

291 messages.error( 

292 request, 

293 gettext("Could not activate your installation: %s") % error, 

294 ) 

295 else: 

296 if not support.pk: 

297 SupportStatus.objects.select_for_update().filter(enabled=True).update( 

298 enabled=False 

299 ) 

300 support.save() 

301 messages.success(request, gettext("Activation completed.")) 

302 return redirect("manage") 

303 

304 

305@management_access 

306def repos(request: AuthenticatedHttpRequest) -> HttpResponse: 

307 """Provide report about Git status of all repos.""" 

308 return render( 

309 request, 

310 "manage/repos.html", 

311 { 

312 "components": Component.objects.order_project(), 

313 "menu_items": MENU, 

314 "menu_page": "repos", 

315 }, 

316 ) 

317 

318 

319@management_access 

320def backups(request: AuthenticatedHttpRequest) -> HttpResponse: 

321 form = BackupForm() 

322 if request.method == "POST": 

323 if "repository" in request.POST: 

324 form = BackupForm(request.POST) 

325 if form.is_valid(): 

326 form.save() 

327 return redirect("manage-backups") 

328 else: 

329 modelform = BackupSelectionForm(request.POST) 

330 if modelform.is_valid(): 

331 service = cast("BackupService", modelform.cleaned_data["service"]) 

332 if "remove" in request.POST: 

333 service.delete() 

334 return redirect("manage-backups") 

335 if "toggle" in request.POST: 

336 service.enabled = not service.enabled 

337 service.save() 

338 return redirect("manage-backups") 

339 if "trigger" in request.POST: 

340 settings_backup.delay() 

341 database_backup.delay() 

342 backup_service.delay(pk=service.pk) 

343 messages.success(request, gettext("Backup process triggered")) 

344 return redirect("manage-backups") 

345 else: 

346 show_form_errors(request, modelform) 

347 

348 context = { 

349 "services": BackupService.objects.all(), 

350 "menu_items": MENU, 

351 "menu_page": "backups", 

352 "form": form, 

353 "activate_form": ActivateForm(), 

354 } 

355 return render(request, "manage/backups.html", context) 

356 

357 

358def handle_dismiss(request: AuthenticatedHttpRequest) -> HttpResponse: 

359 try: 

360 error = ConfigurationError.objects.get(pk=int(request.POST["pk"])) 

361 if "ignore" in request.POST: 

362 error.ignored = True 

363 error.save(update_fields=["ignored"]) 

364 else: 

365 error.delete() 

366 except (ValueError, KeyError, ConfigurationError.DoesNotExist): 

367 messages.error(request, gettext("Could not dismiss the configuration error!")) 

368 return redirect("manage-performance") 

369 

370 

371@management_access 

372def performance(request: AuthenticatedHttpRequest) -> HttpResponse: 

373 """Show performance tuning tips.""" 

374 if request.method == "POST": 

375 return handle_dismiss(request) 

376 checks = run_checks(include_deployment_checks=True) 

377 

378 context = { 

379 "checks": [check for check in checks if not check.is_silenced()], 

380 "errors": ConfigurationError.objects.filter(ignored=False), 

381 "queues": get_queue_stats().items(), 

382 "menu_items": MENU, 

383 "menu_page": "performance", 

384 "web_encoding": [sys.getfilesystemencoding(), sys.getdefaultencoding()], 

385 "celery_encoding": cache.get("celery_encoding"), 

386 "celery_latency": cache.get("celery_latency"), 

387 "database_latency": measure_database_latency(), 

388 "cache_latency": measure_cache_latency(), 

389 } 

390 

391 return render(request, "manage/performance.html", context) 

392 

393 

394@management_access 

395def ssh_key(request: AuthenticatedHttpRequest) -> HttpResponse: 

396 key_type = cast("KeyType", request.GET.get("type", "rsa")) 

397 filename, data = get_key_data_raw(key_type=key_type, kind="private") 

398 if data is None: 

399 raise Http404 

400 

401 response = HttpResponse(data, content_type="text/plain") 

402 response["Content-Disposition"] = f"attachment; filename={filename}" 

403 response["Content-Length"] = len(data) 

404 return response 

405 

406 

407@management_access 

408def ssh(request: AuthenticatedHttpRequest) -> HttpResponse: 

409 """Show information and manipulate with SSH key.""" 

410 # Check whether we can generate SSH key 

411 can_generate = can_generate_key() 

412 

413 # Grab action type 

414 action = request.POST.get("action") 

415 

416 # Generate key if it does not exist yet 

417 if can_generate and action == "generate": 

418 key_type = cast("KeyType", request.POST.get("type", "rsa")) 

419 generate_ssh_key(request, key_type=key_type) 

420 return redirect("manage-ssh") 

421 

422 # Read key data if it exists 

423 keys = get_all_key_data() 

424 

425 # Add host key 

426 form = SSHAddForm() 

427 if action == "add-host": 

428 form = SSHAddForm(request.POST) 

429 if form.is_valid(): 

430 add_host_key(request, **form.cleaned_data) 

431 

432 context = { 

433 "public_ssh_keys": keys, 

434 "can_generate": can_generate, 

435 "missing_ssh_keys": [ 

436 keydata for keydata in keys.values() if keydata["key"] is None 

437 ], 

438 "host_keys": get_host_keys(), 

439 "menu_items": MENU, 

440 "menu_page": "ssh", 

441 "add_form": form, 

442 } 

443 

444 return render(request, "manage/ssh.html", context) 

445 

446 

447@management_access 

448def alerts(request: AuthenticatedHttpRequest) -> HttpResponse: 

449 """Show component alerts.""" 

450 context = { 

451 "alerts": Alert.objects.order_by( 

452 "name", "component__project__name", "component__name" 

453 ).select_related("component", "component__project"), 

454 "no_components": Project.objects.annotate(Count("component")).filter( 

455 component__count=0 

456 ), 

457 "menu_items": MENU, 

458 "menu_page": "alerts", 

459 } 

460 

461 return render(request, "manage/alerts.html", context) 

462 

463 

464@method_decorator(management_access, name="dispatch") 

465class AdminUserList(UserList): 

466 template_name = "manage/users.html" 

467 form_class = AdminUserSearchForm 

468 initial_query = "is:active" 

469 

470 def get_base_queryset(self) -> QuerySet[User]: 

471 return User.objects.all() 

472 

473 def post(self, request: AuthenticatedHttpRequest, **kwargs) -> HttpResponse: 

474 if "email" in request.POST: 

475 invite_form = AdminInviteUserForm(request.POST) 

476 if invite_form.is_valid(): 

477 invite_form.save(request) 

478 return redirect("manage-users") 

479 return super().get(request, **kwargs) 

480 

481 def get_context_data(self, **kwargs) -> dict[str, Any]: 

482 result = super().get_context_data(**kwargs) 

483 

484 if self.request.method == "POST": 

485 invite_form = AdminInviteUserForm(self.request.POST) 

486 invite_form.is_valid() 

487 else: 

488 invite_form = AdminInviteUserForm() 

489 

490 result["menu_items"] = MENU 

491 result["menu_page"] = "users" 

492 result["invite_form"] = invite_form 

493 result["search_form"] = self.form 

494 result["invitations"] = Invitation.objects.all().select_related("user") 

495 return result 

496 

497 

498@management_access 

499def users_check(request: AuthenticatedHttpRequest) -> HttpResponse: 

500 data: QueryDict = request.GET 

501 # Legacy links for care.weblate.org integration 

502 if "email" in data and "q" not in data: 

503 data = data.copy() 

504 data.setlist("q", data.getlist("email")) 

505 form = AdminUserSearchForm(data) 

506 

507 user_list = None 

508 if form.is_valid(): 

509 query = form.cleaned_data.get("q", "") 

510 parser = cast( 

511 "Literal['user', 'superuser']", getattr(form.fields["q"], "parser", "unit") 

512 ) 

513 user_list = User.objects.search(query, parser=parser)[:2] 

514 if user_list.count() != 1: 

515 return redirect_param( 

516 "manage-users", "?q={}".format(quote(form.cleaned_data["q"])) 

517 ) 

518 return redirect(user_list[0]) 

519 return redirect("manage-users") 

520 

521 

522@management_access 

523def appearance(request: AuthenticatedHttpRequest) -> HttpResponse: 

524 current = Setting.objects.get_settings_dict(SettingCategory.UI) 

525 form = AppearanceForm(initial=current) 

526 

527 if request.method == "POST": 

528 if "reset" in request.POST: 

529 Setting.objects.filter(category=SettingCategory.UI).delete() 

530 CustomCSSView.drop_cache() 

531 return redirect("manage-appearance") 

532 form = AppearanceForm(request.POST) 

533 if form.is_valid(): 

534 for name, value in form.cleaned_data.items(): 

535 if name not in current: 

536 # New setting previously not set 

537 Setting.objects.create( 

538 category=SettingCategory.UI, name=name, value=value 

539 ) 

540 else: 

541 if value != current[name]: 

542 # Update setting 

543 Setting.objects.filter( 

544 category=SettingCategory.UI, name=name 

545 ).update(value=value) 

546 current.pop(name) 

547 # Drop stale settings 

548 if current: 

549 Setting.objects.filter( 

550 category=SettingCategory.UI, name__in=current.keys() 

551 ).delete() 

552 

553 # Flush cache 

554 CustomCSSView.drop_cache() 

555 return redirect("manage-appearance") 

556 

557 return render( 

558 request, 

559 "manage/appearance.html", 

560 { 

561 "menu_items": MENU, 

562 "menu_page": "appearance", 

563 "form": form, 

564 }, 

565 ) 

566 

567 

568@management_access 

569def billing(request: AuthenticatedHttpRequest) -> HttpResponse: 

570 from weblate.billing.models import Billing 

571 

572 trial = [] 

573 pending = [] 

574 removal = [] 

575 free = [] 

576 paid = [] 

577 terminated = [] 

578 

579 # We will list all billings anyway, so fetch them at once 

580 billings = Billing.objects.prefetch().order_by("expiry", "removal", "id") 

581 projects = [] 

582 

583 for current in billings: 

584 if current.removal: 

585 removal.append(current) 

586 if current.state == Billing.STATE_TRIAL: 

587 if ( 

588 current.plan 

589 and current.plan.price == 0 

590 and current.payment.get("libre_request") 

591 ): 

592 pending.append(current) 

593 trial.append(current) 

594 elif current.state == Billing.STATE_TERMINATED: 

595 terminated.append(current) 

596 elif current.plan.price: 

597 paid.append(current) 

598 else: 

599 free.append(current) 

600 projects.extend(current.ordered_projects) 

601 prefetch_stats(projects) 

602 

603 return render( 

604 request, 

605 "manage/billing.html", 

606 { 

607 "menu_items": MENU, 

608 "menu_page": "billing", 

609 "trial": trial, 

610 "removal": removal, 

611 "free": free, 

612 "paid": paid, 

613 "terminated": terminated, 

614 "pending": pending, 

615 }, 

616 ) 

617 

618 

619@method_decorator(management_access, name="dispatch") 

620class TeamListView(FormMixin, ListView): 

621 template_name = "manage/teams.html" 

622 paginate_by = 50 

623 model = Group 

624 form_class = SitewideTeamForm 

625 

626 def get_queryset(self) -> QuerySet[Group]: 

627 return ( 

628 cast("GroupQuerySet", super().get_queryset()) 

629 .prefetch_related("languages", "projects", "components") 

630 .filter(defining_project=None) 

631 .annotate(Count("user"), Count("autogroup")) 

632 .order() 

633 ) 

634 

635 def get_context_data(self, **kwargs) -> dict[str, Any]: 

636 result = super().get_context_data(**kwargs) 

637 result["menu_items"] = MENU 

638 result["menu_page"] = "teams" 

639 return result 

640 

641 def get_success_url(self) -> str: 

642 return reverse("manage-teams") 

643 

644 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> HttpResponse: 

645 form = self.get_form() 

646 if form.is_valid(): 

647 return self.form_valid(form) 

648 return self.form_invalid(form) 

649 

650 def form_valid(self, form: SitewideTeamForm) -> HttpResponse: 

651 form.save() 

652 return super().form_valid(form)