Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/models/translation.py: 42%

1038 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 codecs 

8import contextlib 

9import os 

10import tempfile 

11from datetime import UTC 

12from itertools import chain 

13from pathlib import Path 

14from typing import TYPE_CHECKING, BinaryIO, Literal, NotRequired, TypedDict, overload 

15 

16import sentry_sdk 

17from django.core.cache import cache 

18from django.core.exceptions import ObjectDoesNotExist, ValidationError 

19from django.db import IntegrityError, models, transaction 

20from django.db.models import F, Q 

21from django.urls import reverse 

22from django.utils import timezone 

23from django.utils.functional import cached_property 

24from django.utils.html import format_html 

25from django.utils.translation import gettext, ngettext 

26 

27from weblate.checks.flags import Flags 

28from weblate.formats.auto import try_load 

29from weblate.formats.base import UnitNotFoundError 

30from weblate.formats.helpers import CONTROLCHARS, NamedBytesIO 

31from weblate.lang.models import Language, Plural 

32from weblate.trans.actions import ActionEvents 

33from weblate.trans.checklists import TranslationChecklistMixin 

34from weblate.trans.defines import FILENAME_LENGTH 

35from weblate.trans.exceptions import ( 

36 FailedCommitError, 

37 FileParseError, 

38 PluralFormsMismatchError, 

39) 

40from weblate.trans.mixins import CacheKeyMixin, LockMixin, LoggerMixin, URLMixin 

41from weblate.trans.models.change import Change 

42from weblate.trans.models.pending import PendingUnitChange 

43from weblate.trans.models.suggestion import Suggestion 

44from weblate.trans.models.unit import Unit 

45from weblate.trans.models.variant import Variant 

46from weblate.trans.signals import component_post_update, vcs_pre_commit 

47from weblate.trans.util import is_plural, join_plural, split_plural 

48from weblate.trans.validators import validate_check_flags 

49from weblate.utils import messages 

50from weblate.utils.errors import report_error 

51from weblate.utils.html import format_html_join_comma 

52from weblate.utils.render import render_template 

53from weblate.utils.site import get_site_url 

54from weblate.utils.state import ( 

55 STATE_APPROVED, 

56 STATE_EMPTY, 

57 STATE_FUZZY, 

58 STATE_READONLY, 

59 STATE_TRANSLATED, 

60) 

61from weblate.utils.stats import GhostStats, TranslationStats 

62from weblate.utils.version import GIT_VERSION 

63 

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

65 from datetime import datetime 

66 

67 from weblate.auth.models import AuthenticatedHttpRequest, User 

68 from weblate.formats.base import TranslationFormat, TranslationUnit 

69 from weblate.utils.state import ( 

70 StringState, 

71 ) 

72 

73 from .project import Project 

74 

75UploadResult = tuple[int, int, int, int] 

76 

77 

78class NewUnitParams(TypedDict): 

79 context: NotRequired[str] 

80 source: str | list[str] 

81 target: NotRequired[str | list[str] | None] 

82 auto_context: NotRequired[bool] 

83 extra_flags: NotRequired[str | None] 

84 explanation: NotRequired[str] 

85 state: NotRequired[int | None] 

86 skip_existing: NotRequired[bool] 

87 

88 

89class TranslationManager(models.Manager): 

90 def check_sync( 

91 self, 

92 component, 

93 lang, 

94 code, 

95 path, 

96 force=False, 

97 request: AuthenticatedHttpRequest | None = None, 

98 change=None, 

99 ): 

100 """Parse translation meta info and updates translation object.""" 

101 translation, _created = component.translation_set.get_or_create( 

102 language=lang, 

103 defaults={"filename": path, "language_code": code, "plural": lang.plural}, 

104 ) 

105 if translation.filename != path or translation.language_code != code: 

106 force = True 

107 translation.filename = path 

108 translation.language_code = code 

109 translation.save(update_fields=["filename", "language_code"]) 

110 flags = "" 

111 if (not component.edit_template and translation.is_template) or ( 

112 not component.has_template() and translation.is_source 

113 ): 

114 flags = "read-only" 

115 if translation.check_flags != flags: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 force = True 

117 translation.check_flags = flags 

118 translation.save(update_fields=["check_flags"]) 

119 translation.check_sync(force, request=request, change=change) 

120 return translation 

121 

122 

123class TranslationQuerySet(models.QuerySet): 

124 def prefetch(self, *, defer_huge: bool = True): 

125 from weblate.trans.models import Component 

126 

127 component_prefetch: str | models.Prefetch 

128 if defer_huge: 

129 component_prefetch = models.Prefetch( 

130 "component", queryset=Component.objects.defer_huge() 

131 ) 

132 else: 

133 component_prefetch = "component" 

134 

135 return self.prefetch_related( 

136 component_prefetch, 

137 "component__project", 

138 "component__category", 

139 "component__category__project", 

140 "component__category__category", 

141 "component__category__category__project", 

142 "component__category__category__category", 

143 "component__category__category__category__project", 

144 ).prefetch_meta() 

145 

146 def prefetch_meta(self): 

147 from weblate.trans.models import Alert, Component 

148 

149 return self.prefetch_related( 

150 "language", 

151 models.Prefetch( 

152 "component__linked_component", queryset=Component.objects.defer_huge() 

153 ), 

154 "component__linked_component__project", 

155 models.Prefetch( 

156 "component__alert_set", 

157 queryset=Alert.objects.filter(dismissed=False), 

158 to_attr="all_active_alerts", 

159 ), 

160 ) 

161 

162 def prefetch_plurals(self): 

163 return self.prefetch_related("language__plural_set") 

164 

165 def filter_access(self, user: User): 

166 result = self 

167 if user.needs_project_filter: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 result = result.filter(component__project__in=user.allowed_projects) 

169 if user.needs_component_restrictions_filter: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 result = result.filter( 

171 Q(component__restricted=False) 

172 | Q(component_id__in=user.component_permissions) 

173 ) 

174 return result 

175 

176 def order(self): 

177 return self.order_by( 

178 "component__priority", "component__project__name", "component__name" 

179 ) 

180 

181 

182class Translation( 

183 models.Model, 

184 URLMixin, 

185 LoggerMixin, 

186 CacheKeyMixin, 

187 LockMixin, 

188 TranslationChecklistMixin, 

189): 

190 component = models.ForeignKey( 

191 "trans.Component", on_delete=models.deletion.CASCADE, db_index=False 

192 ) 

193 language = models.ForeignKey(Language, on_delete=models.deletion.CASCADE) 

194 plural = models.ForeignKey(Plural, on_delete=models.deletion.CASCADE) 

195 revision = models.CharField(max_length=200, default="", blank=True) 

196 filename = models.CharField(max_length=FILENAME_LENGTH) 

197 

198 language_code = models.CharField(max_length=50, default="", blank=True) 

199 

200 check_flags = models.TextField( 

201 verbose_name="Translation flags", 

202 default="", 

203 validators=[validate_check_flags], 

204 blank=True, 

205 ) 

206 

207 objects = TranslationManager.from_queryset(TranslationQuerySet)() 

208 

209 remove_permission = "translation.delete" 

210 settings_permission = "component.edit" 

211 

212 class Meta: 

213 app_label = "trans" 

214 unique_together = [("component", "language")] # noqa: RUF012 

215 verbose_name = "translation" 

216 verbose_name_plural = "translations" 

217 

218 def __str__(self) -> str: 

219 return f"{self.component} — {self.language}" 

220 

221 def __init__(self, *args, **kwargs) -> None: 

222 super().__init__(*args, **kwargs) 

223 self.stats = TranslationStats(self) 

224 self.addon_commit_files: list[str] = [] 

225 self.reason = "" 

226 self._invalidate_scheduled = False 

227 self.update_changes: list[Change] = [] 

228 # Project backup integration 

229 self.original_id = -1 

230 

231 self.create_unit_change_action = ActionEvents.NEW_UNIT_REPO 

232 self.update_unit_change_action = ActionEvents.STRING_REPO_UPDATE 

233 

234 @property 

235 def code(self): 

236 return self.language.code 

237 

238 def log_hook(self, level, msg, *args) -> None: 

239 self.component.store_log(self.full_slug, msg, *args) 

240 

241 @property 

242 def project(self) -> Project: 

243 return self.component.project 

244 

245 @cached_property 

246 def is_template(self) -> bool: 

247 """ 

248 Check whether this is template translation. 

249 

250 This means that translations should be propagated as sources to others. 

251 """ 

252 return ( 

253 self.component.has_template() and self.filename == self.component.template 

254 ) 

255 

256 @cached_property 

257 def is_source(self) -> bool: 

258 """ 

259 Check whether this is source strings. 

260 

261 This means that translations should be propagated as sources to others. 

262 """ 

263 return self.language_id == self.component.source_language_id 

264 

265 @cached_property 

266 def all_flags(self): 

267 """Return parsed list of flags.""" 

268 return Flags(self.component.all_flags, self.check_flags) 

269 

270 @cached_property 

271 def is_readonly(self): 

272 return "read-only" in self.all_flags 

273 

274 def clean(self) -> None: 

275 """Validate that filename exists and can be opened using translate-toolkit.""" 

276 filename = self.get_filename() 

277 if filename is None: 

278 # Should not actually happen 

279 msg = "Translation without a filename!" 

280 raise ValidationError(msg) 

281 if not os.path.exists(filename): 

282 raise ValidationError( 

283 gettext( 

284 "Filename %s not found in repository! To add new " 

285 "translation, add language file into repository." 

286 ) 

287 % self.filename 

288 ) 

289 try: 

