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

229 statements  

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

1"""The translation workflow: source strings, drafts, publishing and restoring. 

2 

3Files (templates, drafts, published catalogues) are stored through 

4:mod:`baibu.core.storage`; the database keeps their keys and the audit trail. 

5Storage keys are never overwritten, so saving a draft writes a new file and 

6removes the previous one once the database change is committed. 

7""" 

8 

9import hashlib 

10import logging 

11import uuid 

12from dataclasses import dataclass 

13from dataclasses import field 

14from pathlib import Path 

15 

16import polib 

17from django.conf import settings 

18from django.db import transaction 

19from django.db.models import Max 

20from django.utils import timezone 

21from django.utils.translation import to_locale 

22 

23from baibu.core import storage 

24 

25from . import catalogues 

26from . import extraction 

27from . import runtime 

28from .models import TranslationCatalogue 

29from .models import TranslationPublication 

30from .models import TranslationSource 

31 

32logger = logging.getLogger(__name__) 

33 

34SOURCE_LANGUAGE = "en" 

35AREA = "translations" 

36 

37 

38class NoSourceError(Exception): 

39 """Source strings have not been extracted yet.""" 

40 

41 

42class UnknownLanguageError(LookupError): 

43 """The language is not one this deployment translates.""" 

44 

45 

46@dataclass 

47class SourceUpdate: 

48 source: TranslationSource 

49 created: bool 

50 merged: list[str] = field(default_factory=list) 

51 

52 

53@dataclass 

54class Edit: 

55 """A translator's change to one entry.""" 

56 

57 key: str 

58 # One value for an ordinary entry; one per plural form otherwise. 

59 values: list[str] 

60 fuzzy: bool 

61 # Fingerprint of the entry as the translator saw it (see ``fingerprint``). 

62 seen: str 

63 

64 

65@dataclass 

66class SaveResult: 

67 changed: int = 0 

68 conflicts: list[str] = field(default_factory=list) 

69 errors: dict[str, str] = field(default_factory=dict) 

70 

71 

72def translatable_languages() -> list[tuple[str, str]]: 

73 """The deployment's languages other than the source language (English).""" 

74 return [(code, name) for code, name in settings.LANGUAGES if code != SOURCE_LANGUAGE] 

75 

76 

77def check_language(language_code: str) -> None: 

78 if language_code not in dict(translatable_languages()): 

79 raise UnknownLanguageError(language_code) 

80 

81 

82def latest_source() -> TranslationSource | None: 

83 return TranslationSource.objects.order_by("-created_at").first() 

84 

85 

86def _read_po(key: str) -> polib.POFile: 

87 data = storage.read_bytes(key) 

88 if data is None: 

89 msg = f"Stored catalogue {key} is missing." 

90 raise FileNotFoundError(msg) 

91 return catalogues.parse(data) 

92 

93 

94def read_template(source: TranslationSource) -> polib.POFile: 

95 return _read_po(source.pot_key) 

96 

97 

98def read_draft(catalogue: TranslationCatalogue) -> polib.POFile: 

99 return _read_po(catalogue.draft_key) 

100 

101 

102def _save_draft(catalogue: TranslationCatalogue, po: polib.POFile, *, user=None, source=None) -> None: 

103 """Store ``po`` as the catalogue's draft and update the row (inside a transaction).""" 

104 old_key = catalogue.draft_key 

105 new_key = storage.save_bytes( 

106 storage.build_key(f"{AREA}/{catalogue.language_code}/drafts", f"{uuid.uuid7()}.po"), catalogues.dump(po) 

107 ) 

108 catalogue.draft_key = new_key 

109 catalogue.updated_at = timezone.now() 

110 catalogue.updated_by = user 

111 if source is not None: 

112 catalogue.source = source 

113 try: 

114 catalogue.save() 

115 except Exception: 

116 storage.delete(new_key) 

117 raise 

118 if old_key and old_key != new_key: 

