Coverage for baibu/localization/views.py: 100%

201 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-10-06 13:07 +0000

1"""Pages for translators: languages, the catalogue editor, publishing and history.""" 

2 

3import logging 

4 

5from django.contrib import messages 

6from django.contrib.auth.decorators import login_required 

7from django.contrib.auth.decorators import permission_required 

8from django.core.paginator import Paginator 

9from django.http import Http404 

10from django.http import HttpRequest 

11from django.http import HttpResponse 

12from django.shortcuts import get_object_or_404 

13from django.shortcuts import redirect 

14from django.shortcuts import render 

15from django.utils.translation import gettext as _ 

16from django.utils.translation import ngettext 

17from django.utils.translation import to_locale 

18from django.views.decorators.cache import cache_control 

19from django.views.decorators.http import require_GET 

20from django.views.decorators.http import require_http_methods 

21from django.views.decorators.http import require_POST 

22 

23from . import catalogues 

24from . import services 

25from .extraction import ExtractionError 

26from .forms import PluralFormsForm 

27from .forms import UploadForm 

28from .models import TranslationCatalogue 

29from .models import TranslationPublication 

30from .permissions import PERMISSION 

31 

32logger = logging.getLogger(__name__) 

33 

34STATES = ("untranslated", "fuzzy", "translated", "all") 

35PAGE_SIZE = 25 

36MAX_QUERY_LENGTH = 200 

37 

38 

39def translator_required(view): 

40 view = permission_required(PERMISSION, raise_exception=True)(view) 

41 view = login_required(view) 

42 return cache_control(private=True, no_store=True)(view) 

43 

44 

45def _language_name(language_code: str) -> str: 

46 names = dict(services.translatable_languages()) 

47 if language_code not in names: 

48 raise Http404 

49 return names[language_code] 

50 

51 

52# Overview ------------------------------------------------------------------------ 

53 

54 

55@translator_required 

56@require_GET 

57def index(request: HttpRequest) -> HttpResponse: 

58 existing = {catalogue.language_code: catalogue for catalogue in TranslationCatalogue.objects.all()} 

59 languages = [] 

60 for code, name in services.translatable_languages(): 

61 catalogue = existing.get(code) 

62 publication = services.latest_publication(code) 

63 row = {"code": code, "name": name, "publication": publication, "stats": None, "unpublished": False} 

64 if catalogue is not None: 

65 try: 

66 draft = services.read_draft(catalogue) 

67 except (FileNotFoundError, catalogues.CatalogueError): 

68 logger.exception("Could not read the %s draft", code) 

69 else: 

70 row["stats"] = catalogues.stats(draft) 

71 row["unpublished"] = services.has_unpublished_changes(draft, publication) 

72 languages.append(row) 

73 return render( 

74 request, 

75 "localization/index.html", 

76 {"languages": languages, "source": services.latest_source()}, 

77 ) 

78 

79 

80@translator_required 

81@require_POST 

82def update_sources(request: HttpRequest) -> HttpResponse: 

83 try: 

84 result = services.update_sources(user=request.user) 

85 except ExtractionError: 

86 logger.exception("Could not extract source strings") 

87 messages.error(request, _("The source strings could not be extracted. The error has been logged.")) 

88 return redirect("localization:index") 

89 if result.created: 

90 messages.success( 

91 request, 

92 ngettext( 

93 "Source strings updated: %(count)d message.", 

94 "Source strings updated: %(count)d messages.", 

95 result.source.entry_count, 

96 ) 

97 % {"count": result.source.entry_count}, 

98 ) 

99 else: 

100 messages.success(request, _("The source strings have not changed.")) 

101 return redirect("localization:index") 

102 

103 

104# Editing ------------------------------------------------------------------------- 

105 

106 

107def _edits_from_post(data, plural_count: int) -> list[services.Edit]: 

108 edits = [] 

109 for key in data.getlist("key")[: PAGE_SIZE * 2]: 

110 values = [data.get(f"msgstr-{key}-{index}", "") for index in range(plural_count)] 

111 if f"msgstr-{key}-1" not in data: 

112 values = values[:1] 

113 edits.append( 

114 services.Edit(key=key, values=values, fuzzy=f"fuzzy-{key}" in data, seen=data.get(f"seen-{key}", "")) 

115 ) 

116 return edits 

117 

118 

119def _matches(entry, query: str) -> bool: 

120 texts = [entry.msgid, entry.msgid_plural, entry.msgctxt, entry.msgstr, *entry.msgstr_plural.values()] 

121 return any(query in (text or "").casefold() for text in texts) 

122 

123 

124def _plural_labels(po) -> list[str]: 

125 try: 

126 examples = catalogues.plural_examples(catalogues.plural_forms(po)) 