290 self.load_store() 

291 except Exception as error: 

292 raise ValidationError( 

293 gettext("Could not parse file %(file)s: %(error)s") 

294 % {"file": self.filename, "error": str(error)} 

295 ) from error 

296 

297 def get_url_path(self): 

298 return (*self.component.get_url_path(), self.language.code) 

299 

300 def get_widgets_url(self) -> str: 

301 """Return absolute URL for widgets.""" 

302 return f"{self.component.project.get_widgets_url()}?lang={self.language.code}&component={self.component.pk}" 

303 

304 def get_share_url(self): 

305 """Return absolute URL usable for sharing.""" 

306 return get_site_url( 

307 reverse( 

308 "engage", 

309 kwargs={"path": [self.component.project.slug, "-", self.language.code]}, 

310 ) 

311 ) 

312 

313 def get_translate_url(self): 

314 return reverse("translate", kwargs={"path": self.get_url_path()}) 

315 

316 def get_filename(self) -> str | None: 

317 """Return absolute filename.""" 

318 if not self.filename: 

319 return None 

320 return os.path.join(self.component.full_path, self.filename) 

321 

322 def load_store(self, fileobj=None, force_intermediate=False): 

323 """Load translate-toolkit storage from disk.""" 

324 # Use intermediate store as template for source translation 

325 with sentry_sdk.start_span( 

326 op="translation.load_store", name=self.get_filename() 

327 ): 

328 if force_intermediate or (self.is_template and self.component.intermediate): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 template = self.component.intermediate_store 

330 else: 

331 template = self.component.template_store 

332 if fileobj is None: 332 ↛ 337line 332 didn't jump to line 337 because the condition on line 332 was always true

333 fileobj = self.get_filename() 

334 if fileobj is None: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 msg = "Attempt to parse store without a filename." 

336 raise ValueError(msg) 

337 elif self.is_template: 

338 template = self.component.load_template_store( 

339 NamedBytesIO(fileobj.name, fileobj.read()) 

340 ) 

341 fileobj.seek(0) 

342 return self.component.file_format_cls( 

343 fileobj, 

344 template, 

345 language_code=self.language_code, 

346 source_language=self.language_code 

347 if self.component.has_template() 

348 else self.component.source_language.code, 

349 is_template=self.is_template, 

350 existing_units=self.unit_set.all(), 

351 file_format_params=self.component.file_format_params, 

352 ) 

353 

354 @cached_property 

355 def store(self): 

356 """Return translate-toolkit storage object for a translation.""" 

357 try: 

358 return self.load_store() 

359 except FileParseError: 

360 raise 

361 except Exception as exc: 

362 report_error( 

363 "Translation parse error", project=self.component.project, print_tb=True 

364 ) 

365 self.component.handle_parse_error(exc, self) 

366 

367 def pre_process_unit( 

368 self, 

369 *, 

370 dbunits: dict[int, Unit], 

371 id_hash: int, 

372 unit: TranslationUnit, 

373 pos: int, 

374 ) -> Unit: 

375 try: 

376 newunit = dbunits[id_hash] 

377 is_new = False 

378 except KeyError: 

379 newunit = Unit(translation=self, id_hash=id_hash, state=-1) 

380 # Avoid fetching empty list of checks from the database 

381 newunit.all_checks = [] 

382 # Avoid fetching empty list of variants 

383 newunit._prefetched_objects_cache = { # noqa: SLF001 

384 "defined_variants": Variant.objects.none() 

385 } 

386 is_new = True 

387 

388 newunit.store_unit_attributes( 

389 unit=unit, pos=pos, created=is_new, id_hash=id_hash 

390 ) 

391 return newunit 

392 

393 def check_sync( # noqa: C901 

394 self, 

395 force: bool = False, 

396 request: AuthenticatedHttpRequest | None = None, 

397 change: int | None = None, 

398 author: User | None = None, 

399 ) -> None: 

400 """Check whether database is in sync with git and possibly updates.""" 

401 with sentry_sdk.start_span(op="translation.check_sync", name=self.full_slug): 

402 if change is None: 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was always true

403 change = ActionEvents.UPDATE 

404 user = None if request is None else request.user 

405 

406 details = { 

407 "filename": self.filename, 

408 } 

409 self.update_changes = [] 

410 

411 # Check if we're not already up to date 

412 try: 

413 new_revision = self.get_git_blob_hash() 

414 except FileNotFoundError: 

415 self.reason = "" 

416 return 

417 except Exception as exc: 

418 report_error("Translation parse error", project=self.component.project) 

419 self.component.handle_parse_error(exc, self) 

420 if not self.revision: 

421 self.reason = "new file" 

422 elif self.revision != new_revision: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 self.reason = "content changed" 

424 

425 # Include changed filename in the details 

426 old_parts = self.revision.split(",") 

427 new_parts = new_revision.split(",") 

428 if len(old_parts) == len(new_parts): 

429 filenames = self.get_hash_filenames() 

430 for i, old_part in enumerate(old_parts): 

431 if old_part != new_parts[i]: 

432 details["filename"] = filenames[i][ 

433 len(self.component.full_path) : 

434 ].lstrip("/") 

435 break 

436 

437 elif force: 437 ↛ 440line 437 didn't jump to line 440 because the condition on line 437 was always true

438 self.reason = "check forced" 

439 else: 

440 self.reason = "" 

441 return 

442 details["reason"] = self.reason 

443 

444 self.component.check_template_valid() 

445 

446 # List of updated units (used for cleanup and duplicates detection) 

447 updated: dict[int, Unit] = {} 

448 

449 try: 

450 store = self.store 

451 translation_store = None 

452 

453 try: 

454 store_units = store.content_units 

455 except ValueError as error: 

456 raise FileParseError(str(error)) from error 

457 

458 self.log_info( 

459 "processing %s, %s, %d strings", 

460 self.filename, 

461 self.reason, 

462 len(store_units), 

463 ) 

464 

465 # Store plural 

466 plural = store.get_plural(self.language, store) 

467 if plural != self.plural: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true

468 self.plural = plural 

469 self.save(update_fields=["plural"]) 

470 

471 # Select all current units for update 

472 dbunits = { 

473 unit.id_hash: unit 

474 for unit in self.unit_set.prefetch_bulk().select_for_update() 

475 } 

476 duplicates: list[Unit] = [] 

477 

478 # Process based on intermediate store if available 

479 if self.component.intermediate: 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 translation_store = store 

481 store = self.load_store(force_intermediate=True) 

482 try: 

483 store_units = store.content_units 

484 except ValueError as error: 

485 raise FileParseError(str(error)) from error 

486 

487 for pos, unit in enumerate(store_units): 

488 # Use translation store if exists and if it contains the string 

489 if translation_store is not None: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true

490 try: 

491 translated_unit, created = translation_store.find_unit( 

492 unit.context, unit.source 

493 ) 

494 if translated_unit and not created: 

495 unit = translated_unit 

496 else: 

497 # Patch unit to have matching source 

498 unit.source = translated_unit.source 

499 except UnitNotFoundError: 

500 pass 

501 if ( 501 ↛ 507line 501 didn't jump to line 507 because the condition on line 501 was never true

502 self.component.file_format_cls.monolingual 

503 and self.component.key_filter_re 

504 and self.component.key_filter_re.match(unit.context) is None 

505 ): 

506 # This is where the key filtering take place 

507 self.log_info( 

508 "Doesn't match with key_filter, skipping: %s (%s)", 

509 unit.context, 

510 repr(unit.source), 

511 ) 

512 continue 

513 

514 try: 

515 id_hash = unit.id_hash 

516 except Exception as error: 

517 self.component.handle_parse_error(error, self) 

518 

519 # Check for possible duplicate units 

520 if id_hash in updated: 

521 newunit = updated[id_hash] 

522 self.log_warning( 

523 "duplicate string to translate: %s (%s)", 

524 newunit, 

525 repr(newunit.source), 

526 ) 

527 duplicates.append(newunit) 

528 continue 

529 

530 # Collect source strings 

531 newunit = self.pre_process_unit( 

532 dbunits=dbunits, 

533 id_hash=id_hash, 

534 unit=unit, 

535 pos=pos + 1, 

536 ) 

537 

538 # Store current unit ID 

539 updated[id_hash] = newunit 

540 

541 # Create source strings 

542 if not self.is_source and not self.component.template: 

543 with sentry_sdk.start_span( 

544 op="component.bulk_create_source", name=self.full_slug 

545 ): 

546 self.component.bulk_create_sources( 

547 [ 

548 newunit.unit_attributes 

549 for newunit in updated.values() 

550 if newunit.unit_attributes is not None 

551 ] 

552 ) 

553 

554 # Create/update translations 

555 for newunit in updated.values(): 

556 with sentry_sdk.start_span( 

557 op="unit.update_from_unit", name=f"{self.full_slug}:{pos}" 

558 ): 

559 newunit.update_from_unit(user=user, author=author) 

560 

561 # Trigger duplicate alerts 

562 for newunit in duplicates: 

563 self.component.trigger_alert( 

564 "DuplicateString", 

565 language_code=self.language.code, 

566 source=newunit.source, 

567 unit_pk=newunit.pk, 

568 ) 

569 

570 except FileParseError as error: 

571 report_error( 

572 "Could not parse file on update", project=self.component.project 

573 ) 

574 self.log_warning("skipping update due to parse error: %s", error) 

575 self.store_update_changes() 

576 return 

577 

578 # Delete stale units 

579 stale = set(dbunits) - set(updated) 

580 if stale: 

581 self.log_info("deleting %d stale strings", len(stale)) 