119 transaction.on_commit(lambda: storage.delete(old_key)) 

120 

121 

122# Source strings -------------------------------------------------------------------- 

123 

124 

125def update_sources(*, user=None, roots=None, force: bool = False) -> SourceUpdate: 

126 """Extract the interface strings from the code and merge them into every draft. 

127 

128 An extraction identical to the current template is not stored again 

129 (unless ``force``), but drafts that are behind are still brought up to date. 

130 """ 

131 template = extraction.extract(roots) 

132 digest = catalogues.content_hash(template) 

133 current = latest_source() 

134 created = force or current is None or current.content_hash != digest 

135 with transaction.atomic(): 

136 if created: 

137 source_id = uuid.uuid7() 

138 key = storage.save_bytes( 

139 storage.build_key(f"{AREA}/sources", f"{source_id}.pot"), catalogues.dump(template) 

140 ) 

141 try: 

142 current = TranslationSource.objects.create( 

143 id=source_id, 

144 pot_key=key, 

145 content_hash=digest, 

146 entry_count=len(template), 

147 created_by=user, 

148 ) 

149 except Exception: 

150 storage.delete(key) 

151 raise 

152 merged = [ 

153 code 

154 for code, _name in translatable_languages() 

155 if _bring_up_to_date(code, current, template=template, user=user) 

156 ] 

157 logger.info("Source strings %s (%d entries); drafts updated: %s", current.pk, len(template), merged or "none") 

158 return SourceUpdate(source=current, created=created, merged=merged) 

159 

160 

161def _deployment_catalogue(language_code: str) -> polib.POFile | None: 

162 """The deployment's own .po file for the language, if it has one.""" 

163 path = Path(settings.DEPLOYMENT_DIR) / "locale" / to_locale(language_code) / "LC_MESSAGES" / "django.po" 

164 if not path.is_file(): 

165 return None 

166 try: 

167 return catalogues.parse(path.read_bytes()) 

168 except catalogues.CatalogueError: 

169 logger.warning("Ignoring %s: not a valid PO file", path) 

170 return None 

171 

172 

173def _bring_up_to_date(language_code: str, source: TranslationSource, *, template=None, user=None) -> bool: 

174 """Create the language's draft, or merge a newer template into it. Returns whether it changed.""" 

175 catalogue = TranslationCatalogue.objects.select_for_update().filter(language_code=language_code).first() 

176 if catalogue is not None and catalogue.source_id == source.pk: 

177 return False 

178 template = template if template is not None else read_template(source) 

179 if catalogue is None: 

180 po = catalogues.new_catalogue(template, language_code=language_code, base=_deployment_catalogue(language_code)) 

181 catalogue = TranslationCatalogue(language_code=language_code) 

182 else: 

183 po = catalogues.merge_template(read_draft(catalogue), template) 

184 _save_draft(catalogue, po, user=user, source=source) 

185 return True 

186 

187 

188def get_catalogue(language_code: str, *, user=None) -> TranslationCatalogue: 

189 """The language's catalogue, created or brought up to date with the newest template.""" 

190 check_language(language_code) 

191 source = latest_source() 

192 if source is None: 

193 raise NoSourceError 

194 with transaction.atomic(): 

195 _bring_up_to_date(language_code, source, user=user) 

196 return TranslationCatalogue.objects.get(language_code=language_code) 

197 

198 

199# Editing --------------------------------------------------------------------------- 

200 

201 

202def fingerprint(entry: polib.POEntry) -> str: 

203 """A short digest of an entry's translation, to detect someone else's change.""" 

204 return hashlib.sha256(repr((_values(entry), entry.fuzzy)).encode()).hexdigest()[:16] 

205 

206 

207def _values(entry: polib.POEntry) -> list[str]: 

208 if entry.msgid_plural: 

209 return [entry.msgstr_plural[index] for index in sorted(entry.msgstr_plural)] 

210 return [entry.msgstr] 

