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

989 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 operator 

8import re 

9from functools import partial, reduce 

10from typing import TYPE_CHECKING, Any, Literal, TypedDict 

11 

12import sentry_sdk 

13from django.conf import settings 

14from django.core.cache import cache 

15from django.db import Error as DjangoDatabaseError 

16from django.db import models, transaction 

17from django.db.models import Count, Max, Q, Sum, Value 

18from django.db.models.functions import MD5, Length, Lower 

19from django.utils import timezone 

20from django.utils.functional import cached_property 

21from django.utils.translation import gettext, gettext_lazy, ngettext 

22from pyparsing import ParseException 

23 

24from weblate.auth.results import PermissionResult 

25from weblate.checks.flags import Flags 

26from weblate.checks.models import CHECKS, Check 

27from weblate.formats.helpers import CONTROLCHARS 

28from weblate.memory.tasks import handle_unit_translation_change 

29from weblate.memory.utils import is_valid_memory_entry 

30from weblate.trans.actions import ActionEvents 

31from weblate.trans.autofixes import fix_target 

32from weblate.trans.mixins import LoggerMixin 

33from weblate.trans.models.category import Category 

34from weblate.trans.models.change import Change 

35from weblate.trans.models.comment import Comment 

36from weblate.trans.models.pending import PendingUnitChange 

37from weblate.trans.models.project import Project 

38from weblate.trans.models.suggestion import Suggestion 

39from weblate.trans.models.variant import Variant 

40from weblate.trans.signals import unit_post_sync, unit_pre_create 

41from weblate.trans.util import ( 

42 count_words, 

43 get_distinct_translations, 

44 is_plural, 

45 is_unused_string, 

46 join_plural, 

47 split_plural, 

48) 

49from weblate.trans.validators import validate_check_flags 

50from weblate.utils import messages 

51from weblate.utils.db import using_postgresql, verify_in_transaction 

52from weblate.utils.errors import report_error 

53from weblate.utils.hash import calculate_hash, hash_to_checksum 

54from weblate.utils.state import ( 

55 STATE_APPROVED, 

56 STATE_EMPTY, 

57 STATE_FUZZY, 

58 STATE_READONLY, 

59 STATE_TRANSLATED, 

60 StringState, 

61) 

62 

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

64 from collections.abc import Generator, Iterable 

65 from datetime import datetime 

66 

67 from weblate.auth.models import AuthenticatedHttpRequest, User 

68 from weblate.formats.base import TranslationUnit 

69 from weblate.machinery.base import UnitMemoryResultDict 

70 

71 

72NEWLINES = re.compile(r"\r\n|\r|\n") 

73 

74 

75def fill_in_source_translation(units: Iterable[Unit]) -> None: 

76 """ 

77 Inject source translation intro component from the source unit. 

78 

79 This materializes the query. 

80 

81 This assumes prefetch_source() was called before on the query. 

82 """ 

83 for unit in units: 

84 unit.translation.component.source_translation = unit.source_unit.translation 

85 

86 

87class UnitQuerySet(models.QuerySet["Unit"]): 

88 def prefetch(self): 

89 from weblate.trans.models import Component 

90 

91 return self.prefetch_related( 

92 "translation", 

93 "translation__language", 

94 "translation__plural", 

95 models.Prefetch( 

96 "translation__component", queryset=Component.objects.defer_huge() 

97 ), 

98 "translation__component__category", 

99 "translation__component__category__project", 

100 "translation__component__category__category", 

101 "translation__component__category__category__project", 

102 "translation__component__category__category__category", 

103 "translation__component__category__category__category__project", 

104 "translation__component__project", 

105 "translation__component__source_language", 

106 ) 

107 

108 def prefetch_source(self): 

109 from weblate.trans.models import Component 

110 

111 return self.prefetch_related( 

112 "source_unit", 

113 "source_unit__translation", 

114 "source_unit__translation__language", 

115 "source_unit__translation__plural", 

116 models.Prefetch( 

117 "source_unit__translation__component", 

118 queryset=Component.objects.defer_huge(), 

119 ), 

120 "source_unit__translation__component__source_language", 

121 "source_unit__translation__component__project", 

122 ) 

123 

124 def fill_in_source_translation(self): 

125 """ 

126 Inject source translation intro component from the source unit. 

127 

128 This materializes the query. 

129 

130 This assumes prefetch_source() was called before on the query. 

131 """ 

132 fill_in_source_translation(self) 

133 return self 

134 

135 def prefetch_all_checks(self): 

136 return self.prefetch_related( 

137 "source_unit", 

138 models.Prefetch( 

139 "check_set", 

140 to_attr="all_checks", 

141 ), 

142 ) 

143 

144 def count_screenshots(self): 

145 return self.annotate(Count("screenshots")) 

146 

147 def prefetch_full(self): 

148 return ( 

149 self.prefetch_all_checks() 

150 .prefetch_source() 

151 .prefetch_related( 

152 "labels", 

153 models.Prefetch( 

154 "suggestion_set", 

155 queryset=Suggestion.objects.order(), 

156 to_attr="suggestions", 

157 ), 

158 models.Prefetch( 

159 "comment_set", 

160 queryset=Comment.objects.filter(resolved=False), 

161 to_attr="unresolved_comments", 

162 ), 

163 ) 

164 ) 

165 

166 def prefetch_bulk(self): 

167 """Prefetch useful for bulk editing.""" 

168 return self.prefetch_full().prefetch_related("defined_variants") 

169 

170 def search(self, query, **context) -> UnitQuerySet: 

171 """High level wrapper for searching.""" 

172 from weblate.utils.search import parse_query 

173 

174 filters, annotations = parse_query(query, **context) 

175 result = self.annotate(**annotations).filter(filters) 

176 return result.distinct() 

177 

178 def same(self, unit: Unit, exclude: bool = True) -> UnitQuerySet: 

179 """Get units with same source within same project.""" 

180 translation = unit.translation 

181 component = translation.component 

182 result = self.filter( 

183 source__lower__md5=MD5(Lower(Value(unit.source))), 

184 context__lower__md5=MD5(Lower(Value(unit.context))), 

185 source=unit.source, 

186 context=unit.context, 

187 translation__component__project_id=component.project_id, 

188 translation__language_id=translation.language_id, 

189 translation__component__source_language_id=component.source_language_id, 

190 ) 

191 if exclude: 

192 result = result.exclude(pk=unit.id) 

193 return result 

194 

195 def same_target(self, unit: Unit, target: str | None = None) -> UnitQuerySet: 

196 if target is None: 

197 target = unit.target 

198 if not target or not any(split_plural(target)): 

199 return self.none() 

200 translation = unit.translation 

201 component = translation.component 

202 result = self.filter( 

203 state__gte=STATE_TRANSLATED, 

204 target__lower__md5=MD5(Lower(Value(target))), 

205 target=target, 

206 translation__component__project_id=component.project_id, 

207 translation__language_id=translation.language_id, 

208 translation__component__source_language_id=component.source_language_id, 

209 translation__component__allow_translation_propagation=True, 

210 translation__plural_id=translation.plural_id, 

211 translation__plural__number__gt=1, 

212 ).exclude(source=unit.source) 

213 if not unit.translation.language.is_case_sensitive(): 

214 result = result.exclude(source__lower__md5=MD5(Lower(Value(unit.source)))) 

215 return result 

216 

217 def order_by_request(self, form_data, obj) -> UnitQuerySet: 

218 sort_list_request = form_data.get("sort_by", "").split(",") 

219 available_sort_choices = [ 

220 "priority", 

221 "position", 

222 "context", 

223 "num_words", 

224 "labels", 

225 "timestamp", 

226 "last_updated", 

227 "source", 

228 "target", 

229 "location", 

230 "component", 

231 ] 

232 countable_sort_choices: dict[str, dict[str, Any]] = { 

233 "num_comments": {"order_by": "comment__count", "filter": None}, 

234 "num_failing_checks": { 

235 "order_by": "check__count", 

236 "filter": Q(check__dismissed=False), 

237 }, 

238 } 

239 sort_list = [] 

240 for choice in sort_list_request: 

241 unsigned_choice = choice.replace("-", "") 

242 if unsigned_choice in countable_sort_choices: 

243 return self.order_by_count( 

244 choice.replace( 

245 unsigned_choice, 

246 countable_sort_choices[unsigned_choice]["order_by"], 

247 ), 

248 countable_sort_choices[unsigned_choice]["filter"], 

249 ) 

250 if unsigned_choice in available_sort_choices: 

251 if unsigned_choice == "component": 

252 sign = "-" if choice[0] == "-" else "" 

253 sort_list.extend( 

254 [ 

255 sign + "translation__component__priority", 

256 sign + "translation__component__is_glossary", 

257 sign + "translation__component__name", 

258 ] 

259 ) 

260 continue 

261 

262 if unsigned_choice == "labels": 

263 choice = choice.replace("labels", "max_labels_name") 

264 sort_list.append(choice) 

265 if not sort_list: 

266 if hasattr(obj, "component") and obj.component.is_glossary: 

267 sort_list = ["source"] 

268 elif isinstance(obj, (Project, Category)): 

269 sort_list = [ 

270 "translation__component__priority", 

271 "translation__component__is_glossary", 

272 "translation__component__name", 

273 "-priority", 

274 ] 

275 else: 

276 sort_list = ["-priority", "position"] 

277 if "max_labels_name" in sort_list or "-max_labels_name" in sort_list: 

278 return self.annotate(max_labels_name=Max("labels__name")).order_by( 

279 *sort_list 

280 ) 

281 return self.order_by(*sort_list) 

282 

283 def order_by_count(self, choice: str, count_filter) -> UnitQuerySet: 

284 model = choice.split("__", 1)[0].replace("-", "") 

285 annotation_name = choice.replace("-", "") 