582 self.unit_set.filter(id_hash__in=stale).delete() 

583 self.component.needs_cleanup = True 

584 

585 # We should also do cleanup on source strings tracking objects 

586 

587 # Update revision and stats 

588 self.store_hash() 

589 

590 # Store change entry 

591 self.update_changes.append( 

592 Change( 

593 translation=self, 

594 action=change, 

595 user=user, 

596 author=author, 

597 details=details, 

598 ) 

599 ) 

600 

601 self.store_update_changes() 

602 

603 # Invalidate keys cache 

604 transaction.on_commit(self.invalidate_keys) 

605 self.log_info("updating completed") 

606 

607 # Use up to date list as prefetch for source 

608 if self.is_source: 

609 self.component.preload_sources(updated) 

610 

611 # Unload the store, this is intentionally in a way that it would break 

612 # further consumers as no further consumer is expected after this and 

613 # we do not want to parse the file again. 

614 self.__dict__["store"] = None 

615 

616 def store_update_changes(self) -> None: 

617 # Save change 

618 Change.objects.bulk_create(self.update_changes, batch_size=500) 

619 self.update_changes.clear() 

620 

621 def do_update( 

622 self, request: AuthenticatedHttpRequest | None = None, method: str | None = None 

623 ): 

624 return self.component.do_update(request, method=method) 

625 

626 def do_push(self, request: AuthenticatedHttpRequest | None = None): 

627 return self.component.do_push(request) 

628 

629 def do_reset( 

630 self, 

631 request: AuthenticatedHttpRequest | None = None, 

632 *, 

633 keep_changes: bool = False, 

634 ) -> bool: 

635 return self.component.do_reset(request, keep_changes=keep_changes) 

636 

637 def do_cleanup(self, request: AuthenticatedHttpRequest | None = None): 

638 return self.component.do_cleanup(request) 

639 

640 def do_file_sync(self, request: AuthenticatedHttpRequest | None = None): 

641 return self.component.do_file_sync(request) 

642 

643 def do_file_scan(self, request: AuthenticatedHttpRequest | None = None): 

644 return self.component.do_file_scan(request) 

645 

646 def can_push(self) -> bool: 

647 return self.component.can_push() 

648 

649 def has_push_configuration(self) -> bool: 

650 return self.component.has_push_configuration() 

651 

652 def get_hash_filenames(self) -> list[str]: 

653 """Return filenames to include in the hash.""" 

654 component = self.component 

655 filenames = [self.get_filename()] 

656 

657 if component.has_template(): 657 ↛ 659line 657 didn't jump to line 659 because the condition on line 657 was never true

658 # Include template 

659 filenames.append(component.get_template_filename()) 

660 

661 filename = component.get_intermediate_filename() 

662 if component.intermediate and os.path.exists(filename): 

663 # Include intermediate language as it might add new strings 

664 filenames.append(filename) 

665 

666 return filenames 

667 

668 def get_git_blob_hash(self) -> str: 

669 """Return current VCS blob hash for file.""" 

670 get_object_hash = self.component.repository.get_object_hash 

671 filenames = self.get_hash_filenames() 

672 return ",".join(get_object_hash(filename) for filename in filenames) 

673 

674 def store_hash(self) -> None: 

675 """Store current hash in database.""" 

676 self.revision = self.get_git_blob_hash() 

677 self.save(update_fields=["revision"]) 

678 

679 def get_last_author(self): 

680 """Return last author of change done in Weblate.""" 

681 if not self.stats.last_author: 

682 return None 

683 from weblate.auth.models import User 

684 

685 return User.objects.get(pk=self.stats.last_author).get_visible_name() 

686 

687 @transaction.atomic 

688 def commit_pending(self, reason: str, user: User | None, skip_push: bool = False): 

689 """Commit any pending changes.""" 

690 if not self.needs_commit(): 690 ↛ 692line 690 didn't jump to line 692 because the condition on line 690 was always true

691 return False 

692 return self.component.commit_pending(reason, user, skip_push=skip_push) 

693 

694 @transaction.atomic 

695 def _commit_pending(self, reason: str, user: User | None) -> bool: 

696 """ 

697 Commit pending translation. 

698 

699 Assumptions: 

700 

701 - repository lock is held 

702 - the source translation needs to be committed first 

703 - signals and alerts are updated by the caller 

704 - repository push is handled by the caller 

705 """ 

706 try: 

707 store = self.store 

708 except FileParseError as error: 

709 report_error( 

710 "Could not parse file on commit", project=self.component.project 

711 ) 

712 self.log_error("skipping commit due to error: %s", error) 

713 return False 

714 

715 try: 

716 store.ensure_index() 

717 except ValueError as error: 

718 report_error( 

719 "Could not parse file on commit", project=self.component.project 

720 ) 

721 self.log_error("skipping commit due to error: %s", error) 

722 return False 

723 

724 pending_changes = list( 

725 PendingUnitChange.objects.for_translation(self) 

726 .prefetch_related("unit", "author") 

727 .order_by("timestamp") 

728 .select_for_update() 

729 ) 

730 

731 if not pending_changes: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true

732 return False 

733 

734 self.log_info( 

735 "committing %d pending changes (%s)", len(pending_changes), reason 

736 ) 

737 

738 all_changes_status = {} 

739 units_to_clear_disk_state = set() # Track units for disk_state clearing 

740 

741 commit_groups = self._group_changes_by_author(pending_changes) 

742 for author, changes in commit_groups: 

743 author_name = author.get_author_name() 

744 timestamp = max(change.timestamp for change in changes) 

745 

746 # Flush the grouped pending changes for this author 

747 changes_status = self.update_units(changes, store, author_name) 

748 all_changes_status.update(changes_status) 

749 

750 # Track successful units 

751 for change in changes: 

752 if changes_status.get(change.pk): 752 ↛ 751line 752 didn't jump to line 751 because the condition on line 752 was always true

753 units_to_clear_disk_state.add(change.unit_id) 

754 

755 # Commit changes if there was anything written out 

756 if any(changes_status.values()): 756 ↛ 742line 756 didn't jump to line 742 because the condition on line 756 was always true

757 self.git_commit( 

758 user, author_name, timestamp, skip_push=True, signals=False 

759 ) 

760 

761 # Short-circuit when no changes were processed 

762 if not any(all_changes_status.values()): 762 ↛ 763line 762 didn't jump to line 763 because the condition on line 762 was never true

763 return False 

764 

765 # A pending change can be deleted from the database: 

766 # 1. Always, if it has been applied successfully. 

767 # 2. If it failed to apply, it can be removed only if a newer pending 

768 # change targeting the same unit has since been successfully applied. 

769 # In some cases, a previously failed change may be resolved by a subsequent 

770 # change to the unit. Failed pending changes are not retried on every commit 

771 # attempt and hence may be absent from the list of pending changes being 

772 # currently processed. Hence, we track the latest timestamp up to which 

773 # changes to a unit have been applied successfully and delete all pending 

774 # changes for the unit until that timestamp. 

775 success_times = {} 

776 for change in pending_changes: 

777 if all_changes_status.get(change.pk): 777 ↛ 776line 777 didn't jump to line 776 because the condition on line 777 was always true

778 prev = success_times.get(change.unit_id) 

779 if prev is None or change.timestamp > prev: 779 ↛ 776line 779 didn't jump to line 776 because the condition on line 779 was always true

780 success_times[change.unit_id] = change.timestamp 

781 

782 for unit_id, ts in success_times.items(): 

783 PendingUnitChange.objects.filter( 

784 unit_id=unit_id, timestamp__lte=ts 

785 ).delete() 

786 

787 # Clear disk states for units that have no more pending changes 

788 units_with_remaining_changes = set( 

789 PendingUnitChange.objects.filter( 

790 unit_id__in=units_to_clear_disk_state 

791 ).values_list("unit_id", flat=True) 

792 ) 

793 

794 units_to_actually_clear = ( 

795 units_to_clear_disk_state - units_with_remaining_changes 

796 ) 

797 

798 if units_to_actually_clear: 

799 units_to_update = list( 

800 Unit.objects.filter( 

801 id__in=units_to_actually_clear, details__has_key="disk_state" 

802 ).select_for_update() 

803 ) 

804 

805 for unit in units_to_update: 

806 del unit.details["disk_state"] 

807 

808 if units_to_update: 808 ↛ 812line 808 didn't jump to line 812 because the condition on line 808 was always true

809 Unit.objects.bulk_update(units_to_update, ["details"], batch_size=500) 

810 

811 # Update stats (the translated flag might have changed) 

812 self.invalidate_cache() 

813 

814 # Make sure template cache is purged upon commit 

815 self.drop_store_cache() 

816 

817 return True 

818 

819 @staticmethod 

820 def _group_changes_by_author( 

821 pending_changes: list[PendingUnitChange], 

822 ) -> list[tuple[User, list[PendingUnitChange]]]: 

