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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7import csv 

8from typing import TYPE_CHECKING 

9 

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 

18 

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 

27 

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 

30 

31 from weblate.auth.models import AuthenticatedHttpRequest 

32 

33 

34class ChangesView(PathViewMixin, ListView): 

35 """Browser for changes.""" 

36 

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 ) 

47 

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() 

54 

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 

59 

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) 

92 

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 ) 

99 

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 ) 

110 

111 context["form"] = self.changes_form 

112 

113 # Compatibility with digest templates 

114 context["changes"] = context["object_list"] 

115 context["subject"] = "Digest preview" 

116 

117 return context 

118 

119 def setup(self, *args, **kwargs) -> None: 

120 super().setup(*args, **kwargs) 

121 self.changes_form = ChangesForm(data=self.request.GET) 

122 

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 

128 

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) 

156 

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) 

182 

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 

198 

199 result = Change.objects.last_changes(self.request.user, **params) 

200 

201 if filters: 

202 result = result.filter(**filters) 

203 if excludes: 

204 result = result.exclude(**excludes) 

205 

206 return result 

207 

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 

216 

217 

218class ChangesCSVView(ChangesView): 

219 """CSV renderer for changes view.""" 

220 

221 paginate_by = None 

222 

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

224 object_list = self.get_queryset()[:2000] 

225 

226 if not request.user.has_perm("change.download", self.path_object): 

227 raise PermissionDenied 

228 

229 # Always output in english 

230 activate("en") 

231 

232 response = HttpResponse(content_type="text/csv; charset=utf-8") 

233 response["Content-Disposition"] = "attachment; filename=changes.csv" 

234 

235 writer = csv.writer(response) 

236 

237 # Add header 

238 writer.writerow( 

239 ("timestamp", "action", "user", "url", "target", "edit_distance") 

240 ) 

241 

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 ) 

253 

254 return response 

255 

256 

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)) 

283 

284 return HttpResponse(content_type="text/html; charset=utf-8", content=content)