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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import sys
8from typing import TYPE_CHECKING, Any, Literal, cast
9from urllib.parse import quote
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
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
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
78 from weblate.auth.models import (
79 AuthenticatedHttpRequest,
80 GroupQuerySet,
81 )
82 from weblate.vcs.ssh import (
83 KeyType,
84 )
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")),)
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 )
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 )
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")
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 )
180@management_access
181def tools(request: AuthenticatedHttpRequest) -> HttpResponse:
182 email_form = TestMailForm(initial={"email": request.user.email})
183 announce_form = AnnouncementForm()
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")
199 if "sentry" in request.POST:
200 report_error("Test message", message=True, level="info")
201 return redirect("manage-tools")
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 )
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 )
222@management_access
223@require_POST
224@transaction.atomic
225def discovery(request: AuthenticatedHttpRequest) -> HttpResponse:
226 support = SupportStatus.objects.get_current(for_update=True)
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()
240 return redirect("manage")
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)
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")
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 )
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)
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)
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")
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)
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 }
391 return render(request, "manage/performance.html", context)
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
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
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()
413 # Grab action type
414 action = request.POST.get("action")
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")
422 # Read key data if it exists
423 keys = get_all_key_data()
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)
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 }
444 return render(request, "manage/ssh.html", context)
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 }
461 return render(request, "manage/alerts.html", context)
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"
470 def get_base_queryset(self) -> QuerySet[User]:
471 return User.objects.all()
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)
481 def get_context_data(self, **kwargs) -> dict[str, Any]:
482 result = super().get_context_data(**kwargs)
484 if self.request.method == "POST":
485 invite_form = AdminInviteUserForm(self.request.POST)
486 invite_form.is_valid()
487 else:
488 invite_form = AdminInviteUserForm()
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
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)
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")
522@management_access
523def appearance(request: AuthenticatedHttpRequest) -> HttpResponse:
524 current = Setting.objects.get_settings_dict(SettingCategory.UI)
525 form = AppearanceForm(initial=current)
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()
553 # Flush cache
554 CustomCSSView.drop_cache()
555 return redirect("manage-appearance")
557 return render(
558 request,
559 "manage/appearance.html",
560 {
561 "menu_items": MENU,
562 "menu_page": "appearance",
563 "form": form,
564 },
565 )
568@management_access
569def billing(request: AuthenticatedHttpRequest) -> HttpResponse:
570 from weblate.billing.models import Billing
572 trial = []
573 pending = []
574 removal = []
575 free = []
576 paid = []
577 terminated = []
579 # We will list all billings anyway, so fetch them at once
580 billings = Billing.objects.prefetch().order_by("expiry", "removal", "id")
581 projects = []
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)
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 )
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
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 )
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
641 def get_success_url(self) -> str:
642 return reverse("manage-teams")
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)
650 def form_valid(self, form: SitewideTeamForm) -> HttpResponse:
651 form.save()
652 return super().form_valid(form)