823 """ 

824 Organize pending changes into groups where each group represents the set of changes that can be safely committed together. 

825 

826 Assuming pending_changes is sorted by timestamp in ascending order, the changes are 

827 grouped according to the following logic: 

828 

829 1. Maintain active groups per author in a dict ({author_id: {"changes": [...], "units": {...}}}) 

830 2. For each pending change, check if the unit conflicts with ANY active group EXCEPT the change author's own group. 

831 3. If conflict found: finalize conflicting group(s) 

832 4. Add change to the author's existing group or create a new one if needed 

833 

834 Example: 

835 Consider the following changes were made in chronological order: 

836 

837 - A1: Author 1 edits Unit A -> Group 1: Author1 

838 - A2: Author 2 edits Unit A -> Unit A conflicts -> Finalize Group 1, start Group 2: Author2 

839 - B3: Author 1 edits Unit B -> No conflicts -> Start Group 3: Author1 

840 - C4: Author 2 edits Unit C -> No conflicts -> Add to Group 2: Author2 

841 - B5: Author 1 edits Unit B -> conflicts with own change -> Do not break group, Add to Group 3: Author1 

842 - D6: Author 1 edits Unit D -> No conflicts -> Add to Group 3: Author1 

843 

844 Result: 

845 - Commit 1: Author1 [A1] 

846 - Commit 2: Author2 [A2, C4] 

847 - Commit 3: Author1 [B3, B5, D6] 

848 

849 """ 

850 author_map = { 

851 change.author.id: change.author 

852 for change in pending_changes 

853 if change.author 

854 } 

855 

856 active_groups = {} 

857 finalized_groups = [] 

858 

859 for change in pending_changes: 

860 unit_id = change.unit.id 

861 author_id = change.author.id 

862 

863 conflicting_authors = [] 

864 for other_author_id, group_data in active_groups.items(): 

865 if unit_id in group_data["units"] and author_id != other_author_id: 865 ↛ 866line 865 didn't jump to line 866 because the condition on line 865 was never true

866 conflicting_authors.append(other_author_id) 

867 

868 for conflicting_author_id in conflicting_authors: 868 ↛ 869line 868 didn't jump to line 869 because the loop on line 868 never started

869 group_data = active_groups.pop(conflicting_author_id) 

870 author = author_map[conflicting_author_id] 

871 finalized_groups.append((author, group_data["changes"])) 

872 

873 if author_id in active_groups: 

874 active_groups[author_id]["changes"].append(change) 

875 active_groups[author_id]["units"].add(unit_id) 

876 else: 

877 active_groups[author_id] = {"changes": [change], "units": {unit_id}} 

878 

879 for author_id, group_data in active_groups.items(): 

880 author = author_map[author_id] 

881 finalized_groups.append((author, group_data["changes"])) 

882 

883 return finalized_groups 

884 

885 def get_commit_message(self, author: str, template: str, **kwargs): 

886 """Format commit message based on project configuration.""" 

887 return render_template(template, translation=self, author=author, **kwargs) 

888 

889 @property 

890 def count_pending_units(self): 

891 """Return count of units with pending changes.""" 

892 return PendingUnitChange.objects.for_translation(self).count() 

893 

894 def needs_commit(self): 

895 """Check whether there are some not committed changes.""" 

896 return self.count_pending_units > 0 

897 

898 def repo_needs_merge(self): 

899 return self.component.repo_needs_merge() 

900 

901 def repo_needs_push(self): 

902 return self.component.repo_needs_push() 

903 

904 @cached_property 

905 def filenames(self) -> list[str]: 

906 if not self.filename: 906 ↛ 907line 906 didn't jump to line 907 because the condition on line 906 was never true

907 return [] 

908 if self.component.file_format_cls.simple_filename: 908 ↛ 910line 908 didn't jump to line 910 because the condition on line 908 was always true

909 return [self.get_filename()] 

910 return self.store.get_filenames() 

911 

912 def git_commit( 

913 self, 

914 user: User | None, 

915 author: str, 

916 timestamp: datetime | None = None, 

917 skip_push=False, 

918 signals=True, 

919 template: str | None = None, 

920 store_hash: bool = True, 

921 ) -> bool: 

922 """Commit translation to git.""" 

923 repository = self.component.repository 

924 if template is None: 

925 template = self.component.commit_message 

926 with repository.lock: 

927 # Pre commit hook 

928 vcs_pre_commit.send( 

929 sender=self.__class__, 

930 translation=self, 

931 author=author, 

932 store_hash=store_hash, 

933 ) 

934 

935 # Do actual commit with git lock 

936 if self.component.commit_files( 

937 template=template, 

938 author=author, 

939 timestamp=timestamp, 

940 skip_push=skip_push, 

941 signals=signals, 

942 files=self.filenames + self.addon_commit_files, 

943 extra_context={"translation": self}, 

944 store_hash=store_hash, 

945 ): 

946 self.log_info("committed %s as %s", self.filenames, author) 

947 self.change_set.create( 

948 action=ActionEvents.COMMIT, user=user, author=user 

949 ) 

950 

951 # Store updated hash 

952 if store_hash: 

953 self.store_hash() 

954 self.addon_commit_files = [] 

955 

956 return True 

957 

958 def update_units( 

959 self, 

960 pending_changes: list[PendingUnitChange], 

961 store: TranslationFormat, 

962 author_name: str, 

963 ) -> dict[int, bool]: 

964 """Update backend file and unit.""" 

965 changes_status = {} 

966 updated = False 

967 for pending_change in pending_changes: 

968 unit = pending_change.unit 

969 

970 # update the unit in memory so that get_target_plurals returns 

971 # the correct result for this change 

972 unit.target = pending_change.target 

973 

974 if pending_change.add_unit: 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true

975 pounit = store.new_unit( 

976 unit.context, unit.get_source_plurals(), unit.get_target_plurals() 

977 ) 

978 pounit.set_explanation(pending_change.explanation) 

979 pounit.set_source_explanation(pending_change.source_unit_explanation) 

980 # Check if context has changed while adding to storage 

981 if pounit.context != unit.context: 

982 if self.is_source: 

983 # Update all matching translations 

984 Unit.objects.filter( 

985 context=unit.context, translation__component=self.component 

986 ).update(context=pounit.context) 

987 else: 

988 # Update this unit only 

989 Unit.objects.filter(pk=unit.pk).update(context=pounit.context) 

990 unit.context = pounit.context 

991 

992 changes_status[pending_change.pk] = True 

993 updated = True 

994 else: 

995 try: 

996 pounit, add = store.find_unit(unit.context, unit.source) 

997 except UnitNotFoundError: 

998 # Bail out if we have not found anything 

999 report_error( 

1000 "String disappeared", 

1001 project=self.component.project, 

1002 skip_sentry=True, 

1003 ) 

1004 unit.state = STATE_FUZZY 

1005 # Use update instead of hitting expensive save() 

1006 Unit.objects.filter(pk=unit.pk).update(state=STATE_FUZZY) 

1007 unit.change_set.create( 

1008 action=ActionEvents.SAVE_FAILED, 

1009 target="Could not find string in the translation file", 

1010 ) 

1011 pending_change.metadata.update( 

1012 { 

1013 "last_failed": timezone.now().isoformat(), 

1014 "failed_revision": self.revision, 

1015 "weblate_version": GIT_VERSION, 

1016 "blocking_unit": True, 

1017 } 

1018 ) 

1019 pending_change.save() 

1020 # this should be kept as pending, so that the changes are not lost 

1021 changes_status[pending_change.pk] = False 

1022 continue 

1023 

1024 # Optionally add unit to translation file. 

1025 # This has be done prior setting target as some formats 

1026 # generate content based on target language. 

1027 if add: 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true

1028 store.add_unit(pounit) 

1029 

1030 # Store translations 

1031 try: 

1032 if unit.is_plural: 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true

1033 pounit.set_target(unit.get_target_plurals()) 

1034 else: 

1035 pounit.set_target(pending_change.target) 

1036 pounit.set_explanation(pending_change.explanation) 

1037 pounit.set_source_explanation( 

1038 pending_change.source_unit_explanation 

1039 ) 

1040 except Exception as error: 

1041 report_error( 

1042 "Could not update unit", project=self.component.project 

1043 ) 

1044 unit.state = STATE_FUZZY 

1045 # Use update instead of hitting expensive save() 

1046 Unit.objects.filter(pk=unit.pk).update(state=STATE_FUZZY) 

1047 unit.change_set.create( 

1048 action=ActionEvents.SAVE_FAILED, 

1049 target=self.component.get_parse_error_message(error), 

1050 ) 

1051 

1052 pending_change.metadata.update( 

1053 { 

1054 "last_failed": timezone.now().isoformat(), 

1055 "failed_revision": self.revision, 

1056 "weblate_version": GIT_VERSION, 

1057 "blocking_unit": False, 

1058 } 

1059 ) 

1060 pending_change.save() 

1061 # this should be kept as pending, so that the changes are not lost 

1062 changes_status[pending_change.pk] = False 

1063 continue 

1064 

1065 changes_status[pending_change.pk] = True 

1066 updated = True 

1067 

1068 # Update fuzzy/approved flag 

1069 pounit.set_state(pending_change.state) 

1070 

1071 # Update disk state from the file to the unit 

1072 unit.details["disk_state"] = { 

1073 "target": pounit.target, 

1074 "state": pending_change.state, 

1075 "explanation": pounit.explanation, 

1076 } 

1077 unit.save(update_fields=["details"]) 

1078 

1079 # Did we do any updates? 

1080 if not updated: 1080 ↛ 1081line 1080 didn't jump to line 1081 because the condition on line 1080 was never true

1081 return changes_status 

1082 

1083 # Update po file header 

1084 now = timezone.now() 

1085 if not timezone.is_aware(now): 1085 ↛ 1086line 1085 didn't jump to line 1086 because the condition on line 1085 was never true

1086 now = timezone.make_aware(now, UTC) 

1087 

1088 # Prepare headers to update 

1089 headers = { 

1090 "last_translator": author_name, 

1091 "plural_forms": self.plural.plural_form, 

1092 "language": self.language_code, 

1093 "PO_Revision_Date": now.strftime("%Y-%m-%d %H:%M%z"), 

1094 } 