286 return self.annotate( 

287 **{annotation_name: Count(model, filter=count_filter)} 

288 ).order_by(choice) 

289 

290 @cached_property 

291 def source_context_lookup(self): 

292 return {(unit.context, unit.source): unit for unit in self} 

293 

294 @cached_property 

295 def source_lookup(self) -> dict[str, Unit]: 

296 return {unit.source: unit for unit in self} 

297 

298 def get_unit(self, ttunit: TranslationUnit) -> Unit: 

299 """ 

300 Find unit matching translate-toolkit unit. 

301 

302 This is used for import, so kind of fuzzy matching is expected. 

303 """ 

304 source = ttunit.source 

305 context = ttunit.context 

306 

307 contexts = [context] 

308 # Special case for XLIFF, strip file 

309 if "///" in context: 

310 contexts.append(context.split("///", 1)[1]) 

311 

312 # Try with empty context if exact context is not found, useful for importing 

313 # monolingual to bilingual 

314 if context: 

315 contexts.append("") 

316 

317 # Lookups based on context 

318 for match in contexts: 

319 try: 

320 return self.source_context_lookup[match, source] 

321 except KeyError: 

322 continue 

323 

324 # Fallback to source string only lookup 

325 try: 

326 return self.source_lookup[source] 

327 except KeyError: 

328 msg = "No matching unit found!" 

329 raise Unit.DoesNotExist(msg) from None 

330 

331 def order(self): 

332 return self.order_by("-priority", "position") 

333 

334 def filter_access(self, user: User): 

335 result = self 

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

337 result = result.filter( 

338 translation__component__project__in=user.allowed_projects 

339 ) 

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

341 result = result.filter( 

342 Q(translation__component__restricted=False) 

343 | Q(translation__component_id__in=user.component_permissions) 

344 ) 

345 return result 

346 

347 def get_ordered(self, ids): 

348 """Return list of units ordered by ID.""" 

349 return sorted(self.filter(id__in=ids), key=lambda unit: ids.index(unit.id)) 

350 

351 def select_for_update(self) -> UnitQuerySet: # type: ignore[override] 

352 if using_postgresql(): 352 ↛ 356line 352 didn't jump to line 356 because the condition on line 352 was always true

353 # Use weaker locking and limit locking to Unit table only 

354 return super().select_for_update(no_key=True, of=("self",)) 

355 # Discard any select_related to avoid locking additional tables 

356 return super().select_for_update().select_related(None) 

357 

358 def annotate_stats(self): 

359 return self.annotate( 

360 strings=Count("pk"), words=Sum("num_words"), chars=Sum(Length("source")) 

361 ) 

362 

363 

364class LabelsField(models.ManyToManyField): 

365 def save_form_data(self, instance, data) -> None: 

366 from weblate.trans.models.label import TRANSLATION_LABELS 

367 

368 super().save_form_data(instance, data) 

369 

370 # Delete translation labels when not checked 

371 new_labels = {label.name for label in data} 

372 through = getattr(instance, self.attname).through.objects 

373 for label in TRANSLATION_LABELS: 

374 if label not in new_labels: 

375 through.filter(unit__source_unit=instance, label__name=label).delete() 

376 

377 

378class OldUnit(TypedDict): 

379 state: StringState 

380 source: str 

381 target: str 

382 context: str 

383 extra_flags: str 

384 explanation: str 

385 

386 

387class UnitAttributesDict(TypedDict): 

388 location: str 

389 explanation: str 

390 source_explanation: str 

391 flags: Flags 

392 source: str 

393 target: str 

394 context: str 

395 note: str 

396 previous_source: str 

397 unit: TranslationUnit 

398 created: bool 

399 pos: int 

400 id_hash: int 

401 

402 

403class Unit(models.Model, LoggerMixin): 

404 translation = models.ForeignKey( 

405 "trans.Translation", on_delete=models.deletion.CASCADE, db_index=False 

406 ) 

407 id_hash = models.BigIntegerField() 

408 location = models.TextField(default="", blank=True) 

409 context = models.TextField(default="", blank=True) 

410 note = models.TextField(default="", blank=True) 

411 flags = models.TextField(default="", blank=True) 

412 source = models.TextField() 

413 previous_source = models.TextField(default="", blank=True) 

414 target = models.TextField(default="", blank=True) 

415 state = models.IntegerField(default=STATE_EMPTY, choices=StringState.choices) 

416 # Stores string state ignoring Weblate originated read-only state 

417 original_state = models.IntegerField( 

418 default=STATE_EMPTY, choices=StringState.choices 

419 ) 

420 details = models.JSONField(default=dict) 

421 

422 position = models.IntegerField() 

423 

424 num_words = models.IntegerField(default=0) 

425 

426 priority = models.IntegerField(default=100) 

427 

428 timestamp = models.DateTimeField(auto_now_add=True) 

429 last_updated = models.DateTimeField(auto_now=True) 

430 

431 extra_flags = models.TextField( 

432 verbose_name=gettext_lazy("Translation flags"), 

433 default="", 

434 help_text=gettext_lazy( 

435 "Additional comma-separated flags to influence Weblate behavior." 

436 ), 

437 validators=[validate_check_flags], 

438 blank=True, 

439 ) 

440 explanation = models.TextField( 

441 verbose_name=gettext_lazy("Explanation"), 

442 default="", 

443 blank=True, 

444 help_text=gettext_lazy( 

445 "Additional explanation to clarify meaning or usage of the string." 

446 ), 

447 ) 

448 variant = models.ForeignKey( 

449 "trans.Variant", 

450 on_delete=models.deletion.SET_NULL, 

451 blank=True, 

452 null=True, 

453 default=None, 

454 ) 

455 labels = LabelsField("Label", verbose_name=gettext_lazy("Labels"), blank=True) 

456 

457 # The type annotation hides that field can be None because 

458 # save() updates it to non-None immediately. 

459 source_unit: Unit = models.ForeignKey( 

460 "trans.Unit", on_delete=models.deletion.CASCADE, blank=True, null=True 

461 ) # type: ignore[assignment] 

462 

463 objects = UnitQuerySet.as_manager() 

464 

465 class Meta: 

466 app_label = "trans" 

467 unique_together = [("translation", "id_hash")] # noqa: RUF012 

468 verbose_name = "string" 

469 verbose_name_plural = "strings" 

470 indexes = [ # noqa: RUF012 

471 models.Index( 

472 MD5(Lower("source")), "translation", name="trans_unit_source_md5" 

473 ), 

474 models.Index( 

475 MD5(Lower("target")), "translation", name="trans_unit_target_md5" 

476 ), 

477 models.Index( 

478 MD5(Lower("context")), "translation", name="trans_unit_context_md5" 

479 ), 

480 ] 

481 

482 def __str__(self) -> str: 

483 source = self.get_source_plurals()[0] 

484 if self.translation.is_template: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true

485 name = self.context 

486 elif self.context: 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 name = f"[{self.context}] {source}" 

488 else: 

489 name = source 

490 return f"{self.pk}: {name}" 

491 

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

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

494 self.is_batch_update = False 

495 self.source_updated = False 

496 self.check_cache: dict[str, Any] = {} 

497 self.trigger_update_variants = True 

498 self.fixups: list[str] = [] 

499 # Data for machinery integration 

500 self.machinery: UnitMemoryResultDict = {} 

501 # PluralMapper integration 

502 self.plural_map: list[str] = [] 

503 # Data for glossary integration 

504 self.glossary_terms: list[Unit] | None = None 

505 self.glossary_positions: tuple[tuple[int, int], ...] = () 

506 # Project backup integration 

507 self.import_data: dict[str, Any] = {} 

508 # Store original attributes for change tracking 

509 self.old_unit: OldUnit 

510 # Unit attributes used when parsing 

511 self.unit_attributes: UnitAttributesDict | None = None 

512 # To handle pending update for enforced checks 

513 self.pending_unit_change: PendingUnitChange | None = None 

514 # Avoid loading self-referencing source unit from the database 

515 # Skip this when deferred fields are present to avoid database access 

516 if ( 

517 self.id 

518 and not self.get_deferred_fields() 

519 and self.source_unit_id == self.id 

520 ): 

521 self.source_unit = self 

522 if "state" in self.__dict__ and "source" in self.__dict__: 522 ↛ exitline 522 didn't return from function '__init__' because the condition on line 522 was always true

523 # Avoid storing if .only() was used to fetch the query (eg. in stats) 

524 self.store_old_unit(self) 

525 

526 def save( # type: ignore[override] 

527 self, 

528 *, 

529 same_content: bool = False, 

530 run_checks: bool = True, 

531 force_propagate_checks: bool = False, 

532 force_insert: bool = False, 

533 force_update: bool = False, 

534 only_save: bool = False, 

535 sync_terminology: bool = True, 

536 using=None, 

537 update_fields: list[str] | None = None, 

538 ) -> None: 

539 """ 

540 Save the unit. 

541 

542 Wrapper around save to run checks or update fulltext. 

543 """ 

544 # Store number of words 

545 if not same_content or not self.num_words: 

546 self.num_words = count_words( 

547 self.source, self.translation.component.source_language 

548 ) 

549 if update_fields and "num_words" not in update_fields: 

550 update_fields.append("num_words") 

551 

552 # Update last_updated timestamp 

553 if update_fields and "last_updated" not in update_fields: 

554 update_fields.append("last_updated") 

555 

556 # Actually save the unit 

557 super().save( 

558 force_insert=force_insert, 

559 force_update=force_update, 

560 using=using, 

561 update_fields=update_fields, 

562 ) 

563 

564 # Set source_unit for source units, this needs to be done after 

565 # having a primary key 

566 if self.is_source and not self.source_unit_id: 

567 self.source_unit = self 

568 # Avoid using save() for recursion 