127 except catalogues.CatalogueError: 

128 examples = [[1], [2]] 

129 labels = [] 

130 for index, numbers in enumerate(examples, start=1): 

131 if numbers: 

132 labels.append( 

133 _("Form %(number)d (n = %(examples)s…)") 

134 % {"number": index, "examples": ", ".join(str(n) for n in numbers)} 

135 ) 

136 else: 

137 labels.append(_("Form %(number)d") % {"number": index}) 

138 return labels 

139 

140 

141def _row(entry, *, plural_labels, submitted, errors) -> dict: 

142 key = catalogues.entry_key(entry) 

143 edit = submitted.get(key) 

144 if entry.msgid_plural: 

145 values = [entry.msgstr_plural[index] for index in sorted(entry.msgstr_plural)] 

146 labels = plural_labels 

147 else: 

148 values = [entry.msgstr] 

149 labels = [_("Translation")] 

150 if edit is not None: 

151 values = edit.values + [""] * (len(values) - len(edit.values)) 

152 longest = max([len(entry.msgid), len(entry.msgid_plural or ""), *(len(value) for value in values)]) 

153 fields = [ 

154 {"name": f"msgstr-{key}-{index}", "label": labels[index] if index < len(labels) else "", "value": value} 

155 for index, value in enumerate(values) 

156 ] 

157 return { 

158 "key": key, 

159 "entry": entry, 

160 "state": catalogues.entry_state(entry), 

161 "seen": services.fingerprint(entry), 

162 "fields": fields, 

163 "rows": min(8, 1 + longest // 60), 

164 "fuzzy": edit.fuzzy if edit is not None else entry.fuzzy, 

165 "error": errors.get(key), 

166 "occurrences": [f"{path}:{line}" if line else path for path, line in entry.occurrences[:3]], 

167 "more_occurrences": max(0, len(entry.occurrences) - 3), 

168 } 

169 

170 

171@translator_required 

172@require_http_methods(["GET", "POST"]) 

173def catalogue(request: HttpRequest, language_code: str) -> HttpResponse: 

174 language_name = _language_name(language_code) 

175 try: 

176 current = services.get_catalogue(language_code, user=request.user) 

177 except services.NoSourceError: 

178 messages.info(request, _("Extract the source strings first.")) 

179 return redirect("localization:index") 

180 state = request.GET.get("state", "untranslated") 

181 state = state if state in STATES else "untranslated" 

182 query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH] 

183 submitted: dict = {} 

184 errors: dict = {} 

185 if request.method == "POST": 

186 draft = services.read_draft(current) 

187 edits = _edits_from_post(request.POST, catalogues.plural_count(draft)) 

188 result = services.save_entries(language_code, edits, user=request.user) 

189 if result.changed: 

190 messages.success( 

191 request, 

192 ngettext("%(count)d translation saved.", "%(count)d translations saved.", result.changed) 

193 % {"count": result.changed}, 

194 ) 

195 if result.conflicts: 

196 messages.warning( 

197 request, 

198 ngettext( 

199 "%(count)d translation was changed by someone else while you were editing; " 

200 "it was not overwritten. Check it below.", 

201 "%(count)d translations were changed by someone else while you were editing; " 

202 "they were not overwritten. Check them below.", 

203 len(result.conflicts), 

204 ) 

205 % {"count": len(result.conflicts)}, 

206 ) 

207 if not result.errors: 

208 return redirect(request.get_full_path()) 

209 errors = result.errors 

210 submitted = {edit.key: edit for edit in edits if edit.key in errors} 

211 messages.error(request, _("Some translations were not saved. Correct them below.")) 

212 current.refresh_from_db() 

213 

214 draft = services.read_draft(current) 

215 entries = catalogues.active_entries(draft) 

216 counts = catalogues.stats(draft) 

217 if state != "all": 

218 entries = [entry for entry in entries if catalogues.entry_state(entry) == state] 

219 if query: 

220 folded = query.casefold() 

221 entries = [entry for entry in entries if _matches(entry, folded)] 

222 if errors: 

223 # Keep the entries being corrected on the page whatever the filter. 

224 shown = {catalogues.entry_key(entry) for entry in entries} 

225 entries += [ 

226 entry 

227 for entry in catalogues.active_entries(draft) 

228 if catalogues.entry_key(entry) in errors and catalogues.entry_key(entry) not in shown 

229 ] 

230 page = Paginator(entries, PAGE_SIZE).get_page(request.GET.get("page")) 

231 plural_labels = _plural_labels(draft) 

232 publication = services.latest_publication(language_code) 