1095 

1096 # Optionally store language team with link to website 

1097 if self.component.project.set_language_team: 

1098 headers["language_team"] = ( 

1099 f"{self.language.name} <{get_site_url(self.get_absolute_url())}>" 

1100 ) 

1101 

1102 # Optionally store email for reporting bugs in source 

1103 report_source_bugs = self.component.report_source_bugs 

1104 if report_source_bugs: 

1105 headers["report_msgid_bugs_to"] = report_source_bugs 

1106 

1107 # Update generic headers 

1108 store.update_header(**headers) 

1109 

1110 # save translation changes 

1111 store.save() 

1112 

1113 return changes_status 

1114 

1115 @cached_property 

1116 def workflow_settings(self): 

1117 return self.component.project.project_languages[self.language].workflow_settings 

1118 

1119 @cached_property 

1120 def enable_review(self): 

1121 project = self.component.project 

1122 project_review = ( 

1123 project.source_review if self.is_source else project.translation_review 

1124 ) 

1125 if not project_review: 1125 ↛ 1127line 1125 didn't jump to line 1127 because the condition on line 1125 was always true

1126 return False 

1127 if self.workflow_settings is not None: 

1128 return self.workflow_settings.translation_review 

1129 return project_review 

1130 

1131 @property 

1132 def enable_suggestions(self): 

1133 if self.workflow_settings is not None: 1133 ↛ 1134line 1133 didn't jump to line 1134 because the condition on line 1133 was never true

1134 return self.workflow_settings.enable_suggestions 

1135 return self.component.enable_suggestions 

1136 

1137 @property 

1138 def suggestion_voting(self): 

1139 if self.workflow_settings is not None: 1139 ↛ 1140line 1139 didn't jump to line 1140 because the condition on line 1139 was never true

1140 return self.workflow_settings.suggestion_voting 

1141 return self.component.suggestion_voting 

1142 

1143 @property 

1144 def suggestion_autoaccept(self): 

1145 if self.workflow_settings is not None: 

1146 return self.workflow_settings.suggestion_autoaccept 

1147 return self.component.suggestion_autoaccept 

1148 

1149 def log_upload_not_found( 

1150 self, not_found_log: list[str], unit: TranslationUnit 

1151 ) -> None: 

1152 not_found_log.append(unit.source) 

1153 

1154 def show_upload_not_found( 

1155 self, 

1156 request: AuthenticatedHttpRequest, 

1157 not_found_log: list[str], 

1158 string_limit: int = 8, 

1159 ) -> None: 

1160 count = len(not_found_log) 

1161 

1162 strings = format_html_join_comma( 

1163 gettext("“{}”"), ((string,) for string in not_found_log[:string_limit]) 

1164 ) 

1165 if count > string_limit: 

1166 strings = format_html_join_comma("{}", [(strings,), (gettext("…"),)]) 

1167 

1168 messages.warning( 

1169 request, 

1170 format_html( 

1171 "{} {}", 

1172 ngettext( 

1173 "Could not find %d string:", "Could not find %d strings:", count 

1174 ) 

1175 % count, 

1176 strings, 

1177 ), 

1178 ) 

1179 

1180 def merge_translations( 

1181 self, 

1182 request: AuthenticatedHttpRequest, 

1183 author: User, 

1184 store2: TranslationFormat, 

1185 conflicts: Literal["", "replace-approved", "replace-translated"], 

1186 method: Literal["fuzzy", "approve", "translate"], 

1187 fuzzy: Literal["", "process", "approve"], 

1188 ): 

1189 """ 

1190 Merge translation unit wise. 

1191 

1192 Needed for template based translations to add new strings. 

1193 """ 

1194 skipped = 0 

1195 accepted = 0 

1196 add_fuzzy = method == "fuzzy" 

1197 add_approve = method == "approve" 

1198 not_found_log: list[str] = [] 

1199 

1200 # Are there any translations to propagate? 

1201 # This is just an optimalization to avoid doing that for every unit. 

1202 propagate = ( 

1203 Translation.objects.filter( 

1204 language=self.language, 

1205 component__source_language_id=self.component.source_language_id, 

1206 component__project=self.component.project, 

1207 ) 

1208 .filter(component__allow_translation_propagation=True) 

1209 .exclude(pk=self.pk) 

1210 .exists() 

1211 ) 

1212 

1213 unit_set = self.unit_set.select_for_update() 

1214 

1215 for set_fuzzy, unit2 in store2.iterate_merge(fuzzy): 

1216 try: 

1217 unit = unit_set.get_unit(unit2) 

1218 except Unit.DoesNotExist: 

1219 self.log_upload_not_found(not_found_log, unit2) 

1220 continue 

1221 

1222 state = STATE_TRANSLATED 

1223 if add_fuzzy or set_fuzzy: 

1224 state = STATE_FUZZY 

1225 elif add_approve: 

1226 state = STATE_APPROVED 

1227 

1228 if ( 

1229 (unit.translated and not conflicts) 

1230 or (unit.approved and conflicts != "replace-approved") 

1231 or unit.readonly 

1232 or (not request.user.has_perm("unit.edit", unit)) 

1233 or (unit.target == unit2.target and unit.state == state) 

1234 ): 

1235 skipped += 1 

1236 continue 

1237 

1238 accepted += 1 

1239 

1240 unit.translate( 

1241 request.user, 

1242 split_plural(unit2.target), 

1243 state, 

1244 change_action=ActionEvents.UPLOAD, 

1245 propagate=propagate, 

1246 author=author, 

1247 request=request, 

1248 ) 

1249 

1250 if accepted > 0: 

1251 self.invalidate_cache() 

1252 request.user.profile.increase_count("translated", accepted) 

1253 

1254 if not_found_log: 

1255 self.show_upload_not_found(request, not_found_log) 

1256 

1257 return (len(not_found_log), skipped, accepted, len(store2.content_units)) 

1258 

1259 def merge_suggestions( 

1260 self, request: AuthenticatedHttpRequest, author: User, store, fuzzy 

1261 ): 

1262 """Merge content of translate-toolkit store as a suggestions.""" 

1263 skipped = 0 

1264 accepted = 0 

1265 not_found_log: list[str] = [] 

1266 

1267 unit_set = self.unit_set.all() 

1268 

1269 for _unused, unit in store.iterate_merge(fuzzy): 

1270 # Grab database unit 

1271 try: 

1272 dbunit = unit_set.get_unit(unit) 

1273 except Unit.DoesNotExist: 

1274 self.log_upload_not_found(not_found_log, unit) 

1275 continue 

1276 

1277 # Add suggestion 

1278 current_target = dbunit.get_target_plurals() 

1279 new_target = unit.target 

1280 if isinstance(new_target, str): 

1281 new_target = [new_target] 

1282 if current_target != new_target and not dbunit.readonly: 

1283 if Suggestion.objects.add( 

1284 dbunit, 

1285 new_target, 

1286 request, 

1287 raise_exception=False, 

1288 user=author, 

1289 ): 

1290 accepted += 1 

1291 else: 

1292 skipped += 1 

1293 else: 

1294 skipped += 1 

1295 

1296 # Update suggestion count 

1297 if accepted > 0: 

1298 self.invalidate_cache() 

1299 

1300 if not_found_log: 

1301 self.show_upload_not_found(request, not_found_log) 

1302 

1303 return (len(not_found_log), skipped, accepted, len(store.content_units)) 

1304 

1305 def drop_store_cache(self) -> None: 

1306 if "store" in self.__dict__: 1306 ↛ 1308line 1306 didn't jump to line 1308 because the condition on line 1306 was always true

1307 del self.__dict__["store"] 

1308 if self.is_source: 

1309 self.component.drop_template_store_cache() 

1310 

1311 def handle_upload_store_change( 

1312 self, 

1313 request: AuthenticatedHttpRequest, 

1314 author: User, 

1315 change_action: ActionEvents, 

1316 ) -> None: 

1317 component = self.component 

1318 if not component.repository.needs_commit(self.filenames): 

1319 return 

1320 

1321 self.create_unit_change_action = ActionEvents.NEW_UNIT_UPLOAD 

1322 self.update_unit_change_action = ActionEvents.STRING_UPLOAD_UPDATE 

1323 self.component.acting_user = author 

1324 

1325 previous_revision = component.repository.last_revision 

1326 self.drop_store_cache() 

1327 

1328 self.git_commit( 

1329 author, 

1330 author=author.get_author_name(), 

1331 store_hash=False, 

1332 signals=False, 

1333 ) 

1334 

1335 self.handle_store_change( 

1336 request, author, previous_revision, change=change_action 

1337 ) 

1338 # Emit signals later to avoid cleanup add-on to store translation 

1339 # revision before parsing 

1340 component.send_post_commit_signal() 

1341 

1342 self.create_unit_change_action = ActionEvents.NEW_UNIT_REPO 

1343 self.update_unit_change_action = ActionEvents.STRING_REPO_UPDATE 

1344 

1345 def handle_source( 

1346 self, request: AuthenticatedHttpRequest, author: User, fileobj: BinaryIO 

1347 ) -> UploadResult: 

1348 """Replace source translations with uploaded one.""" 

1349 component = self.component 

1350 filenames = [] 

1351 with component.repository.lock: 

1352 # Commit pending changes 

1353 try: 

1354 component.commit_pending("source update", author) 

1355 except Exception as error: 

1356 raise FailedCommitError( 

1357 gettext("Could not commit pending changes: %s") 

1358 % str(error).replace(self.component.full_path, "") 

1359 ) from error 

1360 

1361 # Create actual file with the uploaded content 