569 Unit.objects.filter(pk=self.pk).update(source_unit=self) 

570 

571 if only_save: 

572 return 

573 

574 # Update checks if content or fuzzy flag has changed 

575 if run_checks: 575 ↛ 577line 575 didn't jump to line 577 because the condition on line 575 was always true

576 self.run_checks(force_propagate=force_propagate_checks) 

577 if self.is_source: 

578 self.source_unit_save() 

579 

580 # Update manual variants 

581 if ( 

582 self.old_unit["extra_flags"] != self.extra_flags 

583 or self.context != self.old_unit["context"] 

584 or force_insert 

585 ): 

586 self.update_variants() 

587 

588 # Update terminology 

589 if sync_terminology: 589 ↛ exitline 589 didn't return from function 'save' because the condition on line 589 was always true

590 self.sync_terminology() 

591 

592 def get_absolute_url(self) -> str: 

593 return f"{self.translation.get_translate_url()}?checksum={self.checksum}" 

594 

595 def get_url_path(self): 

596 return (*self.translation.get_url_path(), str(self.pk)) 

597 

598 def invalidate_checks_cache(self) -> None: 

599 self.check_cache = {} 

600 for key in ["propagated_units"]: 

601 if key in self.__dict__: 

602 del self.__dict__[key] 

603 

604 def store_old_unit(self, unit) -> None: 

605 self.old_unit = { 

606 "state": unit.state, 

607 "source": unit.source, 

608 "target": unit.target, 

609 "context": unit.context, 

610 "extra_flags": unit.extra_flags, 

611 "explanation": unit.explanation, 

612 } 

613 

614 def store_disk_state(self) -> None: 

615 """ 

616 Store a snapshot of unit state before pending changes are created. 

617 

618 Only stores the state if it doesn't already exist (first pending change). 

619 """ 

620 if self.old_unit is None: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true

621 msg = "`store_old_unit` should be called before saving disk state." 

622 raise ValueError(msg) 

623 if "disk_state" not in self.details: 

624 self.details["disk_state"] = { 

625 "target": self.old_unit["target"], 

626 "state": self.old_unit["state"], 

627 "explanation": self.old_unit["explanation"], 

628 } 

629 self.save(same_content=True, only_save=True, update_fields=["details"]) 

630 

631 def clear_disk_state(self) -> None: 

632 """ 

633 Clear the disk_state snapshot from the details field. 

634 

635 This should be called after all pending changes for this unit have been committed. 

636 """ 

637 if "disk_state" in self.details: 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true

638 del self.details["disk_state"] 

639 self.save(same_content=True, only_save=True, update_fields=["details"]) 

640 

641 def get_comparison_state(self) -> dict[str, Any]: 

642 """ 

643 Get the fields that can differ in database and disk to compare against during check_sync. 

644 

645 If disk_state exists, returns it. Otherwise, returns the current unit state. 

646 This allows comparing file contents against the state before pending changes. 

647 

648 Returns: 

649 Dictionary containing unit state fields. 

650 

651 """ 

652 if "disk_state" in self.details: 

653 return self.details["disk_state"] 

654 

655 return { 

656 "target": self.target, 

657 "state": self.state, 

658 "explanation": self.explanation, 

659 } 

660 

661 @property 

662 def approved(self) -> bool: 

663 return self.state == STATE_APPROVED 

664 

665 @property 

666 def translated(self) -> bool: 

667 return self.state >= STATE_TRANSLATED 

668 

669 @property 

670 def readonly(self) -> bool: 

671 return self.state == STATE_READONLY 

672 

673 @property 

674 def fuzzy(self) -> bool: 

675 return self.state == STATE_FUZZY 

676 

677 @property 

678 def has_failing_check(self) -> bool: 

679 return bool(self.active_checks) 

680 

681 @property 

682 def has_comment(self) -> bool: 

683 # Use bool here as unresolved_comments might be list 

684 # or a queryset (from prefetch) 

685 return bool(self.unresolved_comments) 

686 

687 @property 

688 def has_suggestion(self) -> bool: 

689 return bool(self.suggestions) 

690 

691 def source_unit_save(self) -> None: 

692 # Run checks, update state and priority if flags changed 

693 # or running bulk edit 

694 if ( 

695 self.old_unit["extra_flags"] != self.extra_flags 

696 or self.state != self.old_unit["state"] 

697 ): 

698 # We can not exclude current unit here as we need to trigger 

699 # the updates below 

700 for unit in self.unit_set.select_for_update().prefetch().prefetch_bulk(): 

701 # Share component instance for locking and possible bulk updates 

702 unit.translation.component = self.translation.component 

703 unit.update_state() 

704 unit.update_priority() 

705 unit.run_checks() 

706 if not self.is_batch_update: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true

707 self.translation.component.invalidate_cache() 

708 

709 def sync_terminology(self) -> None: 

710 try: 

711 unit_flags = Flags(self.flags) 

712 except ParseException: 

713 unit_flags = None 

714 new_flags = Flags(self.extra_flags, unit_flags) 

715 

716 if "terminology" in new_flags: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true

717 self.translation.component.schedule_sync_terminology() 

718 

719 def update_variants(self) -> None: 

720 variants = self.defined_variants.all() 

721 component = self.translation.component 

722 flags = self.all_flags 

723 new_variant = None 

724 remove = False 

725 

726 if not flags.has_value("variant"): 726 ↛ 729line 726 didn't jump to line 729 because the condition on line 726 was always true

727 remove = bool(variants) 

728 else: 

729 new_variant = flags.get_value("variant") 

730 if any(variant.key != new_variant for variant in variants): 

731 remove = True 

732 

733 # Delete stale variant 

734 if remove: 734 ↛ 735line 734 didn't jump to line 735 because the condition on line 734 was never true

735 for variant in variants: 

736 variant.defining_units.remove(self) 

737 if variant.defining_units.count() == 0: 

738 variant.delete() 

739 else: 

740 variant.unit_set.filter(id_hash=self.id_hash).update(variant=None) 

741 

742 # Add new variant 

743 if new_variant: 743 ↛ 744line 743 didn't jump to line 744 because the condition on line 743 was never true

744 variant = Variant.objects.get_or_create( 

745 key=new_variant, component=component 

746 )[0] 

747 variant.defining_units.add(self) 

748 

749 # Update variant links 

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

751 remove 

752 or new_variant 

753 or ( 

754 component.variant_regex 

755 and re.findall(component.variant_regex, self.context) 

756 ) 

757 ): 

758 if self.trigger_update_variants: 

759 component.update_variants() 

760 else: 

761 component.needs_variants_update = True 

762 

763 def get_unit_state( 

764 self, 

765 unit, 

766 flags: Flags | str | None, 

767 string_changed: bool = False, 

768 disk_unit_state: StringState | None = None, 

769 ) -> StringState: 

770 """Calculate translated and fuzzy status.""" 

771 # Read-only from the file format 

772 if unit.is_readonly(): 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true

773 return STATE_READONLY 

774 

775 if flags is not None: 

776 # Read-only from the source 

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

778 not self.is_source 

779 and self.source_unit.state < STATE_TRANSLATED 

780 and self.translation.component.intermediate 

781 ): 

782 return STATE_READONLY 

783 

784 # Read-only from flags 

785 if "read-only" in self.get_all_flags(flags): 

786 return STATE_READONLY 

787 

788 # We need to keep approved/fuzzy state for formats which do not 

789 # support saving it 

790 is_existing_fuzzy_state = self.fuzzy or disk_unit_state == STATE_FUZZY 

791 if unit.is_fuzzy(is_existing_fuzzy_state and not string_changed): 791 ↛ 792line 791 didn't jump to line 792 because the condition on line 791 was never true

792 return STATE_FUZZY 

793 

794 if not unit.is_translated(): 

795 return STATE_EMPTY 

796 

797 is_existing_approved_state = self.approved or disk_unit_state == STATE_APPROVED 

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

799 unit.is_approved(is_existing_approved_state and not string_changed) 

800 and self.translation.enable_review 

801 ): 

802 return STATE_APPROVED 

803 

804 return STATE_TRANSLATED 

805 

806 @staticmethod 

807 def check_valid(texts) -> None: 

808 for text in texts: 

809 if any(char in text for char in CONTROLCHARS): 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true

810 raise ValueError( 

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

812 ) 

813 

814 def update_source_unit( 

815 self, component, source, context, pos, note, location, flags: Flags, explanation 

816 ) -> None: 

817 source_unit = component.get_source( 

818 self.id_hash, 

819 create={ 

820 "source": source, 

821 "target": source, 

822 "context": context, 

823 "position": pos, 

824 "note": note, 

825 "location": location, 

826 "explanation": explanation, 

827 "flags": flags.format(), 

828 }, 

829 ) 

830 same_flags = flags == Flags(source_unit.flags) 

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

832 not source_unit.source_updated 

833 and not source_unit.translation.filename 

834 and ( 

835 pos != source_unit.position 

836 or location != source_unit.location 

837 or not same_flags 

838 or note != source_unit.note 

839 ) 

840 ): 

841 source_unit.position = pos 

842 source_unit.source_updated = True 

843 source_unit.location = location 

844 source_unit.explanation = explanation 

845 source_unit.flags = flags.format() 

846 source_unit.note = note 

847 source_unit.save( 

848 update_fields=["position", "location", "explanation", "flags", "note"], 

849 same_content=True, 

850 run_checks=False, 

851 only_save=same_flags, 

852 ) 

853 self.source_unit = source_unit 

854 

855 def store_unit_attributes( 

856 self, 

857 *, 

858 unit: TranslationUnit, 

859 pos: int, 

860 created: bool, 

861 id_hash: int, 

862 ) -> UnitAttributesDict: 

863 """Get unit attributes.""" 

864 if self.unit_attributes is None: 864 ↛ 909line 864 didn't jump to line 909 because the condition on line 864 was always true