233 return render( 

234 request, 

235 "localization/catalogue.html", 

236 { 

237 "language_code": language_code, 

238 "language_name": language_name, 

239 "catalogue": current, 

240 "counts": counts, 

241 "state": state, 

242 "states": [ 

243 ("untranslated", _("Untranslated"), counts.untranslated), 

244 ("fuzzy", _("Needs review"), counts.fuzzy), 

245 ("translated", _("Translated"), counts.translated), 

246 ("all", _("All"), counts.total), 

247 ], 

248 "query": query, 

249 "page": page, 

250 "rows": [_row(entry, plural_labels=plural_labels, submitted=submitted, errors=errors) for entry in page], 

251 "publication": publication, 

252 "unpublished": services.has_unpublished_changes(draft, publication), 

253 "plural_form": PluralFormsForm(initial={"plural_forms": catalogues.plural_forms(draft)}), 

254 "upload_form": UploadForm(), 

255 }, 

256 ) 

257 

258 

259@translator_required 

260@require_POST 

261def plural_forms(request: HttpRequest, language_code: str) -> HttpResponse: 

262 _language_name(language_code) 

263 form = PluralFormsForm(request.POST) 

264 if form.is_valid(): 

265 services.set_plural_forms(language_code, form.cleaned_data["plural_forms"], user=request.user) 

266 messages.success(request, _("Plural forms saved.")) 

267 else: 

268 messages.error(request, " ".join(form.errors["plural_forms"])) 

269 return redirect("localization:catalogue", language_code=language_code) 

270 

271 

272@translator_required 

273@require_POST 

274def upload(request: HttpRequest, language_code: str) -> HttpResponse: 

275 _language_name(language_code) 

276 form = UploadForm(request.POST, request.FILES) 

277 if not form.is_valid(): 

278 messages.error(request, " ".join(error for errors in form.errors.values() for error in errors)) 

279 return redirect("localization:catalogue", language_code=language_code) 

280 result = services.import_catalogue(language_code, form.cleaned_data["file"], user=request.user) 

281 messages.success( 

282 request, 

283 ngettext( 

284 "%(count)d translation taken from the file.", "%(count)d translations taken from the file.", result.changed 

285 ) 

286 % {"count": result.changed}, 

287 ) 

288 if result.errors: 

289 messages.warning( 

290 request, 

291 ngettext( 

292 "%(count)d translation was skipped because its placeholders do not match the original.", 

293 "%(count)d translations were skipped because their placeholders do not match the original.", 

294 len(result.errors), 

295 ) 

296 % {"count": len(result.errors)}, 

297 ) 

298 return redirect("localization:catalogue", language_code=language_code) 

299 

300 

301@translator_required 

302@require_GET 

303def download(request: HttpRequest, language_code: str) -> HttpResponse: 

304 _language_name(language_code) 

305 current = get_object_or_404(TranslationCatalogue, language_code=language_code) 

306 response = HttpResponse(catalogues.dump(services.read_draft(current)), content_type="text/x-gettext-translation") 

307 response["Content-Disposition"] = f'attachment; filename="{to_locale(language_code)}.po"' 

308 return response 

309 

310 

311# Publishing ---------------------------------------------------------------------- 

312 

313 

314@translator_required 

315@require_POST 

316def publish(request: HttpRequest, language_code: str) -> HttpResponse: 

317 _language_name(language_code) 

318 get_object_or_404(TranslationCatalogue, language_code=language_code) 

319 publication = services.publish(language_code, user=request.user) 

320 messages.success( 

321 request, 

322 _("Published version %(version)d. Every page uses it within a few seconds.") 

323 % {"version": publication.version}, 

324 ) 

325 return redirect("localization:catalogue", language_code=language_code) 

326 

327 

328@translator_required 

329@require_GET 

330def history(request: HttpRequest, language_code: str) -> HttpResponse: 

331 language_name = _language_name(language_code) 

332 publications = list( 

333 TranslationPublication.objects.filter(language_code=language_code) 

334 .select_related("published_by", "restored_from") 

335 .order_by("-version") 

336 ) 

337 return render( 

338 request, 

339 "localization/history.html", 

340 { 

341 "language_code": language_code, 

342 "language_name": language_name, 

343 "publications": publications, 

344 "live": publications[0] if publications else None, 

345 }, 

346 ) 

347 

348 

349@translator_required 

350@require_POST 

351def restore(request: HttpRequest, language_code: str, pk) -> HttpResponse: 

352 _language_name(language_code) 

353 publication = get_object_or_404(TranslationPublication, pk=pk, language_code=language_code) 

354 restored = services.restore(publication, user=request.user) 

355 messages.success( 

356 request, 

357 _("Version %(old)d is live again, as version %(new)d. The draft was not changed.") 

358 % {"old": publication.version, "new": restored.version}, 

359 ) 

360 return redirect("localization:history", language_code=language_code)