1362 with tempfile.NamedTemporaryFile( 

1363 prefix="weblate-upload", dir=self.component.full_path, delete=False 

1364 ) as temp: 

1365 temp.write(fileobj.read()) 

1366 

1367 try: 

1368 # Prepare msgmerge args based on add-ons (if configured) 

1369 args = self.component.file_format_cls.get_msgmerge_args(component) 

1370 # Update translation files 

1371 for translation in component.translation_set.exclude( 

1372 language=component.source_language 

1373 ): 

1374 filename = translation.get_filename() 

1375 component.file_format_cls.update_bilingual( 

1376 filename, temp.name, args=args 

1377 ) 

1378 filenames.append(filename) 

1379 finally: 

1380 if os.path.exists(temp.name): 

1381 if component.new_base: 

1382 filename = component.get_new_base_filename() 

1383 os.replace(temp.name, filename) 

1384 filenames.append(filename) 

1385 else: 

1386 os.unlink(temp.name) 

1387 

1388 # Commit changes 

1389 self.handle_upload_store_change( 

1390 request, author, change_action=ActionEvents.SOURCE_UPLOAD 

1391 ) 

1392 return (0, 0, self.unit_set.count(), self.unit_set.count()) 

1393 

1394 def handle_replace( 

1395 self, request: AuthenticatedHttpRequest, author: User, fileobj: BinaryIO 

1396 ) -> UploadResult: 

1397 """Replace file content with uploaded one.""" 

1398 filecopy = fileobj.read() 

1399 fileobj.close() 

1400 fileobj = NamedBytesIO(fileobj.name, filecopy) 

1401 self.unit_set.select_for_update() 

1402 with self.component.repository.lock: 

1403 try: 

1404 if self.is_source: 

1405 self.component.commit_pending("replace file", author) 

1406 else: 

1407 self.commit_pending("replace file", author) 

1408 except Exception as error: 

1409 raise FailedCommitError( 

1410 gettext("Could not commit pending changes: %s") 

1411 % str(error).replace(self.component.full_path, "") 

1412 ) from error 

1413 # This will throw an exception in case of error 

1414 store2 = self.load_store(fileobj) 

1415 store2.check_valid() 

1416 

1417 # Actually replace file content 

1418 self.component.file_format_cls.save_atomic( 

1419 self.get_filename(), lambda handle: handle.write(filecopy) 

1420 ) 

1421 

1422 # Commit to VCS 

1423 self.handle_upload_store_change( 

1424 request, author, change_action=ActionEvents.REPLACE_UPLOAD 

1425 ) 

1426 

1427 return (0, 0, self.unit_set.count(), len(store2.content_units)) 

1428 

1429 def handle_add_upload( 

1430 self, 

1431 request: AuthenticatedHttpRequest, 

1432 author: User, 

1433 store: TranslationFormat, 

1434 fuzzy: Literal["", "process", "approve"] = "", 

1435 ) -> UploadResult: 

1436 component = self.component 

1437 has_template = component.has_template() 

1438 skipped = 0 

1439 accepted = 0 

1440 component.start_batched_checks() 

1441 existing: set[str] | set[tuple[str, str]] 

1442 if has_template: 

1443 existing = set(self.unit_set.values_list("context", flat=True)) 

1444 else: 

1445 existing = set(self.unit_set.values_list("context", "source")) 

1446 for _set_fuzzy, unit in store.iterate_merge(fuzzy, only_translated=False): 

1447 idkey = unit.context if has_template else (unit.context, unit.source) 

1448 if idkey in existing: 

1449 skipped += 1 

1450 continue 

1451 self.add_unit( 

1452 request, 

1453 unit.context, 

1454 split_plural(unit.source), 

1455 split_plural(unit.target) if not self.is_source else [], 

1456 is_batch_update=True, 

1457 state=STATE_READONLY if unit.is_readonly() else None, 

1458 ) 

1459 existing.add(idkey) 

1460 accepted += 1 

1461 self.store_update_changes() 

1462 component.invalidate_cache() 

1463 if component.needs_variants_update: 

1464 component.update_variants() 

1465 component.schedule_sync_terminology() 

1466 component.update_source_checks() 

1467 component.run_batched_checks() 

1468 component_post_update.send(sender=self.__class__, component=component) 

1469 return (0, skipped, accepted, len(store.content_units)) 

1470 

1471 def load_uploaded_file( 

1472 self, 

1473 request: AuthenticatedHttpRequest, 

1474 fileobj: BinaryIO, 

1475 method: Literal["fuzzy", "approve", "translate"], 

1476 ) -> TranslationFormat: 

1477 component = self.component 

1478 

1479 filecopy = fileobj.read() 

1480 fileobj.close() 

1481 

1482 # Strip possible UTF-8 BOM 

1483 if filecopy[:3] == codecs.BOM_UTF8: 

1484 filecopy = filecopy[3:] 

1485 

1486 # Commit pending changes in template 

1487 if component.has_template() and component.source_translation.needs_commit(): 

1488 try: 

1489 component.commit_pending("upload", request.user) 

1490 except Exception as error: 

1491 raise FailedCommitError( 

1492 gettext("Could not commit pending changes: %s") 

1493 % str(error).replace(self.component.full_path, "") 

1494 ) from error 

1495 

1496 # Load backend file 

1497 if method == "add" and self.is_template: 

1498 template_store = try_load( 

1499 fileobj.name, 

1500 filecopy, 

1501 component.file_format_cls, 

1502 None, 

1503 is_template=True, 

1504 language_code=self.language_code, 

1505 source_language=self.component.source_language.code, 

1506 file_format_params=self.component.file_format_params, 

1507 ) 

1508 

1509 else: 

1510 template_store = component.template_store 

1511 store = try_load( 

1512 fileobj.name, 

1513 filecopy, 

1514 component.file_format_cls, 

1515 template_store, 

1516 language_code=self.language_code, 

1517 source_language=self.component.source_language.code, 

1518 file_format_params=self.component.file_format_params, 

1519 ) 

1520 

1521 # Check valid plural forms 

1522 if hasattr(store.store, "parseheader"): 

1523 header = store.store.parseheader() 

1524 try: 

1525 number, formula = Plural.parse_plural_forms(header["Plural-Forms"]) 

1526 except (ValueError, KeyError): 

1527 # Formula wrong or missing 

1528 pass 

1529 else: 

1530 if not self.plural.same_plural(number, formula): 

1531 raise PluralFormsMismatchError 

1532 return store 

1533 

1534 @transaction.atomic 

1535 def handle_upload( 

1536 self, 

1537 request: AuthenticatedHttpRequest, 

1538 fileobj: BinaryIO, 

1539 conflicts: Literal["", "replace-approved", "replace-translated"], 

1540 author_name: str | None = None, 

1541 author_email: str | None = None, 

1542 method: Literal["fuzzy", "approve", "translate"] = "translate", 

1543 fuzzy: Literal["", "process", "approve"] = "", 

1544 ) -> UploadResult: 

1545 """Top level handler for file uploads.""" 

1546 from weblate.auth.models import User 

1547 

1548 component = self.component 

1549 

1550 # Get User object for author 

1551 author = User.objects.get_author_by_email( 

1552 author_name, author_email, request.user, request 

1553 ) 

1554 result: UploadResult 

1555 

1556 if method == "replace": 

1557 result = self.handle_replace(request, author, fileobj) 

1558 

1559 elif method == "source": 

1560 result = self.handle_source(request, author, fileobj) 

1561 else: 

1562 store = self.load_uploaded_file(request, fileobj, method) 

1563 

1564 if method in {"translate", "fuzzy", "approve"}: 

1565 # Merge on units level 

1566 result = self.merge_translations( 

1567 request, author, store, conflicts, method, fuzzy 

1568 ) 

1569 elif method == "add": 

1570 with component.lock: 

1571 result = self.handle_add_upload(request, author, store, fuzzy=fuzzy) 

1572 else: 

1573 # Add as suggestions 

1574 result = self.merge_suggestions(request, author, store, fuzzy) 

1575 

1576 self.change_set.create( 

1577 action=ActionEvents.FILE_UPLOAD, 

1578 user=request.user, 

1579 author=author, 

1580 details={ 

1581 "method": method, 

1582 "not_found": result[0], 

1583 "skipped": result[1], 

1584 "accepted": result[2], 

1585 "total": result[3], 

1586 }, 

1587 ) 

1588 

1589 return result 

1590 

1591 def _invalidate_trigger(self) -> None: 

1592 self._invalidate_scheduled = False 

1593 self.stats.update_stats() 

1594 self.component.invalidate_glossary_cache() 

1595 

1596 def invalidate_cache(self) -> None: 

1597 """Invalidate any cached stats.""" 

1598 # Invalidate summary stats 

1599 if self._invalidate_scheduled: 

1600 return 

1601 self._invalidate_scheduled = True 

1602 transaction.on_commit(self._invalidate_trigger) 

1603 

1604 def detect_completed_translation(self, change: Change, old_translated: int) -> None: 

1605 translated = self.stats.translated 

1606 if old_translated < translated and translated == self.stats.all: 

1607 self.change_set.create( 

1608 action=ActionEvents.COMPLETE, 

1609 user=change.user, 

1610 author=change.author, 

1611 ) 

1612 

1613 # check if component is fully translated 

1614 component = self.component 

1615 if component.stats.translated == component.stats.all: 

1616 self.component.change_set.create( 

1617 action=ActionEvents.COMPLETED_COMPONENT, 

1618 user=change.user, 

1619 author=change.author, 

1620 ) 

1621 

1622 @property 