865 translation = self.translation 

866 component = translation.component 

867 try: 

868 location = unit.locations 

869 if self.translation.component.file_format_cls.supports_explanation: 869 ↛ 870line 869 didn't jump to line 870 because the condition on line 869 was never true

870 explanation = unit.explanation 

871 source_explanation = unit.source_explanation 

872 else: 

873 explanation = self.explanation 

874 source_explanation = "" if created else self.source_unit.explanation 

875 flags = Flags(unit.flags) 

876 source = unit.source 

877 self.check_valid(split_plural(source)) 

878 if not translation.is_template and translation.is_source: 

879 # Load target from source string for bilingual source translations 

880 target = source 

881 else: 

882 target = unit.target 

883 self.check_valid(split_plural(target)) 

884 context = unit.context 

885 self.check_valid([context]) 

886 note = unit.notes 

887 previous_source = unit.previous_source 

888 except DjangoDatabaseError: 

889 raise 

890 except Exception as error: 

891 report_error("Unit update error", project=component.project) 

892 translation.component.handle_parse_error(error, translation) 

893 

894 self.unit_attributes = { 

895 "location": location, 

896 "explanation": explanation, 

897 "source_explanation": source_explanation, 

898 "flags": flags, 

899 "source": source, 

900 "target": target, 

901 "context": context, 

902 "note": note, 

903 "previous_source": previous_source, 

904 "unit": unit, 

905 "created": created, 

906 "pos": pos, 

907 "id_hash": id_hash, 

908 } 

909 return self.unit_attributes 

910 

911 def update_from_unit( # noqa: C901,PLR0914 

912 self, 

913 *, 

914 user: User | None = None, 

915 author: User | None = None, 

916 ) -> None: 

917 """Update Unit from ttkit unit.""" 

918 translation = self.translation 

919 component = translation.component 

920 self.is_batch_update = True 

921 self.trigger_update_variants = False 

922 self.source_updated = True 

923 

924 pending = False 

925 

926 # Get unit attributes 

927 if self.unit_attributes is None: 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true

928 msg = "store_unit_attributes has to be called first" 

929 raise ValueError(msg) 

930 unit_attributes = self.unit_attributes 

931 location = unit_attributes["location"] 

932 explanation = unit_attributes["explanation"] 

933 source_explanation = unit_attributes["source_explanation"] 

934 flags = unit_attributes["flags"] 

935 source = unit_attributes["source"] 

936 target = unit_attributes["target"] 

937 context = unit_attributes["context"] 

938 note = unit_attributes["note"] 

939 source_change = previous_source = unit_attributes["previous_source"] 

940 unit = unit_attributes["unit"] 

941 created = unit_attributes["created"] 

942 pos = unit_attributes["pos"] 

943 

944 # Should not be needed again 

945 self.unit_attributes = None 

946 

947 # Ensure we track source string for bilingual, this can not use 

948 # Unit.is_source as that depends on source_unit attribute, which 

949 # we set here 

950 old_source_unit = self.source_unit 

951 if not translation.is_source: 

952 self.update_source_unit( 

953 component, 

954 source, 

955 context, 

956 pos, 

957 note, 

958 location, 

959 flags, 

960 source_explanation, 

961 ) 

962 

963 # Get comparison state (disk_state if exists, otherwise current state) 

964 comparison_state = self.get_comparison_state() 

965 

966 # Has source/target changed 

967 same_source = source == self.source and context == self.context 

968 same_target = target == comparison_state["target"] 

969 

970 # Calculate state 

971 state = self.get_unit_state( 

972 unit, 

973 flags, 

974 string_changed=not same_source or not same_target, 

975 disk_unit_state=comparison_state["state"], 

976 ) 

977 original_state = self.get_unit_state(unit, None) 

978 

979 # Monolingual files handling (without target change) 

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

981 not created 

982 and state != STATE_READONLY 

983 and unit.template is not None 

984 and same_target 

985 ): 

986 if not same_source and state in {STATE_TRANSLATED, STATE_APPROVED}: 

987 if ( 

988 self.previous_source == source 

989 and comparison_state["state"] == STATE_FUZZY 

990 ): 

991 # Source change was reverted 

992 source_change = self.source 

993 previous_source = "" 

994 state = STATE_TRANSLATED 

995 else: 

996 # Store previous source and fuzzy flag for monolingual 

997 if not previous_source: 

998 source_change = previous_source = self.source 

999 # Keep prevoious source if already set in case source 

1000 # changes multiple times 

1001 if self.previous_source: 

1002 previous_source = self.previous_source 

1003 state = STATE_FUZZY 

1004 pending = True 

1005 elif ( 

1006 comparison_state["state"] == STATE_FUZZY 

1007 and state == STATE_FUZZY 

1008 and not previous_source 

1009 ): 

1010 # Avoid losing previous source of fuzzy strings 

1011 previous_source = self.previous_source 

1012 

1013 # Update checks on fuzzy update or on content change 

1014 same_state = state == comparison_state["state"] and flags == Flags(self.flags) 

1015 same_metadata = ( 

1016 location == self.location 

1017 and explanation == comparison_state["explanation"] 

1018 and note == self.note 

1019 and pos == self.position 

1020 and not pending 

1021 ) 

1022 same_data = ( 

1023 not created 

1024 and same_source 

1025 and same_target 

1026 and same_state 

1027 and flags == Flags(self.flags) 

1028 and previous_source == self.previous_source 

1029 and self.source_unit == old_source_unit 

1030 and old_source_unit is not None 

1031 ) 

1032 

1033 # Conditionally check original state changes if it would be used. It is not 

1034 # properly tracked in PendingUnitChange, so this would not work for units 

1035 # with pending changes. But there shouldn't be any uncommitable pending changes 

1036 # for read-only units. 

1037 if STATE_READONLY in {state, self.state, comparison_state["state"]}: 

1038 same_data &= original_state == self.original_state 

1039 

1040 # Check if we actually need to change anything 

1041 if same_data and same_metadata: 

1042 return 

1043 

1044 # Store updated values 

1045 self.original_state = original_state 

1046 self.position = pos 

1047 self.location = location 

1048 self.explanation = explanation 

1049 self.flags = flags.format() 

1050 self.source = source 

1051 self.target = target 

1052 self.state = state 

1053 self.context = context 

1054 self.note = note 

1055 self.previous_source = previous_source 

1056 self.update_priority(save=False) 

1057 

1058 # Metadata update only, these do not trigger any actions in Weblate and 

1059 # are display only 

1060 if same_data and not same_metadata: 1060 ↛ 1061line 1060 didn't jump to line 1061 because the condition on line 1060 was never true

1061 self.save( 

1062 same_content=True, 

1063 only_save=True, 

1064 update_fields=["location", "explanation", "note", "position"], 

1065 ) 

1066 return 

1067 

1068 # Sanitize number of plurals 

1069 if self.is_plural and not component.file_format_cls.has_multiple_strings: 1069 ↛ 1070line 1069 didn't jump to line 1070 because the condition on line 1069 was never true

1070 self.target = join_plural(self.get_target_plurals()) 

1071 

1072 if created: 1072 ↛ 1075line 1072 didn't jump to line 1075 because the condition on line 1072 was always true

1073 unit_pre_create.send(sender=self.__class__, unit=self) 

1074 

1075 if not created and not same_target: 1075 ↛ 1076line 1075 didn't jump to line 1076 because the condition on line 1075 was never true

1076 unit_post_sync.send(sender=self.__class__, unit=self, updated_attr="target") 

1077 

1078 # Save into database 

1079 self.save( 

1080 force_insert=created, 

1081 same_content=same_source and same_target, 

1082 run_checks=not same_source or not same_target or not same_state, 

1083 ) 

1084 self.clear_disk_state() 

1085 PendingUnitChange.objects.filter(unit=self).delete() 

1086 

1087 if pending: 1087 ↛ 1088line 1087 didn't jump to line 1088 because the condition on line 1087 was never true

1088 PendingUnitChange.store_unit_change(unit=self) 

1089 # Track updated sources for source checks 

1090 if translation.is_template: 1090 ↛ 1091line 1090 didn't jump to line 1091 because the condition on line 1090 was never true

1091 component.updated_sources[self.id] = self 

1092 # Indicate source string change 

1093 if not same_source and source_change: 1093 ↛ 1094line 1093 didn't jump to line 1094 because the condition on line 1093 was never true

1094 translation.update_changes.append( 

1095 self.generate_change( 

1096 user, 

1097 author, 

1098 ActionEvents.SOURCE_CHANGE, 

1099 check_new=False, 

1100 old=source_change, 

1101 target=self.source, 

1102 save=False, 

1103 ) 

1104 ) 

1105 # Track VCS change 

1106 if not same_data: 1106 ↛ 1120line 1106 didn't jump to line 1120 because the condition on line 1106 was always true

1107 translation.update_changes.append( 

1108 self.generate_change( 

1109 user, 

1110 author, 

1111 change_action=translation.create_unit_change_action 

1112 if created 

1113 else translation.update_unit_change_action, 

1114 check_new=False, 

1115 save=False, 

1116 ) 

1117 ) 

1118 

1119 # Update translation memory if needed 

1120 if created or not same_source or not same_target: 1120 ↛ exitline 1120 didn't return from function 'update_from_unit' because the condition on line 1120 was always true

1121 self.update_translation_memory(needs_user_check=False) 

1122 

1123 def update_state(self) -> None: 

1124 """ 

1125 Update state based on flags. 

1126 

1127 Mark read-only strings: 

1128 

1129 * Flagged with 'read-only' 

1130 * Where source string is untranslated 

1131 """ 

