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

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

2# 

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

4from __future__ import annotations 

5 

6import os 

7from typing import TYPE_CHECKING 

8 

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 

21 

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 

55 

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 

58 

59 

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 

66 

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) 

72 

73 

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) 

86 

87 return render( 

88 request, 

89 "project-settings.html", 

90 {"object": obj, "form": settings_form}, 

91 ) 

92 

93 

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 

99 

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) 

115 

116 return render( 

117 request, 

118 "project-language-settings.html", 

119 {"object": obj, "form": settings_form}, 

120 ) 

121 

122 

123def change_component(request: AuthenticatedHttpRequest, obj): 

124 if not request.user.has_perm("component.edit", obj): 

125 raise Http404 

126 

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) 

141 

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 ) 

150 

151 return render( 

152 request, 

153 "component-settings.html", 

154 {"project": obj.project, "object": obj, "form": form}, 

155 ) 

156 

157 

158@never_cache 

159@login_required 

160@require_POST 

161def dismiss_alert(request: AuthenticatedHttpRequest, path): 

162 obj = parse_path(request, path, (Component,)) 

163 

164 if not request.user.has_perm("component.edit", obj): 

165 raise Http404 

166 

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 

174 

175 return redirect_param(obj, "#alerts") 

176 

177 

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 ) 

186 

187 if not request.user.has_perm(obj.remove_permission, obj): 

188 raise PermissionDenied 

189 

190 form = BaseDeleteForm(obj, request.POST) 

191 if not form.is_valid(): 

192 show_form_errors(request, form) 

193 return redirect_param(obj, "#organize") 

194 

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) 

218 

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) 

224 

225 messages.success(request, gettext("A language in the category was removed.")) 

226 

227 return redirect(parent) 

228 

229 

230def perform_rename(form_cls, request: AuthenticatedHttpRequest, obj, perm: str): 

231 if not request.user.has_perm(perm, obj): 

232 raise PermissionDenied 

233 

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

246 

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

253 

254 # Invalidate old stats 

255 old_stats = list(obj.stats.get_update_objects()) 

256 

257 obj = form.save() 

258 

259 # Invalidate new stats 

260 obj.stats.update_parents(extra_objects=old_stats) 

261 

262 return redirect(obj) 

263 

264 

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

274 

275 

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) 

288 

289 

290@login_required 

291@require_POST 

292def announcement(request: AuthenticatedHttpRequest, path): 

293 obj = parse_path( 

294 request, path, (ProjectLanguage, Translation, Component, Project, Category) 

295 ) 

296 

297 if not request.user.has_perm("component.edit", obj) and not request.user.has_perm( 

298 "announcement.add", obj 

299 ): 

300 raise PermissionDenied 

301 

302 form = AnnouncementForm(request.POST) 

303 if not form.is_valid(): 

304 show_form_errors(request, form) 

305 return redirect_param(obj, "#announcement") 

306 

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 

324 

325 Announcement.objects.create( 

326 user=request.user, 

327 **scope, 

328 **form.cleaned_data, 

329 ) 

330 

331 return redirect(obj) 

332 

333 

334@login_required 

335@require_POST 

336def announcement_delete(request: AuthenticatedHttpRequest, pk): 

337 announcement = get_object_or_404(Announcement, pk=pk) 

338 

339 if request.user.has_perm("meta:announcement.delete", announcement): 

340 announcement.delete() 

341 

342 return JsonResponse({"responseStatus": 200}) 

343 

344 

345@login_required 

346def show_progress(request: AuthenticatedHttpRequest, path): 

347 """ 

348 Show component update progress. 

349 

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) 

356 

357 return component_progress(request, obj) 

358 

359 

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 ) 

377 

378 

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" 

383 

384 if not component.in_progress(): 

385 return redirect(return_url, path=return_target.get_url_path()) 

386 

387 progress, log = component.get_progress() 

388 

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 ) 

400 

401 

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 

408 

409 

410@method_decorator(login_required, name="dispatch") 

411class BackupsView(BackupsMixin, TemplateView): 

412 template_name = "trans/backups.html" 

413 

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) 

420 

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 

428 

429 

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