Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/changes.py: 14%
170 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 csv
8from typing import TYPE_CHECKING
10from django.contrib.auth.decorators import login_required
11from django.core.exceptions import PermissionDenied
12from django.http import HttpResponse
13from django.shortcuts import get_object_or_404, redirect
14from django.urls import reverse
15from django.urls.exceptions import NoReverseMatch
16from django.utils.translation import activate, gettext, pgettext
17from django.views.generic.list import ListView
19from weblate.accounts.notifications import NOTIFICATIONS_ACTIONS
20from weblate.lang.models import Language
21from weblate.trans.forms import ChangesForm
22from weblate.trans.models import Component, Project, Translation, Unit
23from weblate.trans.models.change import Change
24from weblate.utils.site import get_site_url
25from weblate.utils.stats import ProjectLanguage
26from weblate.utils.views import PathViewMixin
28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from django.db.models import Model
31 from weblate.auth.models import AuthenticatedHttpRequest
34class ChangesView(PathViewMixin, ListView):
35 """Browser for changes."""
37 paginate_by: int | None = 20
38 supported_path_types = (
39 None,
40 Project,
41 Component,
42 Translation,
43 Language,
44 ProjectLanguage,
45 Unit,
46 )
48 def get_template_names(self):
49 if digest := self.request.GET.get("digest"):
50 if digest in {"pending_suggestions", "todo_strings"}:
51 return [f"mail/{digest}.html"]
52 return ["mail/digest.html"]
53 return super().get_template_names()
55 def get_context_data(self, **kwargs):
56 """Create context for rendering page."""
57 context = super().get_context_data(**kwargs)
58 context["path_object"] = self.path_object
60 if isinstance(self.path_object, Unit):
61 context["title"] = (
62 pgettext(
63 "Changes of string in a translation", "Changes of string in %s"
64 )
65 % self.path_object
66 )
67 elif isinstance(self.path_object, Translation):
68 context["title"] = (
69 pgettext("Changes in translation", "Changes in %s") % self.path_object
70 )
71 elif isinstance(self.path_object, Component):
72 context["title"] = (
73 pgettext("Changes in component", "Changes in %s") % self.path_object
74 )
75 elif isinstance(self.path_object, Project):
76 context["title"] = (
77 pgettext("Changes in project", "Changes in %s") % self.path_object
78 )
79 elif isinstance(self.path_object, Language):
80 context["title"] = (
81 pgettext("Changes in language", "Changes in %s") % self.path_object
82 )
83 elif isinstance(self.path_object, ProjectLanguage):
84 context["title"] = (
85 pgettext("Changes in project", "Changes in %s") % self.path_object
86 )
87 elif self.path_object is None:
88 context["title"] = gettext("Changes")
89 else:
90 msg = f"Unsupported {self.path_object}"
91 raise TypeError(msg)
93 if self.path_object is None:
94 context["changes_rss"] = reverse("rss")
95 else:
96 context["changes_rss"] = reverse(
97 "rss", kwargs={"path": self.path_object.get_url_path()}
98 )
100 if self.changes_form.is_valid():
101 context["query_string"] = self.changes_form.urlencode()
102 context["search_items"] = self.changes_form.items()
103 if period := self.changes_form.cleaned_data.get("period"):
104 self.changes_form.fields["period"].widget.attrs["data-start-date"] = (
105 period["start_date"].strftime("%m/%d/%Y")
106 )
107 self.changes_form.fields["period"].widget.attrs["data-end-date"] = (
108 period["end_date"].strftime("%m/%d/%Y")
109 )
111 context["form"] = self.changes_form
113 # Compatibility with digest templates
114 context["changes"] = context["object_list"]
115 context["subject"] = "Digest preview"
117 return context
119 def setup(self, *args, **kwargs) -> None:
120 super().setup(*args, **kwargs)
121 self.changes_form = ChangesForm(data=self.request.GET)
123 def get_request_param(self, request: AuthenticatedHttpRequest, param: str) -> str:
124 value = request.GET.get(param)
125 if not value or "/" in value:
126 return "-"
127 return value
129 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
130 if self.path_object is None and self.request.GET:
131 # Handle GET params for filtering prior Weblate 5.0
132 path = None
133 string = self.request.GET.get("string")
134 if string and string.isdigit():
135 try:
136 # Check unit access here to avoid leaking project/component
137 unit = Unit.objects.filter_access(self.request.user).get(pk=string)
138 except Unit.DoesNotExist:
139 pass
140 else:
141 path = unit.get_url_path()
142 else:
143 path = [
144 self.get_request_param(request, "project"),
145 self.get_request_param(request, "component"),
146 self.get_request_param(request, "lang"),
147 ]
148 while path and path[-1] == "-":
149 path.pop()
150 if path:
151 try:
152 return redirect("changes", path=path)
153 except NoReverseMatch:
154 return redirect("changes")
155 return super().get(request, *args, **kwargs)
157 def get_queryset(self):
158 """Return list of changes to browse."""
159 filters = {}
160 excludes = {}
161 params: dict[str, Model]
162 if self.path_object is None:
163 params = {}
164 elif isinstance(self.path_object, Project):
165 params = {"project": self.path_object}
166 elif isinstance(self.path_object, Component):
167 params = {"component": self.path_object}
168 elif isinstance(self.path_object, Translation):
169 params = {"translation": self.path_object}
170 elif isinstance(self.path_object, Unit):
171 params = {"unit": self.path_object}
172 elif isinstance(self.path_object, Language):
173 params = {"language": self.path_object}
174 elif isinstance(self.path_object, ProjectLanguage):
175 params = {
176 "project": self.path_object.project,
177 "language": self.path_object.language,
178 }
179 else:
180 msg = f"Unsupported {self.path_object}"
181 raise TypeError(msg)
183 form = self.changes_form
184 if form.is_valid():
185 if action := form.cleaned_data.get("action"):
186 filters["action__in"] = action
187 if period := form.cleaned_data.get("period"):
188 # start_date and end_date are datetime objects
189 filters["timestamp__gte"] = period["start_date"]
190 filters["timestamp__lte"] = period["end_date"]
191 if user := form.cleaned_data.get("user"):
192 filters["user"] = user
193 # If we are filtering all the entries by one user, all
194 # other users are excluded by default, so we don't need
195 # to set this exclude.
196 elif exclude_user := form.cleaned_data.get("exclude_user"):
197 excludes["user"] = exclude_user
199 result = Change.objects.last_changes(self.request.user, **params)
201 if filters:
202 result = result.filter(**filters)
203 if excludes:
204 result = result.exclude(**excludes)
206 return result
208 def paginate_queryset(self, queryset, page_size):
209 if not self.changes_form.is_valid():
210 queryset = queryset.none()
211 paginator, page, queryset, is_paginated = super().paginate_queryset(
212 queryset, page_size
213 )
214 page = Change.objects.preload_list(page)
215 return paginator, page, queryset, is_paginated
218class ChangesCSVView(ChangesView):
219 """CSV renderer for changes view."""
221 paginate_by = None
223 def get(self, request: AuthenticatedHttpRequest, *args, **kwargs): # type: ignore[override]
224 object_list = self.get_queryset()[:2000]
226 if not request.user.has_perm("change.download", self.path_object):
227 raise PermissionDenied
229 # Always output in english
230 activate("en")
232 response = HttpResponse(content_type="text/csv; charset=utf-8")
233 response["Content-Disposition"] = "attachment; filename=changes.csv"
235 writer = csv.writer(response)
237 # Add header
238 writer.writerow(
239 ("timestamp", "action", "user", "url", "target", "edit_distance")
240 )
242 for change in object_list:
243 writer.writerow(
244 (
245 change.timestamp.isoformat(),
246 change.get_action_display(),
247 change.user.username if change.user else "",
248 get_site_url(change.get_absolute_url()),
249 change.target,
250 change.get_distance(),
251 )
252 )
254 return response
257@login_required
258def show_change(request: AuthenticatedHttpRequest, pk: int):
259 change = get_object_or_404(Change, pk=pk)
260 acl_obj = change.translation or change.component or change.project
261 if not request.user.has_perm("unit.edit", acl_obj):
262 raise PermissionDenied
263 others = request.GET.getlist("other")
264 changes = None
265 if others:
266 changes = Change.objects.filter(pk__in=[*others, change.pk])
267 for change in changes:
268 acl_obj = change.translation or change.component or change.project
269 if not request.user.has_perm("unit.edit", acl_obj):
270 raise PermissionDenied
271 if change.action not in NOTIFICATIONS_ACTIONS:
272 content = ""
273 else:
274 notifications = NOTIFICATIONS_ACTIONS[change.action]
275 notification = notifications[0](None)
276 context = notification.get_context(change if not others else None)
277 context["request"] = request
278 context["changes"] = changes
279 context["subject"] = notification.render_template(
280 "_subject.txt", context, digest=bool(others)
281 )
282 content = notification.render_template(".html", context, digest=bool(others))
284 return HttpResponse(content_type="text/html; charset=utf-8", content=content)