1132 if "read-only" in self.all_flags or ( 1132 ↛ 1145line 1132 didn't jump to line 1145 because the condition on line 1132 was always true

1133 not self.is_source 

1134 and self.source_unit.state < STATE_TRANSLATED 

1135 and self.translation.component.intermediate 

1136 ): 

1137 if not self.readonly: 1137 ↛ 1138line 1137 didn't jump to line 1138 because the condition on line 1137 was never true

1138 self.original_state = self.state 

1139 self.state = STATE_READONLY 

1140 self.save( 

1141 same_content=True, 

1142 run_checks=False, 

1143 update_fields=["state", "original_state"], 

1144 ) 

1145 elif self.readonly and self.state != self.original_state: 

1146 self.state = self.original_state 

1147 self.save(same_content=True, run_checks=False, update_fields=["state"]) 

1148 

1149 def update_priority(self, save: bool = True) -> None: 

1150 if self.all_flags.has_value("priority"): 1150 ↛ 1151line 1150 didn't jump to line 1151 because the condition on line 1150 was never true

1151 priority = self.all_flags.get_value("priority") 

1152 else: 

1153 priority = 100 

1154 if self.priority != priority: 1154 ↛ 1155line 1154 didn't jump to line 1155 because the condition on line 1154 was never true

1155 self.priority = priority 

1156 if save: 

1157 self.save( 

1158 same_content=True, run_checks=False, update_fields=["priority"] 

1159 ) 

1160 

1161 @cached_property 

1162 def is_plural(self) -> bool: 

1163 """Check whether message is plural.""" 

1164 return is_plural(self.source) or is_plural(self.target) 

1165 

1166 @cached_property 

1167 def is_source(self) -> bool: 

1168 return self.source_unit_id is None or self.source_unit_id == self.id 

1169 

1170 def get_source_plurals(self) -> list[str]: 

1171 """Return source plurals in array.""" 

1172 return split_plural(self.source) 

1173 

1174 @cached_property 

1175 def source_string(self) -> str: 

1176 """ 

1177 Return a single source string. 

1178 

1179 In most cases it's singular with exception of unused singulars 

1180 generated by some frameworks. 

1181 """ 

1182 plurals = self.get_source_plurals() 

1183 singular = plurals[0] 

1184 if len(plurals) == 1 or not is_unused_string(singular): 

1185 return singular 

1186 return plurals[1] 

1187 

1188 def adjust_plurals( 

1189 self, values: list[str], plurals: int | None = None 

1190 ) -> list[str]: 

1191 if not self.is_plural: 1191 ↛ 1193line 1191 didn't jump to line 1193 because the condition on line 1191 was always true

1192 plurals = 1 

1193 elif plurals is None: 

1194 plurals = self.translation.plural.number 

1195 

1196 # Check if we have expected number of them 

1197 if len(values) == plurals: 

1198 return values 

1199 

1200 # Pad with empty translations 

1201 while len(values) < plurals: 

1202 values.append("") 

1203 

1204 # Delete extra plurals 

1205 while len(values) > plurals: 

1206 del values[-1] 

1207 

1208 return values 

1209 

1210 def get_target_plurals(self, plurals: int | None = None) -> list[str]: 

1211 """Return target plurals in array.""" 

1212 # Split plurals 

1213 ret = split_plural(self.target) 

1214 

1215 if not self.translation.component.is_multivalue: 1215 ↛ 1218line 1215 didn't jump to line 1218 because the condition on line 1215 was always true

1216 ret = self.adjust_plurals(ret, plurals=plurals) 

1217 

1218 return ret 

1219 

1220 def propagate( 

1221 self, user: User | None, change_action=None, author=None, request=None 

1222 ) -> bool: 

1223 """Propagate current translation to all others.""" 

1224 warnings: list[str] = [] 

1225 

1226 with sentry_sdk.start_span(op="unit.propagate", name=f"{self.pk}"): 

1227 to_update: list[Unit] = [] 

1228 units = self.propagated_units.exclude( 

1229 target=self.target, state=self.state 

1230 ).select_for_update() 

1231 for unit in units: 

1232 if user is not None and not ( 

1233 denied := user.has_perm("unit.edit", unit) 

1234 ): 

1235 component = unit.translation.component 

1236 if request and isinstance(denied, PermissionResult): 

1237 warnings.append( 

1238 gettext( 

1239 "String could not be propagated to %(component)s: %(reason)s" 

1240 ) 

1241 % {"component": component, "reason": denied.reason}, 

1242 ) 

1243 continue 

1244 

1245 # Update unit attributes for the current instance, the database is bulk updated later 

1246 unit.store_old_unit(unit) 

1247 unit.target = self.target 

1248 unit.state = self.state 

1249 

1250 PendingUnitChange.store_unit_change( 

1251 unit=unit, 

1252 author=author, 

1253 ) 

1254 

1255 to_update.append(unit) 

1256 

1257 unit.update_translation_memory(user) 

1258 

1259 if warnings: 

1260 if len(warnings) > 10: 

1261 messages.warning( 

1262 request, 

1263 ngettext( 

1264 "String could not be propagated to %d component.", 

1265 "String could not be propagated to %d components.", 

1266 len(warnings), 

1267 ) 

1268 % len(warnings), 

1269 ) 

1270 else: 

1271 for warning in warnings: 

1272 messages.warning(request, warning) 

1273 if not to_update: 

1274 return False 

1275 

1276 # Bulk update units 

1277 Unit.objects.filter(pk__in=(unit.pk for unit in to_update)).update( 

1278 target=self.target, 

1279 state=self.state, 

1280 original_state=self.state, 

1281 last_updated=self.last_updated, 

1282 ) 

1283 

1284 # Postprocess changes and generate change objects 

1285 changes = [ 

1286 unit.post_save( 

1287 user, 

1288 user, 

1289 change_action=ActionEvents.PROPAGATED_EDIT, 

1290 check_new=False, 

1291 save=False, 

1292 ) 

1293 for unit in to_update 

1294 ] 

1295 

1296 # Bulk create changes 

1297 Change.objects.bulk_create(changes) 

1298 

1299 # Update user stats 

1300 if user is not None: 

1301 user.profile.increase_count("translated", len(to_update)) 

1302 

1303 return True 

1304 

1305 def save_backend( 

1306 self, 

1307 user: User | None, 

1308 propagate: bool = True, 

1309 change_action=None, 

1310 author: User | None = None, 

1311 run_checks: bool = True, 

1312 request=None, 

1313 ) -> bool: 

1314 """ 

1315 Store unit to backend. 

1316 

1317 Optional user parameters defines authorship of a change. 

1318 

1319 This should be always called in a transaction with updated unit 

1320 locked for update. 

1321 """ 

1322 verify_in_transaction() 

1323 # For case when authorship specified, use user 

1324 author = author or user 

1325 

1326 # Propagate to other projects 

1327 # This has to be done before changing source for template 

1328 was_propagated = False 

1329 if propagate: 

1330 was_propagated = self.propagate( 

1331 user, change_action, author=author, request=request 

1332 ) 

1333 

1334 # Return if there was no change 

1335 # We have to explicitly check for fuzzy flag change on monolingual 

1336 # files, where we handle it ourselves without storing to backend 

1337 if ( 

1338 self.old_unit["state"] == self.state 

1339 and self.old_unit["target"] == self.target 

1340 and self.old_unit["explanation"] == self.explanation 

1341 and not was_propagated 

1342 ): 

1343 return False 

1344 

1345 update_fields = ["target", "state", "original_state", "explanation"] 

1346 if self.is_source and not self.translation.component.intermediate: 

1347 self.source = self.target 

1348 update_fields.extend(["source"]) 

1349 

1350 # Unit is pending for write 

1351 self.pending_unit_change = PendingUnitChange.store_unit_change( 

1352 unit=self, 

1353 author=author, 

1354 ) 

1355 

1356 # Update translated flag (not fuzzy and at least one translation) 

1357 translation = any(self.get_target_plurals()) 

1358 if self.state >= STATE_TRANSLATED and not translation: 

1359 self.state = STATE_EMPTY 

1360 elif self.state == STATE_EMPTY and translation: 

1361 self.state = STATE_TRANSLATED 

1362 self.original_state = self.state 

1363 

1364 # Save updated unit to database, skip running checks 

1365 self.save( 

1366 update_fields=update_fields, 

1367 run_checks=run_checks, 

1368 force_propagate_checks=was_propagated, 

1369 ) 

1370 

1371 # Generate change and process it 

1372 self.post_save(user or author, author, change_action) 

1373 

1374 # Update related source strings if working on a template 

1375 if self.translation.is_template and self.old_unit["target"] != self.target: 

1376 self.update_source_units(self.old_unit["target"], user or author, author) 

1377 

1378 return True 

1379 

1380 def post_save( 

1381 self, 

1382 user: User | None, 

1383 author: User | None, 

1384 change_action: int | None, 

1385 *, 

1386 save: bool = True, 

1387 check_new: bool = True, 

1388 ) -> Change: 

1389 # Generate Change object for this change 

1390 change = self.generate_change( 

1391 user or author, author, change_action, save=save, check_new=check_new 

1392 ) 

1393 

1394 if change.action not in { 

1395 ActionEvents.UPLOAD, 

1396 ActionEvents.AUTO, 

1397 ActionEvents.BULK_EDIT, 

1398 }: 

1399 old_translated = self.translation.stats.translated 

1400 

1401 # Update translation stats 

1402 self.translation.invalidate_cache() 

1403 

1404 # Postpone completed translation detection for translated strings 

1405 if self.state >= STATE_TRANSLATED: 

1406 transaction.on_commit( 

1407 partial( 

1408 self.translation.detect_completed_translation, 

1409 change, 

1410 old_translated, 

1411 ) 

1412 ) 

1413 

1414 # Update user stats 

1415 if save and change.author and not change.author.is_anonymous: 

1416 change.author.profile.increase_count("translated") 