1623 def keys_cache_key(self) -> str: 

1624 return f"translation-keys-{self.pk}" 

1625 

1626 def invalidate_keys(self) -> None: 

1627 cache.delete(self.keys_cache_key) 

1628 

1629 def get_export_url(self): 

1630 """Return URL of exported git repository.""" 

1631 return self.component.get_export_url() 

1632 

1633 def remove(self, user: User) -> None: 

1634 """Remove translation from the Database and VCS.""" 

1635 from weblate.glossary.tasks import cleanup_stale_glossaries 

1636 

1637 author = user.get_author_name() 

1638 # Log 

1639 self.log_info("removing %s as %s", self.filenames, author) 

1640 

1641 # Remove file from VCS 

1642 if any(os.path.exists(name) for name in self.filenames): 

1643 with self.component.repository.lock: 

1644 self.component.repository.remove( 

1645 self.filenames, 

1646 self.get_commit_message( 

1647 author, template=self.component.delete_message 

1648 ), 

1649 author, 

1650 ) 

1651 self.component.push_if_needed() 

1652 

1653 # Remove blank directory if still present (appstore) 

1654 filename = Path(self.get_filename()) 

1655 if filename.is_dir(): 

1656 with contextlib.suppress(OSError): 

1657 filename.rmdir() 

1658 

1659 # Delete from the database 

1660 self.delete() 

1661 transaction.on_commit(self.stats.update_parents) 

1662 transaction.on_commit(self.component.schedule_update_checks) 

1663 

1664 # Record change 

1665 self.component.change_set.create( 

1666 action=ActionEvents.REMOVE_TRANSLATION, 

1667 target=self.filename, 

1668 user=user, 

1669 author=user, 

1670 ) 

1671 if not self.component.is_glossary: 

1672 cleanup_stale_glossaries.delay_on_commit(self.component.project.id) 

1673 

1674 def handle_store_change( 

1675 self, 

1676 request: AuthenticatedHttpRequest | None, 

1677 user: User, 

1678 previous_revision: str, 

1679 change=None, 

1680 ) -> None: 

1681 self.drop_store_cache() 

1682 # Explicit stats invalidation is needed here as the unit removal in 

1683 # delete_unit might do changes in the database only and not touch the files 

1684 # for pending new units 

1685 if self.is_source: 

1686 self.component.create_translations(request=request, change=change) 

1687 self.component.invalidate_cache() 

1688 else: 

1689 self.check_sync(request=request, change=change, author=user) 

1690 self.invalidate_cache() 

1691 # Trigger post-update signal 

1692 self.component.trigger_post_update( 

1693 previous_head=previous_revision, 

1694 skip_push=False, 

1695 ) 

1696 

1697 def get_store_change_translations(self) -> list[Translation]: 

1698 component = self.component 

1699 result: list[Translation] = [] 

1700 if self.is_source: 

1701 result.extend(component.translation_set.exclude(id=self.id)) 

1702 # Source is always at the end 

1703 result.append(self) 

1704 return result 

1705 

1706 @overload 

1707 def add_unit( 1707 ↛ anywhereline 1707 didn't jump anywhere: it always raised an exception.

1708 self, 

1709 request: AuthenticatedHttpRequest, 

1710 context: str, 

1711 source: str | list[str], 

1712 target: str | list[str] | None = None, 

1713 *, 

1714 extra_flags: str = "", 

1715 explanation: str = "", 

1716 auto_context: bool = False, 

1717 is_batch_update: bool = False, 

1718 skip_existing: bool = False, 

1719 state: StringState | None = None, 

1720 author: User | None = None, 

1721 ) -> Unit | None: ... 

1722 @overload 

1723 def add_unit( 1723 ↛ anywhereline 1723 didn't jump anywhere: it always raised an exception.

1724 self, 

1725 request: None, 

1726 context: str, 

1727 source: str | list[str], 

1728 target: str | list[str] | None = None, 

1729 *, 

1730 extra_flags: str = "", 

1731 explanation: str = "", 

1732 auto_context: bool = False, 

1733 is_batch_update: bool = False, 

1734 skip_existing: bool = False, 

1735 state: StringState | None = None, 

1736 author: User, 

1737 ) -> Unit | None: ... 

1738 @transaction.atomic 

1739 def add_unit( # noqa: C901, PLR0914, PLR0915, PLR0912 

1740 self, 

1741 request, 

1742 context, 

1743 source, 

1744 target=None, 

1745 *, 

1746 extra_flags="", 

1747 explanation="", 

1748 auto_context=False, 

1749 is_batch_update=False, 

1750 skip_existing=False, 

1751 state=None, 

1752 author=None, 

1753 ): 

1754 if isinstance(source, list): 

1755 source = join_plural(source) 

1756 

1757 parsed_flags = Flags(extra_flags) 

1758 

1759 user = request.user if request else author 

1760 if user is None: 

1761 msg = "Can not add unit without an author!" 

1762 raise ValueError(msg) 

1763 component = self.component 

1764 add_terminology = False 

1765 if is_plural(source) and not component.file_format_cls.supports_plural: 

1766 msg = "Plurals not supported by format!" 

1767 raise ValueError(msg) 

1768 

1769 component_wide = True 

1770 if self.is_source: 

1771 translations = ( 

1772 self, 

1773 *component.translation_set.exclude(id=self.id).select_related( 

1774 "language" 

1775 ), 

1776 ) 

1777 elif component.is_glossary and "terminology" in parsed_flags: 

1778 add_terminology = True 

1779 translations = ( 

1780 component.source_translation, 

1781 *component.translation_set.exclude( 

1782 id=component.source_translation.id 

1783 ).select_related("language"), 

1784 ) 

1785 else: 

1786 component_wide = False 

1787 translations = (component.source_translation, self) 

1788 has_template = component.has_template() 

1789 source_unit = None 

1790 result = None 

1791 

1792 # Automatic context 

1793 if auto_context: 

1794 suffix = 0 

1795 base = context 

1796 filter_args = {"source": source} if not has_template else {} 

1797 while self.unit_set.filter(context=context, **filter_args).exists(): 

1798 suffix += 1 

1799 context = f"{base}{suffix}" 

1800 

1801 unit_ids = [] 

1802 changes = [] 

1803 for translation in translations: 

1804 is_source = translation.is_source 

1805 kwargs = {} 

1806 pending = is_source if has_template else not is_source 

1807 

1808 if (self.is_source and is_source) or (not self.is_source and not is_source): 

1809 kwargs["explanation"] = explanation 

1810 if is_source: 

1811 current_target = source 

1812 kwargs["extra_flags"] = extra_flags 

1813 elif add_terminology and translation != self: 

1814 current_target = "" 

1815 else: 

1816 current_target = target 

1817 if current_target is None: 

1818 current_target = "" 

1819 # Wipe target for untranslatable strings 

1820 if component.is_glossary and "read-only" in parsed_flags: 

1821 current_target = "" 

1822 if isinstance(current_target, list): 

1823 has_translation = any(current_target) 

1824 current_target = join_plural(current_target) 

1825 else: 

1826 has_translation = bool(current_target) 

1827 id_hash = component.file_format_cls.unit_class.calculate_id_hash( 

1828 has_template, source, context 

1829 ) 

1830 # When adding to a target the source string can already exist 

1831 unit = None 

1832 if (skip_existing or not self.is_source) and is_source: 

1833 try: 

1834 unit = component.get_source(id_hash) 

1835 flags = Flags(unit.extra_flags) 

1836 flags.merge(extra_flags) 

1837 new_flags = flags.format() 

1838 if not skip_existing and ( 

1839 unit.extra_flags != new_flags or unit.explanation != explanation 

1840 ): 

1841 unit.extra_flags = new_flags 

1842 unit.explanation = explanation 

1843 unit.save( 

1844 update_fields=["extra_flags", "explanation"], 

1845 same_content=True, 

1846 sync_terminology=False, 

1847 ) 

1848 except Unit.DoesNotExist: 

1849 pass 

1850 if unit is None: 

1851 if "read-only" in translation.all_flags or ( 

1852 component.is_glossary and "read-only" in parsed_flags 

1853 ): 

1854 unit_state = STATE_READONLY 

1855 elif state is None: 

1856 unit_state = STATE_TRANSLATED if has_translation else STATE_EMPTY 

1857 elif has_translation and state == STATE_EMPTY: 

1858 unit_state = STATE_TRANSLATED 

1859 elif not has_translation and state != STATE_EMPTY: 

1860 unit_state = STATE_EMPTY 

1861 else: 

1862 unit_state = state 

1863 unit = Unit( 

1864 translation=translation, 

1865 context=context, 

1866 source=source, 

1867 target=current_target, 

1868 state=unit_state, 

1869 source_unit=source_unit, 

1870 id_hash=id_hash, 

1871 position=translation.stats.all + 1, 

1872 **kwargs, 

1873 ) 

1874 unit.is_batch_update = is_batch_update 

1875 unit.trigger_update_variants = False 

1876 try: 

1877 with transaction.atomic(): 

1878 unit.save( 

1879 force_insert=True, 

1880 sync_terminology=False, 

1881 ) 

1882 if pending: 

1883 PendingUnitChange.store_unit_change( 

1884 unit=unit, author=user, add_unit=True 

1885 ) 

1886 changes.append( 

1887 unit.generate_change( 

1888 user=user, 

1889 author=author or user, 

1890 change_action=ActionEvents.NEW_UNIT, 

1891 check_new=False, 

1892 save=False, 

1893 ) 

1894 ) 

1895 except IntegrityError: 

1896 if not skip_existing: 

1897 raise 

