Coverage for app/venv/lib/python3.14/site-packages/weblate/machinery/views.py: 31%

290 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 

6from itertools import chain 

7from typing import TYPE_CHECKING, cast 

8 

9from django.core.exceptions import PermissionDenied 

10from django.http import ( 

11 Http404, 

12 HttpResponseBadRequest, 

13 HttpResponseRedirect, 

14 JsonResponse, 

15) 

16from django.shortcuts import get_object_or_404 

17from django.urls import reverse 

18from django.utils.functional import cached_property 

19from django.utils.translation import gettext 

20from django.views.decorators.http import require_POST 

21from django.views.generic import TemplateView 

22from django.views.generic.edit import FormView 

23 

24from weblate.configuration.models import Setting, SettingCategory 

25from weblate.machinery.base import ( 

26 MachineTranslationError, 

27) 

28from weblate.machinery.models import MACHINERY 

29from weblate.trans.models import Project, Unit 

30from weblate.trans.templatetags.translations import format_language_string 

31from weblate.utils.diff import Differ 

32from weblate.utils.errors import report_error 

33from weblate.utils.views import parse_path 

34from weblate.wladmin.views import MENU as MANAGE_MENU 

35 

36if TYPE_CHECKING: 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true

37 from weblate.auth.models import AuthenticatedHttpRequest 

38 from weblate.machinery.base import ( 

39 BatchMachineTranslation, 

40 SettingsDict, 

41 ) 

42 from weblate.trans.models import Translation 

43 

44 

45class MachineryMixin: 

46 @cached_property 

47 def global_settings_dict(self) -> dict[str, SettingsDict]: 

48 return cast( 

49 "dict[str, SettingsDict]", 

50 Setting.objects.get_settings_dict(SettingCategory.MT), 

51 ) 

52 

53 

54class MachineryProjectMixin(MachineryMixin): 

55 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None: 

56 self.project = parse_path(request, [kwargs["project"]], (Project,)) 

57 

58 @cached_property 

59 def settings_dict(self) -> dict[str, SettingsDict]: 

60 return self.project.machinery_settings 

61 

62 

63class MachineryGlobalMixin(MachineryMixin): 

64 @cached_property 

65 def settings_dict(self) -> dict[str, SettingsDict]: 

66 return self.global_settings_dict 

67 

68 

69class DeprecatedMachinery: 

70 is_available = False 

71 settings_form: None = None 

72 

73 def __init__(self, identifier: str) -> None: 

74 self.identifier = self.name = identifier 

75 

76 def get_identifier(self) -> str: 

77 return self.identifier 

78 

79 def get_doc_anchor(self) -> str: 

80 return f"mt-{self.get_identifier()}" 

81 

82 

83class MachineryConfiguration: 

84 def __init__( 

85 self, 

86 machinery, 

87 configuration: SettingsDict | None, 

88 sitewide: bool = False, 

89 project=None, 

90 is_configured: bool = True, 

91 ) -> None: 

92 self.machinery = machinery 

93 self.configuration = configuration 

94 self.sitewide = sitewide 

95 self.project = project 

96 self.is_configured = is_configured 

97 

98 @property 

99 def is_enabled(self): 

100 return self.configuration is not None 

101 

102 @property 

103 def is_available(self): 

104 return self.machinery.is_available 

105 

106 @property 

107 def id(self): 

108 return self.machinery.get_identifier() 

109 

110 @property 

111 def name(self): 

112 return self.machinery.name 

113 

114 @property 

115 def doc_anchor(self): 

116 return self.machinery.get_doc_anchor() 

117 

118 @property 

119 def has_settings(self): 

120 return self.machinery.settings_form is not None 

121 

122 def get_absolute_url(self) -> str: 

123 kwargs = {"machinery": self.id} 

124 if self.project: 

125 kwargs["project"] = self.project.slug 

126 return reverse("machinery-edit", kwargs=kwargs) 

127 

128 

129class ListMachineryView(TemplateView): 

130 template_name = "machinery/list.html" 

131 

132 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

133 super().setup(request, *args, **kwargs) 

134 self.project = None 

135 self.post_setup(request, kwargs) 

136 self.configured_services = sorted( 

137 self.get_configured_services(), key=lambda obj: obj.name 

138 ) 

139 installed_services = {obj.id for obj in self.configured_services} 

140 self.available_services = sorted( 

141 ( 

142 MachineryConfiguration( 

143 obj, None, project=self.project, is_configured=False 

144 ) 

145 for name, obj in MACHINERY.items() 

146 if name not in installed_services 

147 ), 

148 key=lambda obj: obj.name, 

149 ) 

150 

151 @cached_property 

152 def settings_dict(self) -> dict[str, SettingsDict]: 

153 raise NotImplementedError 

154 

155 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None: 

156 return 

157 

158 def get_configured_services(self): 

159 for service, configuration in self.settings_dict.items(): 

160 try: 

161 machinery = MACHINERY[service] 

162 except KeyError: 

163 yield MachineryConfiguration( 

164 DeprecatedMachinery(service), configuration, project=self.project 

165 ) 