1417 return change 

1418 

1419 def update_source_units( 

1420 self, previous_source: str, user: User | None, author: User | None 

1421 ) -> None: 

1422 """ 

1423 Update source for units within same component. 

1424 

1425 This is needed when editing template translation for monolingual formats. 

1426 """ 

1427 with sentry_sdk.start_span(op="unit.update_source_units", name=f"{self.pk}"): 

1428 changes = [] 

1429 

1430 # Find relevant units 

1431 for unit in self.unit_set.exclude(id=self.id).prefetch().prefetch_bulk(): 

1432 # Update source and number of words 

1433 unit.source = self.target 

1434 unit.num_words = self.num_words 

1435 # Find reverted units 

1436 if ( 

1437 unit.state == STATE_FUZZY 

1438 and unit.previous_source == self.target 

1439 and unit.target 

1440 ): 

1441 # Unset fuzzy on reverted 

1442 unit.original_state = unit.state = STATE_TRANSLATED 

1443 PendingUnitChange.store_unit_change( 

1444 unit=unit, 

1445 author=author, 

1446 ) 

1447 unit.previous_source = "" 

1448 elif ( 

1449 unit.original_state == STATE_FUZZY 

1450 and unit.previous_source == self.target 

1451 and unit.target 

1452 ): 

1453 # Unset fuzzy on reverted 

1454 unit.original_state = STATE_TRANSLATED 

1455 unit.previous_source = "" 

1456 elif unit.state >= STATE_TRANSLATED and unit.target: 

1457 # Set fuzzy on changed 

1458 unit.original_state = STATE_FUZZY 

1459 if unit.state < STATE_READONLY: 

1460 unit.state = STATE_FUZZY 

1461 PendingUnitChange.store_unit_change( 

1462 unit=unit, 

1463 author=author, 

1464 ) 

1465 unit.previous_source = previous_source 

1466 

1467 # Save unit 

1468 unit.save() 

1469 # Generate change 

1470 changes.append( 

1471 unit.generate_change( 

1472 user, 

1473 author, 

1474 ActionEvents.SOURCE_CHANGE, 

1475 check_new=False, 

1476 old=previous_source, 

1477 target=self.target, 

1478 save=False, 

1479 ) 

1480 ) 

1481 if changes: 

1482 # Bulk create changes 

1483 Change.objects.bulk_create(changes) 

1484 # Invalidate stats 

1485 self.translation.component.invalidate_cache() 

1486 

1487 def generate_change( 

1488 self, 

1489 user: User | None, 

1490 author: User | None, 

1491 change_action: int | None, 

1492 *, 

1493 check_new: bool = True, 

1494 save: bool = True, 

1495 old: str | None = None, 

1496 target: str | None = None, 

1497 ) -> Change: 

1498 """Create Change entry for saving unit.""" 

1499 # Notify about new contributor 

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

1501 check_new 

1502 and not self.is_batch_update 

1503 and user is not None 

1504 and not user.is_bot 

1505 and not self.translation.change_set.filter(user=user).exists() 

1506 ): 

1507 self.change_set.create( 

1508 unit=self, 

1509 action=ActionEvents.NEW_CONTRIBUTOR, 

1510 user=user, 

1511 author=author, 

1512 ) 

1513 

1514 # Action type to store 

1515 if change_action is not None: 1515 ↛ 1517line 1515 didn't jump to line 1517 because the condition on line 1515 was always true

1516 action = change_action 

1517 elif self.state == STATE_FUZZY: 

1518 action = ActionEvents.MARKED_EDIT 

1519 elif self.old_unit["state"] >= STATE_FUZZY: 

1520 if self.state == STATE_APPROVED: 

1521 action = ActionEvents.APPROVE 

1522 else: 

1523 action = ActionEvents.CHANGE 

1524 elif self.state == STATE_APPROVED: 

1525 action = ActionEvents.APPROVE 

1526 else: 

1527 action = ActionEvents.NEW 

1528 

1529 # Create change object 

1530 change = Change( 

1531 unit=self, 

1532 action=action, 

1533 user=user, 

1534 author=author, 

1535 target=self.target if target is None else target, 

1536 old=self.old_unit["target"] if old is None else old, 

1537 details={ 

1538 "state": self.state, 

1539 "old_state": self.old_unit["state"], 

1540 "source": self.source, 

1541 "context": self.context, 

1542 }, 

1543 ) 

1544 if save: 1544 ↛ 1545line 1544 didn't jump to line 1545 because the condition on line 1544 was never true

1545 change.save(force_insert=True) 

1546 return change 

1547 

1548 @cached_property 

1549 def suggestions(self) -> models.QuerySet[Suggestion]: 

1550 """Return all suggestions for this unit.""" 

1551 return self.suggestion_set.order() 

1552 

1553 @cached_property 

1554 def all_checks(self) -> models.QuerySet[Check]: 

1555 result = self.check_set.all() 

1556 # Force fetching 

1557 list(result) 

1558 return result 

1559 

1560 def clear_checks_cache(self) -> None: 

1561 if "all_checks" in self.__dict__: 1561 ↛ exitline 1561 didn't return from function 'clear_checks_cache' because the condition on line 1561 was always true

1562 del self.__dict__["all_checks"] 

1563 

1564 @property 

1565 def all_checks_names(self) -> set[str]: 

1566 return {check.name for check in self.all_checks} 

1567 

1568 @property 

1569 def dismissed_checks(self) -> list[Check]: 

1570 return [check for check in self.all_checks if check.dismissed] 

1571 

1572 @property 

1573 def active_checks(self) -> list[Check]: 

1574 """Return all active (not ignored) checks for this unit.""" 

1575 return [check for check in self.all_checks if not check.dismissed] 

1576 

1577 @cached_property 

1578 def all_comments(self) -> models.QuerySet[Comment]: 

1579 """Return list of target comments.""" 

1580 if self.is_source: 

1581 # Add all comments on translation on source string comment 

1582 query = Q(unit__source_unit=self) 

1583 else: 

1584 # Add source string comments for translation unit 

1585 query = Q(unit__in=(self, self.source_unit)) 

1586 return Comment.objects.filter(query).prefetch_related("unit", "user").order() 

1587 

1588 @cached_property 

1589 def unresolved_comments(self) -> list[Comment]: 

1590 return [ 

1591 comment 

1592 for comment in self.all_comments 

1593 if not comment.resolved and comment.unit_id == self.id 

1594 ] 

1595 

1596 def run_checks( # noqa: C901 

1597 self, *, force_propagate: bool = False, skip_propagate: bool = False 

1598 ) -> None: 

1599 """Update checks for this unit.""" 

1600 src = self.get_source_plurals() 

1601 tgt = self.get_target_plurals() 

1602 

1603 old_checks = self.all_checks_names 

1604 create = [] 

1605 

1606 args: tuple[list[str], Unit] | tuple[list[str], list[str], Unit] 

1607 if self.is_source: 

1608 checks = CHECKS.source 

1609 meth = "check_source" 

1610 args = src, self 

1611 else: 

1612 checks = {} if self.readonly else CHECKS.target 

1613 meth = "check_target" 

1614 args = src, tgt, self 

1615 if self.translation.component.is_glossary: 

1616 checks = CHECKS.glossary 

1617 meth = "check_target" 

1618 args = src, tgt, self 

1619 

1620 # Initial propagation setup 

1621 propagation: set[Literal["source", "target"]] = set() 

1622 if force_propagate: 1622 ↛ 1623line 1622 didn't jump to line 1623 because the condition on line 1622 was never true

1623 propagation.add("source") 

1624 

1625 # Run all checks 

1626 for check, check_obj in checks.items(): 

1627 # Does the check fire? 

1628 if getattr(check_obj, meth)(*args): 1628 ↛ 1629line 1628 didn't jump to line 1629 because the condition on line 1628 was never true

1629 if check in old_checks: 

1630 # We already have this check 

1631 old_checks.remove(check) 

1632 # Propagation is handled later in this method 

1633 else: 

1634 # Create new check 

1635 create.append(Check(unit=self, dismissed=False, name=check)) 

1636 if check_obj.propagates and not skip_propagate: 

1637 propagation.add(check_obj.propagates) 

1638 

1639 if create: 1639 ↛ 1640line 1639 didn't jump to line 1640 because the condition on line 1639 was never true

1640 Check.objects.bulk_create(create, batch_size=500, ignore_conflicts=True) 

1641 

1642 # Delete no longer failing checks 

1643 if old_checks: 1643 ↛ 1644line 1643 didn't jump to line 1644 because the condition on line 1643 was never true

1644 Check.objects.filter(unit=self, name__in=old_checks).delete() 

1645 if not skip_propagate: 

1646 for check_name in old_checks: 

1647 try: 

1648 check_obj = CHECKS[check_name] 

1649 except KeyError: 

1650 # Skip disabled/removed checks 

1651 continue 

1652 if check_obj.propagates: 

1653 if check_obj.propagates == "source": 

1654 propagated_units = self.propagated_units 

1655 values = set( 

1656 propagated_units.values_list("target", flat=True) 

1657 ) 

1658 elif check_obj.propagates == "target": 

1659 propagated_units = Unit.objects.same_target( 

1660 self, self.old_unit["target"] 

1661 ) 

1662 values = set( 

1663 propagated_units.values_list("source", flat=True) 

1664 ) 

1665 else: 

1666 message = f"Unsupported propagation: {check_obj.propagates}" 

1667 raise ValueError(message) 

1668 

1669 if len(values) == 1: 

1670 for other in propagated_units: 

1671 other.check_set.filter(name=check_name).delete() 

1672 if ( 

1673 other.translation != self.translation 

1674 or other.source != self.source 

1675 ): 

1676 other.translation.invalidate_cache() 

1677 other.clear_checks_cache() 

1678 