211 

212 

213def _apply(entry: polib.POEntry, edit: Edit) -> bool: 

214 """Apply an edit to an entry. Returns whether anything changed.""" 

215 before = fingerprint(entry) 

216 if entry.msgid_plural: 

217 for index in sorted(entry.msgstr_plural): 

218 entry.msgstr_plural[index] = edit.values[index] if index < len(edit.values) else "" 

219 else: 

220 entry.msgstr = edit.values[0] if edit.values else "" 

221 entry.fuzzy = edit.fuzzy and bool(any(edit.values)) 

222 if not entry.fuzzy: 

223 entry.previous_msgid = entry.previous_msgid_plural = entry.previous_msgctxt = None 

224 return fingerprint(entry) != before 

225 

226 

227def save_entries(language_code: str, edits: list[Edit], *, user) -> SaveResult: 

228 """Apply translators' edits to the draft. 

229 

230 Entries someone else changed since the page was loaded are not 

231 overwritten (reported in ``conflicts``); translations that would break a 

232 format string are refused (reported in ``errors``). 

233 """ 

234 check_language(language_code) 

235 result = SaveResult() 

236 with transaction.atomic(): 

237 catalogue = TranslationCatalogue.objects.select_for_update().get(language_code=language_code) 

238 po = read_draft(catalogue) 

239 for edit in edits: 

240 entry = catalogues.find_entry(po, edit.key) 

241 if entry is None: 

242 result.conflicts.append(edit.key) 

243 continue 

244 values = [value.replace("\r\n", "\n") for value in edit.values] 

245 if values == _values(entry) and edit.fuzzy == entry.fuzzy: 

246 continue 

247 if fingerprint(entry) != edit.seen: 

248 result.conflicts.append(edit.key) 

249 continue 

250 if problem := catalogues.placeholder_problem(entry, *values): 

251 result.errors[edit.key] = problem 

252 continue 

253 if _apply(entry, Edit(edit.key, values, edit.fuzzy, edit.seen)): 

254 result.changed += 1 

255 if result.changed: 

256 _save_draft(catalogue, po, user=user) 

257 return result 

258 

259 

260def set_plural_forms(language_code: str, value: str, *, user) -> None: 

261 catalogues.parse_plural_forms(value) 

262 check_language(language_code) 

263 with transaction.atomic(): 

264 catalogue = TranslationCatalogue.objects.select_for_update().get(language_code=language_code) 

265 po = read_draft(catalogue) 

266 catalogues.set_header(po, language_code=language_code, plural_forms_value=value.strip()) 

267 _save_draft(catalogue, po, user=user) 

268 

269 

270def import_catalogue(language_code: str, data: bytes, *, user) -> SaveResult: 

271 """Take translations from an uploaded .po file for messages the draft has. 

272 

273 Messages not in the draft are ignored; translations that would break a 

274 format string are skipped. 

275 """ 

276 check_language(language_code) 

277 uploaded = catalogues.parse(data) 

278 result = SaveResult() 

279 with transaction.atomic(): 

280 catalogue = TranslationCatalogue.objects.select_for_update().get(language_code=language_code) 

281 po = read_draft(catalogue) 

282 count = catalogues.plural_count(po) 

283 by_key = {catalogues.entry_key(entry): entry for entry in catalogues.active_entries(po)} 

284 for incoming in catalogues.active_entries(uploaded): 

285 key = catalogues.entry_key(incoming) 

286 entry = by_key.get(key) 

287 if entry is None or not (incoming.msgstr or any(incoming.msgstr_plural.values())): 

288 continue 

289 if entry.msgid_plural: 

290 values = [incoming.msgstr_plural.get(index, "") for index in range(count)] 

291 else: 

292 values = [incoming.msgstr] 

293 if problem := catalogues.placeholder_problem(entry, *values): 

294 result.errors[key] = problem 

295 continue 

296 if _apply(entry, Edit(key, values, incoming.fuzzy, "")): 

