Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/settings.py: 19%
258 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
4from __future__ import annotations
6import os
7from typing import TYPE_CHECKING
9from django.conf import settings
10from django.contrib.auth.decorators import login_required
11from django.contrib.staticfiles.storage import staticfiles_storage
12from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
13from django.http import Http404, JsonResponse
14from django.shortcuts import get_object_or_404, redirect
15from django.urls import reverse
16from django.utils.decorators import method_decorator
17from django.utils.translation import gettext
18from django.views.decorators.cache import never_cache
19from django.views.decorators.http import require_POST
20from django.views.generic import TemplateView, View
22from weblate.auth.models import AuthenticatedHttpRequest
23from weblate.trans.backups import PROJECTBACKUP_PREFIX, list_backups
24from weblate.trans.forms import (
25 AddCategoryForm,
26 AnnouncementForm,
27 BaseDeleteForm,
28 CategoryRenameForm,
29 ComponentRenameForm,
30 ComponentSettingsForm,
31 ProjectRenameForm,
32 ProjectSettingsForm,
33 WorkflowSettingForm,
34)
35from weblate.trans.models import (
36 Announcement,
37 Category,
38 Component,
39 Project,
40 Translation,
41 WorkflowSetting,
42)
43from weblate.trans.tasks import (
44 category_removal,
45 component_removal,
46 create_project_backup,
47 project_removal,
48 remove_project_backup_download,
49)
50from weblate.trans.util import redirect_param, render
51from weblate.utils import messages
52from weblate.utils.random import get_random_identifier
53from weblate.utils.stats import CategoryLanguage, ProjectLanguage
54from weblate.utils.views import parse_path, show_form_errors
56if TYPE_CHECKING: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 from weblate.auth.models import AuthenticatedHttpRequest
60@never_cache
61@login_required
62def change(request: AuthenticatedHttpRequest, path):
63 obj = parse_path(request, path, (Component, Project, ProjectLanguage))
64 if not request.user.has_perm(obj.settings_permission, obj):
65 raise Http404
67 if isinstance(obj, Component):
68 return change_component(request, obj)
69 if isinstance(obj, ProjectLanguage):
70 return change_project_language(request, obj)
71 return change_project(request, obj)
74def change_project(request: AuthenticatedHttpRequest, obj):
75 if request.method == "POST":
76 settings_form = ProjectSettingsForm(request, request.POST, instance=obj)
77 if settings_form.is_valid():
78 settings_form.save()
79 messages.success(request, gettext("Settings saved"))
80 return redirect("settings", path=obj.get_url_path())
81 messages.error(
82 request, gettext("Invalid settings. Please check the form for errors.")
83 )
84 else:
85 settings_form = ProjectSettingsForm(request, instance=obj)
87 return render(
88 request,
89 "project-settings.html",
90 {"object": obj, "form": settings_form},
91 )
94def change_project_language(request: AuthenticatedHttpRequest, obj):
95 try:
96 instance = obj.project.workflowsetting_set.get(language=obj.language)
97 except WorkflowSetting.DoesNotExist:
98 instance = None
100 if request.method == "POST":
101 settings_form = WorkflowSettingForm(
102 request.POST, instance=instance, project=obj.project
103 )
104 if settings_form.is_valid():
105 settings_form.instance.project = obj.project
106 settings_form.instance.language = obj.language
107 settings_form.save()
108 messages.success(request, gettext("Settings saved"))
109 return redirect("settings", path=obj.get_url_path())
110 messages.error(
111 request, gettext("Invalid settings. Please check the form for errors.")
112 )
113 else:
114 settings_form = WorkflowSettingForm(instance=instance, project=obj.project)
116 return render(
117 request,
118 "project-language-settings.html",
119 {"object": obj, "form": settings_form},
120 )
123def change_component(request: AuthenticatedHttpRequest, obj):
124 if not request.user.has_perm("component.edit", obj):
125 raise Http404
127 if request.method == "POST":
128 form = ComponentSettingsForm(request, request.POST, instance=obj)
129 if form.is_valid():
130 form.save()
131 messages.success(request, gettext("Settings saved"))
132 return redirect("settings", path=obj.get_url_path())
133 messages.error(
134 request, gettext("Invalid settings. Please check the form for errors.")
135 )
136 # Get a fresh copy of object, otherwise it will use unsaved changes
137 # from the failed form
138 obj = Component.objects.get(pk=obj.pk)
139 else:
140 form = ComponentSettingsForm(request, instance=obj)
142 if obj.repo_needs_merge():
143 messages.warning(
144 request,
145 gettext(
146 "The repository is outdated. You might not get "
147 "expected results until you update it."
148 ),
149 )
151 return render(
152 request,
153 "component-settings.html",
154 {"project": obj.project, "object": obj, "form": form},
155 )
158@never_cache
159@login_required
160@require_POST
161def dismiss_alert(request: AuthenticatedHttpRequest, path):
162 obj = parse_path(request, path, (Component,))
164 if not request.user.has_perm("component.edit", obj):
165 raise Http404
167 try:
168 alert = obj.alert_set.get(name=request.POST["dismiss"])
169 if alert.obj.dismissable:
170 alert.dismissed = True
171 alert.save(update_fields=["dismissed"])
172 except ObjectDoesNotExist:
173 pass
175 return redirect_param(obj, "#alerts")
178@login_required
179@require_POST
180def remove(request: AuthenticatedHttpRequest, path):
181 obj = parse_path(
182 request,
183 path,
184 (Translation, Component, Project, ProjectLanguage, CategoryLanguage, Category),
185 )
187 if not request.user.has_perm(obj.remove_permission, obj):
188 raise PermissionDenied
190 form = BaseDeleteForm(obj, request.POST)
191 if not form.is_valid():
192 show_form_errors(request, form)
193 return redirect_param(obj, "#organize")
195 parent: Component | Category | Project | str
196 if isinstance(obj, Translation):
197 parent = obj.component
198 obj.remove(request.user)
199 messages.success(request, gettext("The translation has been removed."))
200 elif isinstance(obj, Component):
201 parent = obj.category or obj.project
202 component_removal.delay(obj.pk, request.user.pk)
203 messages.success(
204 request, gettext("The translation component was scheduled for removal.")
205 )
206 elif isinstance(obj, Category):
207 parent = obj.category or obj.project
208 category_removal.delay(obj.pk, request.user.pk)
209 messages.success(request, gettext("The category was scheduled for removal."))
210 elif isinstance(obj, Project):
211 parent = reverse("home")
212 project_removal.delay(obj.pk, request.user.pk)
213 messages.success(request, gettext("The project was scheduled for removal."))
214 elif isinstance(obj, ProjectLanguage):
215 parent = obj.project
216 for translation in obj.translation_set:
217 translation.remove(request.user)
219 messages.success(request, gettext("A language in the project was removed."))
220 elif isinstance(obj, CategoryLanguage):
221 parent = obj.category
222 for translation in obj.translation_set:
223 translation.remove(request.user)
225 messages.success(request, gettext("A language in the category was removed."))
227 return redirect(parent)
230def perform_rename(form_cls, request: AuthenticatedHttpRequest, obj, perm: str):
231 if not request.user.has_perm(perm, obj):
232 raise PermissionDenied
234 # Make sure any non-rename related issues are resolved first
235 try:
236 obj.full_clean()
237 except ValidationError as err:
238 messages.error(
239 request,
240 gettext(
241 "Could not change %(obj)s due to an outstanding issue in its settings: %(error)s"
242 )
243 % {"obj": obj, "error": err},
244 )
245 return redirect_param(obj, "#organize")
247 form = form_cls(request, request.POST, instance=obj)
248 if not form.is_valid():
249 show_form_errors(request, form)
250 # Reload the object from DB to revert possible rejected change
251 obj.refresh_from_db()
252 return redirect_param(obj, "#organize")
254 # Invalidate old stats
255 old_stats = list(obj.stats.get_update_objects())
257 obj = form.save()
259 # Invalidate new stats
260 obj.stats.update_parents(extra_objects=old_stats)
262 return redirect(obj)
265@login_required
266@require_POST
267def rename(request: AuthenticatedHttpRequest, path):
268 obj = parse_path(request, path, (Component, Project, Category))
269 if isinstance(obj, Component):
270 return perform_rename(ComponentRenameForm, request, obj, "component.edit")
271 if isinstance(obj, Category):
272 return perform_rename(CategoryRenameForm, request, obj, "project.edit")
273 return perform_rename(ProjectRenameForm, request, obj, "project.edit")
276@login_required
277@require_POST
278def add_category(request: AuthenticatedHttpRequest, path):
279 obj = parse_path(request, path, (Project, Category))
280 if not request.user.has_perm("project.edit", obj) or not obj.can_add_category:
281 raise PermissionDenied
282 form = AddCategoryForm(request, obj, request.POST)
283 if not form.is_valid():
284 show_form_errors(request, form)
285 return redirect_param(obj, "#organize")
286 form.save()
287 return redirect(form.instance)
290@login_required
291@require_POST
292def announcement(request: AuthenticatedHttpRequest, path):
293 obj = parse_path(
294 request, path, (ProjectLanguage, Translation, Component, Project, Category)
295 )
297 if not request.user.has_perm("component.edit", obj) and not request.user.has_perm(
298 "announcement.add", obj
299 ):
300 raise PermissionDenied
302 form = AnnouncementForm(request.POST)
303 if not form.is_valid():
304 show_form_errors(request, form)
305 return redirect_param(obj, "#announcement")
307 # Scope specific attributes
308 scope = {}
309 if isinstance(obj, ProjectLanguage):
310 scope["project"] = obj.project
311 scope["language"] = obj.language
312 elif isinstance(obj, Category):
313 scope["project"] = obj.project
314 scope["category"] = obj
315 elif isinstance(obj, Translation):
316 scope["project"] = obj.component.project
317 scope["component"] = obj.component
318 scope["language"] = obj.language
319 elif isinstance(obj, Component):
320 scope["project"] = obj.project
321 scope["component"] = obj
322 elif isinstance(obj, Project):
323 scope["project"] = obj
325 Announcement.objects.create(
326 user=request.user,
327 **scope,
328 **form.cleaned_data,
329 )
331 return redirect(obj)
334@login_required
335@require_POST
336def announcement_delete(request: AuthenticatedHttpRequest, pk):
337 announcement = get_object_or_404(Announcement, pk=pk)
339 if request.user.has_perm("meta:announcement.delete", announcement):
340 announcement.delete()
342 return JsonResponse({"responseStatus": 200})
345@login_required
346def show_progress(request: AuthenticatedHttpRequest, path):
347 """
348 Show component update progress.
350 The target 'return to' URL can be either the component itself,
351 or one of its languages.
352 """
353 obj = parse_path(request, path, (Project, Category, Component, Translation))
354 if isinstance(obj, (Project, Category)):
355 return multi_progress(request, obj)
357 return component_progress(request, obj)
360def multi_progress(request: AuthenticatedHttpRequest, obj: Category | Project):
361 components = list(obj.all_repo_components)
362 return_target = obj
363 # There is no guide as in component view here
364 return_url = "show"
365 if not any(component.in_progress() for component in components):
366 return redirect(return_url, path=return_target.get_url_path())
367 return render(
368 request,
369 "multi-progress.html",
370 {
371 "object": obj,
372 "return_target": return_target,
373 "return_url": return_url,
374 "components": components,
375 },
376 )
379def component_progress(request: AuthenticatedHttpRequest, obj: Component | Translation):
380 component = obj if isinstance(obj, Component) else obj.component
381 return_target = obj
382 return_url = "show" if "info" in request.GET else "guide"
384 if not component.in_progress():
385 return redirect(return_url, path=return_target.get_url_path())
387 progress, log = component.get_progress()
389 return render(
390 request,
391 "component-progress.html",
392 {
393 "object": component,
394 "return_target": return_target,
395 "progress": progress,
396 "log": "\n".join(log),
397 "return_url": return_url,
398 },
399 )
402class BackupsMixin(View):
403 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override]
404 super().setup(request, *args, **kwargs)
405 self.obj = parse_path(request, [kwargs["project"]], (Project,))
406 if not request.user.has_perm("project.edit", self.obj):
407 raise PermissionDenied
410@method_decorator(login_required, name="dispatch")
411class BackupsView(BackupsMixin, TemplateView):
412 template_name = "trans/backups.html"
414 def post(self, request: AuthenticatedHttpRequest, *args, **kwargs):
415 create_project_backup.delay(self.obj.pk)
416 messages.success(
417 request, gettext("Backup scheduled. It will be available soon.")
418 )
419 return redirect("backups", project=self.obj.slug)
421 def get_context_data(self, **kwargs):
422 context = super().get_context_data(**kwargs)
423 context["keep_count"] = settings.PROJECT_BACKUP_KEEP_COUNT
424 context["keep_days"] = settings.PROJECT_BACKUP_KEEP_DAYS
425 context["object"] = context["project"] = self.obj
426 context["backups"] = list_backups(self.obj)
427 return context
430@method_decorator(login_required, name="dispatch")
431class BackupsDownloadView(BackupsMixin):
432 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs):
433 for backup in list_backups(self.obj):
434 if backup["name"] != kwargs["backup"]:
435 continue
436 # Generate random name for download
437 name = os.path.join(
438 PROJECTBACKUP_PREFIX, f"{self.obj.slug}-{get_random_identifier(32)}.zip"
439 )
440 # Copy to static files
441 with open(backup["path"], "rb") as handle:
442 name = staticfiles_storage.save(name, handle)
443 # Schedule removal
444 if not settings.CELERY_TASK_ALWAYS_EAGER:
445 remove_project_backup_download.apply_async(args=(name,), countdown=3600)
446 # Redirect to static
447 return redirect(staticfiles_storage.url(name))
448 raise Http404