1679 # Propagate checks which need it (for example consistency) 

1680 if propagation: 1680 ↛ 1681line 1680 didn't jump to line 1681 because the condition on line 1680 was never true

1681 querymap: dict[Literal["source", "target"], UnitQuerySet] = { 

1682 "source": self.propagated_units, 

1683 "target": Unit.objects.same_target(self), 

1684 } 

1685 propagated_units: UnitQuerySet = reduce( 

1686 operator.or_, (querymap[item] for item in propagation) 

1687 ) 

1688 propagated_units = propagated_units.distinct().prefetch_all_checks() 

1689 

1690 for unit in propagated_units: 

1691 try: 

1692 unit.run_checks(force_propagate=False, skip_propagate=True) 

1693 except Unit.DoesNotExist: 

1694 # This can happen in some corner cases like changing 

1695 # source language of a project - the source language is 

1696 # changed first and then components are updated. But 

1697 # not all are yet updated and this spans across them. 

1698 continue 

1699 

1700 # Trigger source checks on target check update (multiple failing checks) 

1701 if (create or old_checks) and not self.is_source: 1701 ↛ 1702line 1701 didn't jump to line 1702 because the condition on line 1701 was never true

1702 if self.is_batch_update: 

1703 self.translation.component.updated_sources[self.source_unit.id] = ( 

1704 self.source_unit 

1705 ) 

1706 else: 

1707 self.source_unit.run_checks() 

1708 

1709 # This is always preset as it is used in top of this method 

1710 self.clear_checks_cache() 

1711 

1712 if not self.is_batch_update and (create or old_checks): 1712 ↛ 1713line 1712 didn't jump to line 1713 because the condition on line 1712 was never true

1713 self.translation.invalidate_cache() 

1714 

1715 def nearby(self, count: int) -> models.QuerySet[Unit]: 

1716 """Return list of nearby messages based on location.""" 

1717 if self.position == 0: 

1718 return Unit.objects.none() 

1719 with sentry_sdk.start_span(op="unit.nearby", name=f"{self.pk}"): 

1720 # Limiting the query is needed to avoid issues when unit 

1721 # position is not properly populated 

1722 result = ( 

1723 self.translation.unit_set.prefetch_full() 

1724 .order_by("position") 

1725 .filter( 

1726 position__gte=self.position - count, 

1727 position__lte=self.position + count, 

1728 )[: ((2 * count) + 1)] 

1729 ) 

1730 # Force materializing the query 

1731 return result.fill_in_source_translation() 

1732 

1733 def nearby_keys(self, count: int) -> Iterable[Unit]: 

1734 # Do not show nearby keys on bilingual 

1735 if not self.translation.component.has_template(): 

1736 return [] 

1737 with sentry_sdk.start_span(op="unit.nearby_keys", name=f"{self.pk}"): 

1738 key = self.translation.keys_cache_key 

1739 key_list = cache.get(key) 

1740 unit_set = self.translation.unit_set 

1741 if key_list is None or self.pk not in key_list: 

1742 key_list = list( 

1743 unit_set.order_by("context").values_list("id", flat=True) 

1744 ) 

1745 cache.set(key, key_list) 

1746 offset = key_list.index(self.pk) 

1747 nearby = key_list[max(offset - count, 0) : offset + count] 

1748 return ( 

1749 unit_set.filter(id__in=nearby) 

1750 .prefetch_full() 

1751 .order_by("context") 

1752 .fill_in_source_translation() 

1753 ) 

1754 

1755 def variants(self) -> Iterable[Unit]: 

1756 if not self.variant: 

1757 return [] 

1758 return ( 

1759 self.variant.unit_set.filter(translation=self.translation) 

1760 .prefetch() 

1761 .prefetch_full() 

1762 .order_by("context") 

1763 ) 

1764 

1765 @transaction.atomic 

1766 def translate( 

1767 self, 

1768 user: User | None, 

1769 new_target: str | list[str], 

1770 new_state: StringState, 

1771 *, 

1772 change_action: int | None = None, 

1773 propagate: bool = True, 

1774 author: User | None = None, 

1775 request: AuthenticatedHttpRequest | None = None, 

1776 add_alternative: bool = False, 

1777 ) -> bool: 

1778 """ 

1779 Store new translation of a unit. 

1780 

1781 Propagation is currently disabled on import. 

1782 """ 

1783 component = self.translation.component 

1784 

1785 # Force flushing checks cache 

1786 self.invalidate_checks_cache() 

1787 

1788 # Fetch current copy from database and lock it for update 

1789 old_unit = Unit.objects.select_for_update().get(pk=self.pk) 

1790 self.store_old_unit(old_unit) 

1791 

1792 # Handle simple string units 

1793 new_target_list = [new_target] if isinstance(new_target, str) else new_target 

1794 

1795 # Handle managing alternative translations 

1796 if add_alternative: 

1797 new_target_list.append("") 

1798 elif component.is_multivalue: 

1799 new_target_list = [target for target in new_target_list if target] 

1800 if not new_target_list: 

1801 new_target_list = [""] 

1802 

1803 if not component.is_multivalue: 

1804 new_target_list = self.adjust_plurals(new_target_list) 

1805 

1806 # Apply autofixes 

1807 if not self.translation.is_template: 

1808 new_target_list, self.fixups = fix_target(new_target_list, self) 

1809 

1810 # Update unit and save it 

1811 self.target = join_plural(new_target_list) 

1812 not_empty = any(new_target_list) 

1813 

1814 # Newlines fixup 

1815 if "dos-eol" in self.all_flags: 

1816 self.target = NEWLINES.sub("\r\n", self.target) 

1817 

1818 # Update string state 

1819 if not_empty: 

1820 self.state = new_state 

1821 else: 

1822 self.state = STATE_EMPTY 

1823 

1824 # Update original state unless we are updating read-only strings. This 

1825 # does never happen directly, but FillReadOnlyAddon does this. 

1826 if new_state != STATE_READONLY: 

1827 self.original_state = self.state 

1828 

1829 # Save to the database 

1830 saved = self.save_backend( 

1831 user, 

1832 change_action=change_action, 

1833 propagate=propagate, 

1834 author=author, 

1835 request=request, 

1836 ) 

1837 

1838 # Enforced checks can revert the state to needs editing (fuzzy) 

1839 if ( 

1840 self.state >= STATE_TRANSLATED 

1841 and component.enforced_checks 

1842 and self.all_checks_names & set(component.enforced_checks) 

1843 ): 

1844 self.state = self.original_state = STATE_FUZZY 

1845 self.save( 

1846 run_checks=False, 

1847 same_content=True, 

1848 update_fields=["state", "original_state"], 

1849 ) 

1850 self.generate_change( 

1851 user or author, author, ActionEvents.ENFORCED_CHECK, check_new=False 

1852 ) 

1853 if self.pending_unit_change is not None: 

1854 # Update PendingUnitChange if there is one 

1855 self.pending_unit_change.state = STATE_FUZZY 

1856 self.pending_unit_change.save(update_fields=["state"]) 

1857 elif saved: 

1858 # There should be a pending unit if saved 

1859 msg = "Updating unit, but pending unit change is not set!" 

1860 raise ValueError(msg) 

1861 else: 

1862 # Generate pending unit change otherwise 

1863 PendingUnitChange.store_unit_change(unit=self, author=author) 

1864 # Indicate as saved 

1865 saved = True 

1866 

1867 self.update_translation_memory(user) 

1868 

1869 if change_action == ActionEvents.AUTO: 

1870 self.labels.add(component.project.automatically_translated_label) 

1871 else: 

1872 self.labels.through.objects.filter( 

1873 unit=self, label__name="Automatically translated" 

1874 ).delete() 

1875 

1876 return saved 

1877 

1878 def get_all_flags(self, override: Flags | str | None = None) -> Flags: 

1879 """Return union of own and component flags.""" 

1880 # Validate flags from the unit to avoid crash 

1881 try: 

1882 unit_flags = Flags(override or self.flags) 

1883 except ParseException: 

1884 unit_flags = None 

1885 

1886 # Ordering is important here as that defines overriding 

1887 return Flags( 

1888 # Base on translation + component flags 

1889 self.translation.all_flags, 

1890 # Apply unit flags from the file format 

1891 unit_flags, 

1892 # The source_unit is None before saving the object for the first time 

1893 getattr(self.source_unit, "extra_flags", ""), 

1894 # This unit flag overrides 

1895 self.extra_flags, 

1896 ) 

1897 

1898 @cached_property 

1899 def all_flags(self) -> Flags: 

1900 return self.get_all_flags() 

1901 

1902 def get_unit_flags(self) -> Flags: 

1903 return Flags(self.extra_flags) 

1904 

1905 @cached_property 

1906 def edit_mode(self) -> str: 

1907 """Return syntax highlighting mode for Prismjs.""" 

1908 flags = self.all_flags 

1909 if "icu-message-format" in flags: 

1910 return "icu-message-format" 

1911 if "rst-text" in flags: 

1912 return "rest" 

1913 if "md-text" in flags: 

1914 return "markdown" 

1915 if "xml-text" in flags: 

1916 return "xml" 

1917 if "safe-html" in flags: 

1918 return "html" 

1919 return "none" 

1920 

1921 def get_secondary_units(self, user: User) -> list[Unit]: 

1922 """Return list of secondary units.""" 

1923 translation = self.translation 

1924 component = translation.component 

1925 secondary_langs: set[int] = user.profile.secondary_language_ids 

1926 

1927 # Add project/component secondary languages 

1928 if component.secondary_language_id: 

1929 secondary_langs.add(component.secondary_language_id) 

1930 elif component.project.secondary_language_id: 

1931 secondary_langs.add(component.project.secondary_language_id) 

1932 

1933 # Remove current source and target language 

1934 secondary_langs -= {translation.language_id, component.source_language_id} 