297 result.changed += 1 

298 if result.changed: 

299 _save_draft(catalogue, po, user=user) 

300 return result 

301 

302 

303# Publishing ------------------------------------------------------------------------ 

304 

305 

306def latest_publication(language_code: str) -> TranslationPublication | None: 

307 return TranslationPublication.objects.filter(language_code=language_code).order_by("-version").first() 

308 

309 

310def _next_version(language_code: str) -> int: 

311 return ( 

312 TranslationPublication.objects.filter(language_code=language_code).aggregate(v=Max("version"))["v"] or 0 

313 ) + 1 

314 

315 

316def _after_publish(publication: TranslationPublication) -> None: 

317 def announce_and_load(): 

318 runtime.announce(str(publication.pk)) 

319 if settings.LOCALIZATION_SYNC: 

320 runtime.sync() 

321 

322 transaction.on_commit(announce_and_load) 

323 

324 

325def publish(language_code: str, *, user) -> TranslationPublication: 

326 """Compile the draft and make it the language's live translations.""" 

327 check_language(language_code) 

328 with transaction.atomic(): 

329 catalogue = TranslationCatalogue.objects.select_for_update().get(language_code=language_code) 

330 po = read_draft(catalogue) 

331 counts = catalogues.stats(po) 

332 publication_id = uuid.uuid7() 

333 area = f"{AREA}/{language_code}/published" 

334 po_key = storage.save_bytes(storage.build_key(area, f"{publication_id}.po"), catalogues.dump(po)) 

335 mo_key = storage.save_bytes(storage.build_key(area, f"{publication_id}.mo"), catalogues.compile_mo(po)) 

336 try: 

337 publication = TranslationPublication.objects.create( 

338 id=publication_id, 

339 language_code=language_code, 

340 version=_next_version(language_code), 

341 po_key=po_key, 

342 mo_key=mo_key, 

343 total_count=counts.total, 

344 translated_count=counts.translated, 

345 fuzzy_count=counts.fuzzy, 

346 published_by=user, 

347 ) 

348 except Exception: 

349 storage.delete(po_key) 

350 storage.delete(mo_key) 

351 raise 

352 _after_publish(publication) 

353 logger.info("Published %s by %s", publication, getattr(user, "pk", None)) 

354 return publication 

355 

356 

357def restore(publication: TranslationPublication, *, user) -> TranslationPublication: 

358 """Make an earlier publication live again, as a new version. The draft is not changed.""" 

359 check_language(publication.language_code) 

360 with transaction.atomic(): 

361 # Serialise with other publications of the same language. 

362 TranslationCatalogue.objects.select_for_update().filter(language_code=publication.language_code).first() 

363 restored = TranslationPublication.objects.create( 

364 language_code=publication.language_code, 

365 version=_next_version(publication.language_code), 

366 po_key=publication.po_key, 

367 mo_key=publication.mo_key, 

368 total_count=publication.total_count, 

369 translated_count=publication.translated_count, 

370 fuzzy_count=publication.fuzzy_count, 

371 restored_from=publication, 

372 published_by=user, 

373 ) 

374 _after_publish(restored) 

375 logger.info("Restored %s as %s by %s", publication, restored, getattr(user, "pk", None)) 

376 return restored 

377 

378 

379def _translations(po: polib.POFile) -> set: 

380 return { 

381 (catalogues.entry_key(entry), tuple(_values(entry)), entry.fuzzy) for entry in catalogues.active_entries(po) 

382 } | {("Plural-Forms", catalogues.plural_forms(po))} 

383 

384 

385def has_unpublished_changes(draft: polib.POFile, publication: TranslationPublication | None) -> bool: 

386 """Whether the draft's translations differ from what is live.""" 

387 if publication is None: 

388 return True 

389 data = storage.read_bytes(publication.po_key) 

390 return data is None or _translations(draft) != _translations(catalogues.parse(data))