1898 unit = translation.unit_set.get(id_hash=id_hash) 

1899 # The source language is always first in the translations array 

1900 if source_unit is None: 

1901 source_unit = unit 

1902 component._sources[id_hash] = unit # noqa: SLF001 

1903 if translation == self: 

1904 result = unit 

1905 unit_ids.append(unit.pk) 

1906 

1907 if changes: 

1908 if is_batch_update: 

1909 self.update_changes.extend(changes) 

1910 else: 

1911 Change.objects.bulk_create(changes) 

1912 

1913 if not is_batch_update: 

1914 if self.component.needs_variants_update: 

1915 component.update_variants( 

1916 updated_units=Unit.objects.filter(pk__in=unit_ids) 

1917 ) 

1918 if component_wide: 

1919 component.invalidate_cache() 

1920 else: 

1921 for translation in translations: 

1922 translation.invalidate_cache() 

1923 component_post_update.send(sender=self.__class__, component=component) 

1924 return result 

1925 

1926 def notify_deletion(self, unit, user: User) -> None: 

1927 self.change_set.create( 

1928 action=ActionEvents.STRING_REMOVE, 

1929 user=user, 

1930 target=unit.target, 

1931 details={ 

1932 "source": unit.source, 

1933 "target": unit.target, 

1934 "context": unit.context, 

1935 }, 

1936 ) 

1937 

1938 @transaction.atomic 

1939 def delete_unit(self, request: AuthenticatedHttpRequest | None, unit: Unit) -> None: 

1940 from weblate.auth.models import get_anonymous 

1941 

1942 component = self.component 

1943 user = request.user if request else get_anonymous() 

1944 with component.repository.lock: 

1945 component.commit_pending("delete unit", user) 

1946 previous_revision = self.component.repository.last_revision 

1947 cleanup_variants = False 

1948 for translation in self.get_store_change_translations(): 

1949 # Does unit exist here? 

1950 try: 

1951 translation_unit = translation.unit_set.select_for_update().get( 

1952 id_hash=unit.id_hash 

1953 ) 

1954 except ObjectDoesNotExist: 

1955 continue 

1956 # Delete the removed unit from the database 

1957 cleanup_variants |= translation_unit.variant_id is not None 

1958 translation_unit.delete() 

1959 translation.notify_deletion(translation_unit, user) 

1960 # Skip file processing on source language without a storage 

1961 if not translation.filename: 

1962 continue 

1963 # Does unit exist in the file? 

1964 try: 

1965 pounit, needs_add = translation.store.find_unit( 

1966 unit.context, unit.source 

1967 ) 

1968 except UnitNotFoundError: 

1969 needs_add = True 

1970 if not needs_add: 

1971 # Commit changed file 

1972 extra_files = translation.store.remove_unit(pounit.unit) 

1973 translation.addon_commit_files.extend(extra_files) 

1974 translation.drop_store_cache() 

1975 translation.git_commit( 

1976 user, user.get_author_name(), store_hash=False 

1977 ) 

1978 # Adjust position as it will happen in most formats 

1979 if translation_unit.position: 

1980 translation.unit_set.filter( 

1981 position__gt=translation_unit.position 

1982 ).update(position=F("position") - 1) 

1983 # Delete stale source units 

1984 if not self.is_source and translation == self: 

1985 source_unit = translation_unit.source_unit 

1986 if source_unit.source_unit.unit_set.count() == 1: 

1987 source_unit.delete() 

1988 source_unit.translation.notify_deletion(source_unit, user) 

1989 

1990 if self.is_source and unit.position and not component.has_template(): 

1991 # Adjust position is source language 

1992 self.unit_set.filter(position__gt=unit.position).update( 

1993 position=F("position") - 1 

1994 ) 

1995 

1996 if cleanup_variants: 

1997 self.component.update_variants() 

1998 

1999 try: 

2000 alert = self.component.alert_set.get(name="DuplicateString") 

2001 except ObjectDoesNotExist: 

2002 pass 

2003 else: 

2004 occurrences = [ 

2005 item 

2006 for item in alert.details["occurrences"] 

2007 if unit.pk != item["unit_pk"] 

2008 ] 

2009 if not occurrences: 

2010 alert.delete() 

2011 elif occurrences != alert.details["occurrences"]: 

2012 alert.details["occurrences"] = occurrences 

2013 alert.save(update_fields=["details"]) 

2014 

2015 self.handle_store_change(request, user, previous_revision) 

2016 

2017 @transaction.atomic 

2018 def sync_terminology(self) -> None: 

2019 from weblate.auth.models import User 

2020 

2021 if not self.is_source or not self.component.manage_units: 2021 ↛ 2022line 2021 didn't jump to line 2022 because the condition on line 2021 was never true

2022 return 

2023 expected_count = self.component.translation_set.count() 

2024 author: User | None = None 

2025 added: bool = False 

2026 for source in self.component.get_all_sources(): 

2027 # Is the string a terminology 

2028 if "terminology" not in source.all_flags: 2028 ↛ 2030line 2028 didn't jump to line 2030 because the condition on line 2028 was always true

2029 continue 

2030 if source.unit_set.count() == expected_count: 

2031 continue 

2032 added = True 

2033 if author is None: 

2034 author = User.objects.get_or_create_bot( 

2035 scope="glossary", 

2036 name="sync", 

2037 verbose="Glossary sync", 

2038 ) 

2039 # Add unit 

2040 self.add_unit( 

2041 None, 

2042 source.context, 

2043 source.get_source_plurals(), 

2044 "", 

2045 is_batch_update=True, 

2046 skip_existing=True, 

2047 author=author, 

2048 ) 

2049 if added: 2049 ↛ 2050line 2049 didn't jump to line 2050 because the condition on line 2049 was never true

2050 self.store_update_changes() 

2051 self.component.invalidate_cache() 

2052 

2053 def validate_new_unit_data( 

2054 self, 

2055 context: str, 

2056 source: str | list[str], 

2057 target: str | list[str] | None = None, 

2058 auto_context: bool = False, 

2059 extra_flags: str | None = None, 

2060 explanation: str = "", 

2061 state: int | None = None, 

2062 skip_existing: bool = False, 

2063 ) -> None: 

2064 component = self.component 

2065 extra = {} 

2066 if isinstance(source, str): 

2067 source = [source] 

2068 if len(source) > 1 and not component.file_format_cls.supports_plural: 

2069 raise ValidationError( 

2070 gettext("Plurals are not supported by the file format.") 

2071 ) 

2072 for text in chain(source, [context]): 

2073 if any(char in text for char in CONTROLCHARS): 

2074 raise ValidationError( 

2075 gettext("String contains control character: %s") % repr(text) 

2076 ) 

2077 if state is not None: 

2078 if state == STATE_EMPTY and any(source): 

2079 raise ValidationError( 

2080 gettext("Empty state is supported for blank strings only.") 

2081 ) 

2082 if not any(source) and state != STATE_EMPTY: 

2083 raise ValidationError(gettext("Blank strings require an empty state.")) 

2084 if state == STATE_APPROVED and not self.enable_review: 

2085 raise ValidationError( 

2086 gettext( 

2087 "Approved state is not available as reviews are not enabled." 

2088 ) 

2089 ) 

2090 if context: 

2091 component.file_format_cls.validate_context(context) 

2092 if not component.has_template(): 

2093 extra["source"] = join_plural(source) 

2094 if not auto_context and self.unit_set.filter(context=context, **extra).exists(): 

2095 raise ValidationError(gettext("This string seems to already exist.")) 

2096 # Avoid using source translations without a filename 

2097 if not self.filename: 

2098 try: 

2099 translation = component.translation_set.exclude(pk=self.pk)[0] 

2100 except IndexError as error: 

2101 raise ValidationError( 

2102 gettext("Failed adding string, no translation found.") 

2103 ) from error 

2104 translation.validate_new_unit_data( 

2105 context, 

2106 source, 

2107 target, 

2108 auto_context=auto_context, 

2109 extra_flags=extra_flags, 

2110 explanation=explanation, 

2111 ) 

2112 return 

2113 

2114 @property 

2115 def all_repo_components(self): 

2116 return self.component.all_repo_components 

2117 

2118 def get_source_plurals(self): 

2119 """Return blank source fields for pluralized new string.""" 

2120 return [""] * self.plural.number 

2121 

2122 def can_be_deleted(self) -> bool: 

2123 """ 

2124 Check if a glossary can be deleted. 

2125 

2126 It is possible to delete a glossary if: 

2127 - it has no translations 

2128 - it is managed by Weblate (i.e. repo == 'local:') 

2129 """ 

2130 return self.stats.translated == 0 and self.component.repo == "local:" 

2131 

2132 def get_glossaries(self) -> TranslationQuerySet: 

2133 return ( 

2134 Translation.objects.filter( 

2135 component__in=self.component.project.glossaries, language=self.language 

2136 ) 

2137 .prefetch() 

2138 .order() 

2139 ) 

2140 

2141 

2142class GhostTranslation: 

2143 """Ghost translation object used to show missing translations.""" 

2144 

2145 is_ghost = True 

2146 

2147 def __init__(self, project, language, component) -> None: 

2148 self.project = project 

2149 self.language = language 

2150 self.component = component 

2151 self.stats = GhostStats(component.stats) 

2152 self.pk = self.stats.pk 

2153 self.is_source = False 

2154 

2155 def __str__(self) -> str: 

2156 return f"{self.component} — {self.language}" 

2157 

2158 def get_absolute_url(self) -> str: 

2159 return "" 

2160 

2161 def base_obj(self): 

2162 return self.component