1935 

1936 if not secondary_langs: 

1937 return [] 

1938 result = get_distinct_translations( 

1939 self.source_unit.unit_set.filter( 

1940 Q(translation__language__in=secondary_langs) 

1941 & Q(state__gte=STATE_TRANSLATED) 

1942 & Q(state__lt=STATE_READONLY) 

1943 & ~Q(target__lower__md5=MD5(Value(""))) 

1944 & ~Q(pk=self.pk) 

1945 ).select_related( 

1946 "source_unit", 

1947 "translation__language", 

1948 "translation__plural", 

1949 ) 

1950 ) 

1951 # Avoid fetching component again from the database 

1952 for unit in result: 

1953 unit.translation.component = component 

1954 return result 

1955 

1956 @property 

1957 def checksum(self): 

1958 """ 

1959 Return unique hex identifier. 

1960 

1961 It's unsigned representation of id_hash in hex. 

1962 """ 

1963 return hash_to_checksum(self.id_hash) 

1964 

1965 @cached_property 

1966 def propagated_units(self) -> UnitQuerySet: 

1967 return ( 

1968 Unit.objects.same(self) 

1969 .prefetch() 

1970 .filter( 

1971 translation__component__allow_translation_propagation=True, 

1972 translation__plural_id=self.translation.plural_id, 

1973 ) 

1974 ) 

1975 

1976 def get_max_length(self): 

1977 """Return maximal translation length.""" 

1978 # Fallback to reasonably big value 

1979 fallback = 10000 

1980 

1981 # Not yet saved unit 

1982 if not self.pk: 

1983 return fallback 

1984 # Flag defines length 

1985 if self.all_flags.has_value("max-length"): 

1986 return self.all_flags.get_value("max-length") 

1987 # Avoid limiting source strings 

1988 if self.is_source and not self.translation.component.intermediate: 

1989 return fallback 

1990 # Base length on source string 

1991 if settings.LIMIT_TRANSLATION_LENGTH_BY_SOURCE_LENGTH: 

1992 return max(100, len(self.get_source_plurals()[0]) * 10) 

1993 

1994 return fallback 

1995 

1996 def get_target_hash(self) -> int: 

1997 return calculate_hash(self.target) 

1998 

1999 @cached_property 

2000 def content_hash(self) -> int: 

2001 return calculate_hash(self.source, self.context) 

2002 

2003 @cached_property 

2004 def recent_content_changes(self): 

2005 """ 

2006 Content changes for a unit ordered by timestamp. 

2007 

2008 Can be prefetched using prefetch_recent_content_changes. 

2009 """ 

2010 return self.change_set.content().select_related("author").order_by("-timestamp") 

2011 

2012 def get_last_content_change(self, silent: bool = False) -> tuple[User, datetime]: 

2013 """ 

2014 Get last content change metadata. 

2015 

2016 Used when committing pending changes, needs to handle and report inconsistencies 

2017 from past releases. 

2018 """ 

2019 from weblate.auth.models import get_anonymous 

2020 

2021 try: 

2022 change = self.recent_content_changes[0] 

2023 except IndexError: 

2024 return get_anonymous(), timezone.now() 

2025 return change.author or get_anonymous(), change.timestamp 

2026 

2027 def get_locations(self) -> Generator[tuple[str, str, str], None, None]: 

2028 """Return list of location filenames.""" 

2029 for location in self.location.split(","): 

2030 location = location.strip() 

2031 if not location: 

2032 continue 

2033 location_parts = location.split(":") 

2034 if len(location_parts) == 2: 

2035 filename, line = location_parts 

2036 else: 

2037 filename = location_parts[0] 

2038 line = "0" 

2039 yield location, filename, line 

2040 

2041 @cached_property 

2042 def all_labels(self): 

2043 from weblate.trans.models import Label 

2044 

2045 if self.is_source: 

2046 return self.labels.all() 

2047 return Label.objects.filter( 

2048 unit__id__in=(self.id, self.source_unit_id) 

2049 ).distinct() 

2050 

2051 def get_flag_actions(self): 

2052 flags = self.all_flags 

2053 translation = self.translation 

2054 component = translation.component 

2055 result = [] 

2056 if self.is_source: 

2057 if "read-only" in flags: 

2058 if ( 

2059 "read-only" not in translation.all_flags 

2060 and "read-only" not in component.all_flags 

2061 ): 

2062 result.append( 

2063 ("removeflag", "read-only", gettext("Unmark as read-only")) 

2064 ) 

2065 else: 

2066 result.append(("addflag", "read-only", gettext("Mark as read-only"))) 

2067 if component.is_glossary: 

2068 if "read-only" in self.source_unit.get_unit_flags(): 

2069 result.append( 

2070 ("removeflag", "read-only", gettext("Unmark as untranslatable")) 

2071 ) 

2072 else: 

2073 result.append( 

2074 ("addflag", "read-only", gettext("Mark as untranslatable")) 

2075 ) 

2076 if "forbidden" in flags: 

2077 result.append( 

2078 ( 

2079 "removeflag", 

2080 "forbidden", 

2081 gettext("Unmark as forbidden translation"), 

2082 ) 

2083 ) 

2084 else: 

2085 result.append( 

2086 ( 

2087 "addflag", 

2088 "forbidden", 

2089 gettext("Mark as forbidden translation"), 

2090 ) 

2091 ) 

2092 if "terminology" in flags: 

2093 result.append( 

2094 ( 

2095 "removeflag", 

2096 "terminology", 

2097 gettext("Unmark as terminology"), 

2098 ) 

2099 ) 

2100 else: 

2101 result.append( 

2102 ( 

2103 "addflag", 

2104 "terminology", 

2105 gettext("Mark as terminology"), 

2106 ) 

2107 ) 

2108 return result 

2109 

2110 def invalidate_related_cache(self) -> None: 

2111 # Invalidate stats counts 

2112 self.translation.invalidate_cache() 

2113 # Invalidate unit cached properties 

2114 for key in ["all_comments", "suggestions"]: 

2115 if key in self.__dict__: 

2116 del self.__dict__[key] 

2117 

2118 def update_explanation( 

2119 self, explanation: str, user: User, save: bool = True 

2120 ) -> bool: 

2121 """Update glossary explanation.""" 

2122 verify_in_transaction() 

2123 old = self.old_unit["explanation"] 

2124 if old == explanation: 

2125 return False 

2126 self.explanation = explanation 

2127 file_format_support = ( 

2128 self.translation.component.file_format_cls.supports_explanation 

2129 ) 

2130 units: Iterable[Unit] = [] 

2131 if self.is_source: 

2132 units = self.unit_set.exclude(id=self.id).select_for_update() 

2133 # Mark change as pending if file format supports this 

2134 if file_format_support: 

2135 for unit in units: 

2136 PendingUnitChange.store_unit_change( 

2137 unit=unit, 

2138 author=user, 

2139 ) 

2140 

2141 # translation file does not exist for source strings in bilingual formats 

2142 if ( 

2143 not self.is_source 

2144 or self.translation.component.file_format_cls.monolingual 

2145 ): 

2146 PendingUnitChange.store_unit_change( 

2147 unit=self, 

2148 author=user, 

2149 ) 

2150 

2151 if save: 

2152 self.save(update_fields=["explanation"], only_save=True) 

2153 

2154 # Always generate change for self 

2155 units = [*units, self] 

2156 

2157 for unit in units: 

2158 unit.generate_change( 

2159 user=user, 

2160 author=user, 

2161 change_action=ActionEvents.EXPLANATION, 

2162 check_new=False, 

2163 save=True, 

2164 target=explanation, 

2165 old=old, 

2166 ) 

2167 return True 

2168 

2169 def update_extra_flags( 

2170 self, extra_flags: str, user: User, save: bool = True 

2171 ) -> None: 

2172 """Update unit extra flags.""" 

2173 verify_in_transaction() 

2174 old = self.old_unit["extra_flags"] 

2175 if old == extra_flags: 

2176 return 

2177 self.extra_flags = extra_flags 

2178 units: Iterable[Unit] = [] 

2179 if self.is_source: 

2180 units = self.unit_set.select_for_update().exclude(id=self.id) 

2181 # Always generate change for self 

2182 units = [*units, self] 

2183 if save: 

2184 self.save(update_fields=["extra_flags"], same_content=True) 

2185 

2186 for unit in units: 

2187 unit.generate_change( 

2188 user=user, 

2189 author=user, 

2190 change_action=ActionEvents.EXTRA_FLAGS, 

2191 check_new=False, 

2192 save=True, 

2193 old=old, 

2194 target=self.extra_flags, 

2195 ) 

2196 

2197 @cached_property 

2198 def glossary_sort_key(self): 

2199 return (self.translation.component.priority, self.source.lower()) 

2200 

2201 def update_translation_memory( 

2202 self, user: User | None = None, *, needs_user_check: bool = True 

2203 ) -> None: 

2204 if needs_user_check and ( 2204 ↛ 2213line 2204 didn't jump to line 2213 because the condition on line 2204 was never true

2205 not user 

2206 or user.is_bot 

2207 or not user.is_active 

2208 or ( 

2209 self.target == self.old_unit["target"] 

2210 and self.state == self.old_unit["state"] 

2211 ) 

2212 ): 

2213 return 

2214 

2215 translation = self.translation 

2216 component = translation.component 

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

2218 (not translation.is_source or component.intermediate) 

2219 and (self.state >= STATE_TRANSLATED or self.state != self.old_unit["state"]) 

2220 and not component.is_glossary 

2221 and is_valid_memory_entry(source=self.source, target=self.target) 

2222 ): 

2223 handle_unit_translation_change(self, user) 

2224 

2225 @property 

2226 def has_pending_changes(self) -> bool: 

2227 return self.pending_changes.exists()