166 else: 

167 yield MachineryConfiguration( 

168 machinery, configuration, project=self.project 

169 ) 

170 

171 def get_context_data(self, **kwargs): 

172 result = super().get_context_data(**kwargs) 

173 result["configured_services"] = self.configured_services 

174 result["available_services"] = self.available_services 

175 if not self.project: 

176 result["menu_items"] = MANAGE_MENU 

177 result["menu_page"] = "machinery" 

178 return result 

179 

180 

181class ListMachineryGlobalView(MachineryGlobalMixin, ListMachineryView): 

182 pass 

183 

184 

185class ListMachineryProjectView(MachineryProjectMixin, ListMachineryView): 

186 def get_configured_services(self): 

187 yield from super().get_configured_services() 

188 project_settings = set(self.settings_dict) 

189 for service, configuration in self.global_settings_dict.items(): 

190 if service in project_settings: 

191 continue 

192 try: 

193 machinery = MACHINERY[service] 

194 except KeyError: 

195 yield MachineryConfiguration( 

196 DeprecatedMachinery(service), configuration, project=self.project 

197 ) 

198 else: 

199 yield MachineryConfiguration( 

200 machinery, configuration, sitewide=True, project=self.project 

201 ) 

202 

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

204 if not request.user.has_perm("project.edit", self.project): 

205 raise PermissionDenied 

206 return super().dispatch(request, *args, **kwargs) 

207 

208 def get_context_data(self, **kwargs): 

209 result = super().get_context_data(**kwargs) 

210 result["project"] = self.project 

211 return result 

212 

213 

214class EditMachineryView(FormView): 

215 template_name = "machinery/edit.html" 

216 

217 machinery: DeprecatedMachinery | BatchMachineTranslation 

218 

219 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

220 super().setup(request, *args, **kwargs) 

221 self.machinery_id = kwargs["machinery"] 

222 try: 

223 self.machinery = MACHINERY[self.machinery_id] 

224 except KeyError: 

225 self.machinery = DeprecatedMachinery(self.machinery_id) 

226 self.project = None 

227 self.post_setup(request, kwargs) 

228 

229 def get_form_class(self): 

230 return self.machinery.settings_form 

231 

232 def get_form_kwargs(self): 

233 result = super().get_form_kwargs() 

234 result["machinery"] = self.machinery 

235 return result 

236 

237 @cached_property 

238 def settings_dict(self) -> dict[str, SettingsDict]: 

239 raise NotImplementedError 

240 

241 def post_setup(self, request: AuthenticatedHttpRequest, kwargs) -> None: 

242 return 

243 

244 def get_initial(self): 

245 return self.settings_dict.get(self.machinery_id) or {} 

246 

247 def get_context_data(self, **kwargs): 

248 result = super().get_context_data(**kwargs) 

249 result["machinery_id"] = self.machinery.get_identifier() 

250 result["machinery_name"] = self.machinery.name 

251 result["machinery_doc_anchor"] = self.machinery.get_doc_anchor() 

252 return result 

253 

254 def install_service(self) -> None: 

255 self.save_settings({}) 

256 

257 def form_valid(self, form): 

258 self.save_settings(form.cleaned_data) 

259 return super().form_valid(form) 

260 

261 def save_settings(self, data) -> None: 

262 raise NotImplementedError 

263 

264 def delete_service(self) -> None: 

265 raise NotImplementedError 

266 

267 def enable_service(self) -> None: 

268 return 

269 

270 def get_success_url(self): 

271 if self.project: 

272 return reverse("machinery-list", kwargs={"project": self.project.slug}) 

273 return reverse("manage-machinery") 

274 

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

276 if "delete" in request.POST: 

277 self.delete_service() 

278 return HttpResponseRedirect(self.get_success_url()) 

279 

280 if not self.machinery.is_available: 

281 msg = "Invalid service specified" 

282 raise Http404(msg) 

283 

284 if "enable" in request.POST: 

285 self.delete_service() 

286 return HttpResponseRedirect(self.get_success_url()) 

287 

288 if "install" in request.POST: 

289 if self.machinery.settings_form is not None: 

290 return HttpResponseRedirect(request.path) 

291 self.install_service() 

292 return HttpResponseRedirect(self.get_success_url()) 

293 return super().post(request, *args, **kwargs) 

294 

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

296 if not self.machinery.is_available: 

297 msg = "Invalid service specified" 

298 raise Http404(msg) 

299 return super().get(request, *args, **kwargs) 

300 

301 

302class EditMachineryGlobalView(MachineryGlobalMixin, EditMachineryView): 

303 def save_settings(self, data: SettingsDict) -> None: 

304 setting, created = Setting.objects.get_or_create( 

305 category=SettingCategory.MT, 

306 name=self.machinery_id, 

307 defaults={"value": data}, 

308 ) 

309 if not created and setting.value != data: 

310 setting.value = data 

311 setting.save() 

312 

313 def delete_service(self) -> None: 

314 Setting.objects.filter( 

315 category=SettingCategory.MT, 

316 name=self.machinery_id, 

317 ).delete() 

318 

319 def form_valid(self, form): 

320 self.save_settings(form.cleaned_data) 

321 return super().form_valid(form) 

322 

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

324 if not request.user.has_perm("machinery.edit"): 

325 raise PermissionDenied 

326 return super().dispatch(request, *args, **kwargs) 

327 

328 

329class EditMachineryProjectView(MachineryProjectMixin, EditMachineryView): 

330 def save_settings(self, data: SettingsDict | None) -> None: 

331 self.project.machinery_settings[self.machinery_id] = data 

332 self.project.save(update_fields=["machinery_settings"]) 

333 

334 def delete_service(self) -> None: 

335 if self.machinery_id in self.project.machinery_settings: 

336 self.enable_service() 

337 else: 

338 self.save_settings(None) 

339 

340 def enable_service(self) -> None: 

341 del self.project.machinery_settings[self.machinery_id] 

342 self.project.save(update_fields=["machinery_settings"]) 

343 

344 def setup(self, request: AuthenticatedHttpRequest, *args, **kwargs) -> None: # type: ignore[override] 

345 super().setup(request, *args, **kwargs) 

346 self.project = parse_path(request, [kwargs["project"]], (Project,)) 

347 

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

349 if not request.user.has_perm("project.edit", self.project): 

350 raise PermissionDenied 

351 return super().dispatch(request, *args, **kwargs) 

352 

353 def get_context_data(self, **kwargs): 

354 result = super().get_context_data(**kwargs) 

355 result["project"] = self.project 

356 return result 

357 

358 

359def format_string_helper( 

360 source: str, translation: Translation, diff: str | None = None 

361): 

362 return format_language_string(source, translation, diff=diff)["items"][0]["content"] 

363 

364 

365def format_results_helper( 

366 item: dict, 

367 targets: list[str], 

368 plural_form: int, 

369 translation: Translation, 

370 source_translation: Translation, 

371) -> None: 

372 item["plural_form"] = plural_form 

373 item["diff"] = format_string_helper(item["text"], translation, targets[plural_form]) 

374 item["source_diff"] = format_string_helper( 

375 item["source"], source_translation, item["original_source"] 

376 ) 

377 item["html"] = format_string_helper(item["text"], translation) 

378 

379 

380def handle_machinery(request: AuthenticatedHttpRequest, service, unit, search=None): 

381 translation = unit.translation 

382 component = translation.component 

383 source_translation = component.source_translation 

384 if not request.user.has_perm("machinery.view", translation): 

385 raise PermissionDenied 

386 

387 try: 

388 translation_service_class = MACHINERY[service] 

389 except KeyError as error: 

390 msg = "Invalid service specified" 

391 raise Http404(msg) from error 

392 

393 # Error response 

394 response = { 

395 "responseStatus": 500, 

396 "responseDetails": "", 

397 "translations": [], 

398 "lang": translation.language.code, 

399 "dir": translation.language.direction, 

400 "service": translation_service_class.name, 

401 } 

402 

403 machinery_settings = component.project.get_machinery_settings() 

404 Differ() 

405 targets = unit.get_target_plurals() 

406 

407 try: 

408 translation_service = translation_service_class(machinery_settings[service]) 

409 except KeyError: 

410 response["responseDetails"] = gettext("Service is currently not available.") 

411 else: 

412 try: 

413 if search: 

414 translations = translation_service.search(unit, search, request.user) 

415 for item in translations: 

416 format_results_helper( 

417 item, targets, 0, translation, source_translation 

418 ) 

419 else: 

420 translations = translation_service.translate(unit, request.user) 

421 for plural_form, possible_translations in enumerate(translations): 

422 for item in possible_translations: 

423 format_results_helper( 

424 item, targets, plural_form, translation, source_translation 

425 ) 

426 translations = list(chain.from_iterable(translations)) 

427 response["translations"] = translations 

428 response["responseStatus"] = 200 

429 except MachineTranslationError as exc: 

430 response["responseDetails"] = str(exc) 

431 except Exception as error: 

432 report_error("Machinery failed", project=component.project) 

433 response["responseDetails"] = f"{error.__class__.__name__}: {error}" 

434 

435 if response["responseStatus"] != 200: 

436 translation.log_info("machinery failed: %s", response["responseDetails"]) 

437 

438 return JsonResponse(data=response) 

439 

440 

441@require_POST 

442def translate(request: AuthenticatedHttpRequest, unit_id: int, service: str): 

443 """AJAX handler for translating.""" 

444 unit = get_object_or_404(Unit, pk=unit_id) 

445 return handle_machinery(request, service, unit) 

446 

447 

448@require_POST 

449def memory(request: AuthenticatedHttpRequest, unit_id: int): 

450 """AJAX handler for translation memory.""" 

451 unit = get_object_or_404(Unit, pk=unit_id) 

452 query = request.POST.get("q") 

453 if not query: 

454 return HttpResponseBadRequest("Missing search string") 

455 

456 return handle_machinery(request, "weblate-translation-memory", unit, search=query)