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

368 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 logging 

8from datetime import date, datetime, timedelta 

9from typing import TYPE_CHECKING, ClassVar, overload 

10from uuid import uuid5 

11 

12import sentry_sdk 

13from django.conf import settings 

14from django.core.cache import cache 

15from django.db import connection, models, transaction 

16from django.db.models import Count, Q 

17from django.db.models.signals import post_save 

18from django.dispatch import receiver 

19from django.utils import timezone 

20from django.utils.translation import gettext, gettext_lazy 

21from rapidfuzz.distance import DamerauLevenshtein 

22 

23from weblate.trans.actions import ( 

24 ACTIONS_ADDON, 

25 ACTIONS_CONTENT, 

26 ACTIONS_LOG, 

27 ACTIONS_MERGE_FAILURE, 

28 ACTIONS_REPOSITORY, 

29 ACTIONS_REVERTABLE, 

30 ACTIONS_SHOW_CONTENT, 

31 ActionEvents, 

32) 

33from weblate.trans.mixins import UserDisplayMixin 

34from weblate.trans.models.project import Project 

35from weblate.trans.signals import change_bulk_create 

36from weblate.utils.const import WEBLATE_UUID_NAMESPACE 

37from weblate.utils.decorators import disable_for_loaddata 

38from weblate.utils.state import StringState 

39 

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

41 from collections.abc import Iterable 

42 from uuid import UUID 

43 

44 from django_stubs_ext import StrOrPromise 

45 

46 from weblate.auth.models import User 

47 from weblate.trans.models import Translation 

48 

49LOGGER = logging.getLogger("weblate.change") 

50 

51CHANGE_PROJECT_LOOKUP_KEY = "change:project-lookup" 

52 

53PREFETCH_FIELDS = ( 

54 "user", 

55 "author", 

56 "translation", 

57 "component", 

58 "category", 

59 "project", 

60 "component__source_language", 

61 "unit", 

62 "unit__source_unit", 

63 "translation__language", 

64 "translation__plural", 

65) 

66 

67COMPONENT_ORIGINS = { 

68 "scratch": gettext_lazy("Component created from scratch"), 

69 "branch": gettext_lazy("Component created as a branch"), 

70 "api": gettext_lazy("Component created via API"), 

71 "vcs": gettext_lazy("Component created from version control"), 

72 "zip": gettext_lazy("Component created via ZIP upload"), 

73 "document": gettext_lazy("Component created via document upload"), 

74} 

75 

76 

77def dt_as_day_range(dt: datetime | date) -> tuple[datetime, datetime]: 

78 """ 

79 Convert given datetime/date to a range for that day. 

80 

81 The resulting tuple contains the start of the day (00:00:00) and end of the 

82 day (23:59:59.999999). 

83 """ 

84 if isinstance(dt, date): 84 ↛ 86line 84 didn't jump to line 86 because the condition on line 84 was always true

85 dt = timezone.make_aware(datetime.combine(dt, datetime.min.time())) 

86 return ( 

87 dt.replace(hour=0, minute=0, second=0, microsecond=0), 

88 dt.replace(hour=23, minute=59, second=59, microsecond=999999), 

89 ) 

90 

91 

92class ChangeQuerySet(models.QuerySet["Change"]): 

93 def content(self, prefetch=False) -> ChangeQuerySet: 

94 """Return queryset with content changes.""" 

95 base = self 

96 if prefetch: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true

97 base = base.prefetch() 

98 return base.filter(action__in=ACTIONS_CONTENT) 

99 

100 def for_category(self, category) -> ChangeQuerySet: 

101 return self.filter( 

102 Q(component_id__in=category.all_component_ids) | Q(category=category) 

103 ) 

104 

105 def filter_announcements(self) -> ChangeQuerySet: 

106 return self.filter(action=ActionEvents.ANNOUNCEMENT) 

107 

108 def count_stats( 

109 self, days: int, step: int, dtstart: datetime 

110 ) -> list[tuple[datetime, int]]: 

111 """Count the number of changes in a given period grouped by step days.""" 

112 # Count number of changes 

113 result = [] 

114 for _unused in range(0, days, step): 

115 # Calculate interval 

116 int_start = dtstart 

117 int_end = int_start + timedelta(days=step) 

118 

119 # Count changes 

120 int_base = self.filter(timestamp__range=(int_start, int_end)) 

121 count = int_base.aggregate(Count("id")) 

122 

123 # Append to result 

124 result.append((int_start, count["id__count"])) 

125 

126 # Advance to next interval 

127 dtstart = int_end 

128 

129 return result 

130 

131 def base_stats( 

132 self, 

133 days: int, 

134 step: int, 

135 project=None, 

136 component=None, 

137 translation=None, 

138 language=None, 

139 user=None, 

140 ) -> list[tuple[datetime, int]]: 

141 """Core of daily/weekly/monthly stats calculation.""" 

142 # Get range (actually start) 

143 dtstart = timezone.now() - timedelta(days=days + 1) 

144 

145 # Base for filtering 

146 base = self.all() 

147 

148 # Filter by translation/project 

149 if translation is not None: 

150 base = base.filter(translation=translation) 

151 elif component is not None: 

152 base = base.filter(component=component) 

153 elif project is not None: 

154 base = base.filter(project=project) 

155 

156 # Filter by language 

157 if language is not None: 

158 base = base.filter(language=language) 

159 

160 # Filter by language 

161 if user is not None: 

162 base = base.filter(user=user) 

163 

164 return base.count_stats(days, step, dtstart) 

165 

166 def prefetch_for_render(self) -> ChangeQuerySet: 

167 """ 

168 Prefetch needed related fields for rendering. 

169 

170 Might be used with Change.fill_in_prefetched. 

171 """ 

172 return self.prefetch().select_related( 

173 "alert", 

174 "screenshot", 

175 "announcement", 

176 "suggestion", 

177 "comment", 

178 ) 

179 

180 def prefetch(self) -> ChangeQuerySet: 

181 """ 

182 Fetch related fields at once to avoid loading them individually. 

183 

184 Call prefetch or prefetch_list later on paginated results to complete. 

185 """ 

186 return self.prefetch_related(*PREFETCH_FIELDS) 

187 

188 @overload 

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

190 self, results: ChangeQuerySet, skip: str | None = None 

191 ) -> ChangeQuerySet: ... 

192 @overload 

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

194 self, results: list[Change], skip: str | None = None 

195 ) -> list[Change]: ... 

196 def preload_list(self, results, skip=None): 

197 """Companion for prefetch to fill in nested references.""" 

198 for item in results: 

199 if item.component and skip != "component": 

200 item.component.project = item.project 

201 if item.translation and skip != "translation": 

202 item.translation.component = item.component 

203 if item.unit and skip != "unit": 

204 item.unit.translation = item.translation 

205 return results 

206 

207 def authors_list( 

208 self, 

209 date_range: tuple[datetime, datetime] | None = None, 

210 *, 

211 values_list: tuple[str, ...] = (), 

212 ) -> Iterable[tuple]: 

213 """Return list of authors.""" 

214 authors = self.content() 

215 if date_range is not None: 

216 authors = authors.filter(timestamp__range=date_range) 

217 return ( 

218 authors.exclude(author__isnull=True) 

219 .values("author") 

220 .annotate(change_count=Count("id")) 

221 .values_list( 

222 "author__email", 

223 "author__username", 

224 "author__full_name", 

225 "change_count", 

226 *values_list, 

227 ) 

228 ) 

229 

230 def order(self) -> ChangeQuerySet: 

231 return self.order_by("-timestamp") 

232 

233 def recent( 

234 self, *, count: int = 10, skip_preload: str | None = None 

235 ) -> list[Change]: 

236 """ 

237 Return recent changes to show on object pages. 

238 

239 This uses iterator() as server-side cursors are typically 

240 more effective here. 

241 """ 

242 result: list[Change] = [] 

243 with transaction.atomic(), sentry_sdk.start_span(op="change.recent"): 

244 for change in self.order().iterator(chunk_size=count): 

245 result.append(change) 

246 if len(result) >= count: 

247 break 

248 return self.preload_list(result, skip_preload) 

249 

250 def bulk_create(self, *args, **kwargs) -> list[Change]: 

251 """ 

252 Bulk creation of changes. 

253 

254 Add processing to bulk creation. 

255 """ 

256 from weblate.accounts.notifications import dispatch_changes_notifications 

257 

258 if connection.features.can_return_rows_from_bulk_insert: 258 ↛ 269line 258 didn't jump to line 269 because the condition on line 258 was always true

259 changes = super().bulk_create(*args, **kwargs) 

260 

261 # Dispatch notifications 

262 dispatch_changes_notifications(changes) 

263 

264 # Executes post save to ensure messages are sent to fedora messaging 

265 change_bulk_create.send(Change, instances=changes) 

266 else: 

267 # bulk_create doesn't set the .pk of instance with MySQL 

268 # Save each instance individually in order to set it 

269 changes = [] 

270 for change in args[0]: 

271 change.save() 

272 changes.append(change) 

273 

274 # Store last content change in cache for improved performance 

275 translations = set() 

276 for change in reversed(changes): 

277 # Process latest change on each translation (when invoked in 

278 # Translation.add_unit, it spans multiple translations) 

279 if ( 

280 change.translation_id not in translations 

281 and change.is_last_content_change_storable() 

282 ): 

283 transaction.on_commit(change.update_cache_last_change) 

284 translations.add(change.translation_id) 

285 

286 # Log to the log 

287 for change in changes: 

288 change.log_event() 

289 

290 return changes 

291 

292 def filter_components(self, user: User) -> ChangeQuerySet: 

293 if not user.needs_component_restrictions_filter: 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was always true

294 return self 

295 return self.filter( 

296 Q(component__isnull=True) 

297 | Q(component__restricted=False) 

298 | Q(component_id__in=user.component_permissions) 

299 ) 

300 

301 def filter_projects(self, user: User) -> ChangeQuerySet: 

302 if not user.needs_project_filter: 302 ↛ 304line 302 didn't jump to line 304 because the condition on line 302 was always true

303 return self 

304 return self.filter(project__in=user.allowed_projects) 

305 

306 def lookup_project_rename(self, name: str) -> Project | None: 

307 lookup = cache.get(CHANGE_PROJECT_LOOKUP_KEY) 

308 if lookup is None: 

309 lookup = self.generate_project_rename_lookup() 

310 if name not in lookup: 

311 return None 

312 try: 

313 return Project.objects.get(pk=lookup[name]) 

314 except Project.DoesNotExist: 

315 return None 

316 

317 def generate_project_rename_lookup(self) -> dict[str, int]: 

318 lookup: dict[str, int] = {} 

319 for change in self.filter(action=ActionEvents.RENAME_PROJECT).order(): 

320 if change.old not in lookup and change.project_id is not None: 

321 lookup[change.old] = change.project_id 

322 cache.set(CHANGE_PROJECT_LOOKUP_KEY, lookup, 3600 * 24 * 7) 

323 return lookup 

324 

325 def filter_by_day(self, dt: datetime | date) -> ChangeQuerySet: 

326 """ 

327 Filter changes by given date. 

328 

329 Optimized to use Database index by not converting timestamp to date object 

330 """ 

331 return self.filter(timestamp__range=dt_as_day_range(dt)) 

332 

333 def since_day(self, dt: datetime | date) -> ChangeQuerySet: 

334 """ 

335 Filter changes since given date. 

336 

337 Optimized to use Database index by not converting timestamp to date object 

338 """ 

339 return self.filter(timestamp__gte=dt_as_day_range(dt)[0]) 

340 

341 def count_users(self) -> int: 

342 """ 

343 Count contributing users. 

344 

345 Used mostly in the metrics. 

346 """ 

347 return ( 

348 self.filter(user__is_active=True, user__is_bot=False) 

349 .values("user") 

350 .distinct() 

351 .count() 

352 ) 

353 

354 

355class ChangeManager(models.Manager["Change"]): 

356 def create(self, *, user=None, **kwargs): 

357 """ 

358 Create a change object. 

359 

360 Wrapper to avoid using anonymous user as change owner. 

361 """ 

362 if user is not None and not user.is_authenticated: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 user = None 

364 return super().create(user=user, **kwargs) 

365 

366 def last_changes( 

367 self, 

368 user, 

369 unit=None, 

370 translation=None, 

371 component=None, 

372 project=None, 

373 category=None, 

374 language=None, 

375 ): 

376 """ 

377 Return the most recent changes for an user. 

378 

379 Filters Change objects by user permissions and fetches related fields for 

380 last changes display. 

381 """ 

382 if unit is not None: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 if not user.can_access_component(unit.translation.component): 

384 return self.none() 

385 result = unit.change_set.all() 

386 elif translation is not None: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 if not user.can_access_component(translation.component): 

388 return self.none() 

389 result = translation.change_set.all() 

390 elif component is not None: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 if not user.can_access_component(component): 

392 return self.none() 

393 result = component.change_set.all() 

394 elif project is not None: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 if not user.can_access_project(project): 

396 return self.none() 

397 result = project.change_set.filter_components(user) 

398 if language is not None: 

399 result = result.filter(language=language) 

400 if category is not None: 

401 result = result.filter(category=category) 

402 elif language is not None: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 result = language.change_set.filter_projects(user).filter_components(user) 

404 else: 

405 result = self.filter_projects(user).filter_components(user) 

406 return result.prefetch_for_render().order() 

407 

408 

409class Change(models.Model, UserDisplayMixin): 

410 ACTIONS_DICT: ClassVar[dict[ActionEvents, StrOrPromise]] = dict( 

411 ActionEvents.choices 

412 ) 

413 ACTION_STRINGS: ClassVar[dict[str, int]] = { 

414 name.lower().replace(" ", "-"): value for value, name in ActionEvents.choices 

415 } 

416 

417 ACTIONS_REVERTABLE = ACTIONS_REVERTABLE 

418 ACTIONS_CONTENT = ACTIONS_CONTENT 

419 ACTIONS_REPOSITORY = ACTIONS_REPOSITORY 

420 ACTIONS_SHOW_CONTENT = ACTIONS_SHOW_CONTENT 

421 ACTIONS_MERGE_FAILURE = ACTIONS_MERGE_FAILURE 

422 ACTIONS_ADDON = ACTIONS_ADDON 

423 

424 ACTION_NAMES: ClassVar[set[str]] = { 

425 str(name): value for value, name in ActionEvents.choices 

426 } 

427 AUTO_ACTIONS: ClassVar[dict[ActionEvents, StrOrPromise]] = { 

428 # Translators: Name of event in the history 

429 ActionEvents.LOCK: gettext_lazy( 

430 "The component was automatically locked because of an alert." 

431 ), 

432 # Translators: Name of event in the history 

433 ActionEvents.UNLOCK: gettext_lazy( 

434 "Fixing an alert automatically unlocked the component." 

435 ), 

436 } 

437 

438 unit = models.ForeignKey( 

439 "trans.Unit", null=True, on_delete=models.deletion.CASCADE, db_index=False 

440 ) 

441 language = models.ForeignKey( 

442 "lang.Language", null=True, on_delete=models.deletion.CASCADE, db_index=False 

443 ) 

444 project = models.ForeignKey( 

445 "trans.Project", null=True, on_delete=models.deletion.CASCADE, db_index=False 

446 ) 

447 category = models.ForeignKey( 

448 "trans.Category", null=True, on_delete=models.deletion.CASCADE, db_index=False 

449 ) 

450 component = models.ForeignKey( 

451 "trans.Component", null=True, on_delete=models.deletion.CASCADE, db_index=False 

452 ) 

453 translation = models.ForeignKey( 

454 "trans.Translation", 

455 null=True, 

456 on_delete=models.deletion.CASCADE, 

457 db_index=False, 

458 ) 

459 comment = models.ForeignKey( 

460 "trans.Comment", null=True, on_delete=models.deletion.SET_NULL 

461 ) 

462 suggestion = models.ForeignKey( 

463 "trans.Suggestion", null=True, on_delete=models.deletion.SET_NULL 

464 ) 

465 announcement = models.ForeignKey( 

466 "trans.Announcement", null=True, on_delete=models.deletion.SET_NULL 

467 ) 

468 screenshot = models.ForeignKey( 

469 "screenshots.Screenshot", 

470 null=True, 

471 on_delete=models.deletion.SET_NULL, 

472 ) 

473 alert = models.ForeignKey( 

474 "trans.Alert", null=True, on_delete=models.deletion.SET_NULL 

475 ) 

476 user = models.ForeignKey( 

477 settings.AUTH_USER_MODEL, null=True, on_delete=models.deletion.CASCADE 

478 ) 

479 author = models.ForeignKey( 

480 settings.AUTH_USER_MODEL, 

481 null=True, 

482 related_name="+", 

483 db_index=False, 

484 on_delete=models.deletion.CASCADE, 

485 ) 

486 timestamp = models.DateTimeField(auto_now_add=True) 

487 action = models.IntegerField( 

488 choices=ActionEvents.choices, default=ActionEvents.CHANGE 

489 ) 

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

491 old = models.TextField(default="", blank=True) 

492 details = models.JSONField(default=dict) 

493 

494 objects = ChangeManager.from_queryset(ChangeQuerySet)() 

495 

496 class Meta: 

497 app_label = "trans" 

498 indexes = [ # noqa: RUF012 

499 models.Index( 

500 fields=["-timestamp", "action"], 

501 name="trans_change_action_idx", 

502 ), 

503 models.Index( 

504 fields=["project", "-timestamp", "action"], 

505 condition=Q(project__isnull=False), 

506 name="trans_change_project_idx", 

507 ), 

508 models.Index( 

509 fields=["language", "-timestamp", "action"], 

510 condition=Q(language__isnull=False), 

511 name="trans_change_language_idx", 

512 ), 

513 models.Index( 

514 fields=["project", "language", "-timestamp", "action"], 

515 condition=Q(project__isnull=False) & Q(language__isnull=False), 

516 name="trans_change_prj_language_idx", 

517 ), 

518 models.Index( 

519 fields=["component", "-timestamp", "action"], 

520 condition=Q(component__isnull=False), 

521 name="trans_change_component_idx", 

522 ), 

523 models.Index( 

524 fields=["translation", "-timestamp", "action"], 

525 condition=Q(translation__isnull=False), 

526 name="trans_change_translation_idx", 

527 ), 

528 models.Index( 

529 fields=["category", "-timestamp", "action"], 

530 condition=Q(category__isnull=False), 

531 name="trans_change_category_idx", 

532 ), 

533 models.Index( 

534 fields=["unit", "-timestamp", "action"], 

535 condition=Q(unit__isnull=False), 

536 name="trans_change_unit_idx", 

537 ), 

538 models.Index( 

539 fields=["user", "-timestamp", "action"], 

540 name="trans_change_user_idx", 

541 ), 

542 ] 

543 verbose_name = "history event" 

544 verbose_name_plural = "history events" 

545 

546 def __str__(self) -> str: 

547 if self.user: 

548 # Translators: condensed rendering of a change action in history 

549 return gettext("%(action)s at %(time)s on %(translation)s by %(user)s") % { 

550 "action": self.get_action_display(), 

551 "time": self.timestamp, 

552 "translation": self.translation or self.component or self.project, 

553 "user": self.get_user_display(False), 

554 } 

555 # Translators: condensed rendering of a change action in history 

556 return gettext("%(action)s at %(time)s on %(translation)s") % { 

557 "action": self.get_action_display(), 

558 "time": self.timestamp, 

559 "translation": self.translation or self.component or self.project, 

560 } 

561 

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

563 self.notify_state = {} 

564 for attr in ("user", "author"): 

565 user = kwargs.get(attr) 

566 if user is not None and hasattr(user, "get_token_user"): 566 ↛ 568line 566 didn't jump to line 568 because the condition on line 566 was never true

567 # ProjectToken / ProjectUser integration 

568 kwargs[attr] = user.get_token_user() 

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

570 if not self.pk: 

571 self.fixup_references() 

572 

573 def save(self, *args, **kwargs) -> None: 

574 self.fixup_references() 

575 

576 super().save(*args, **kwargs) 

577 

578 if self.is_last_content_change_storable(): 

579 # Update cache for stats so that it does not have to hit 

580 # the database again 

581 self.translation.stats.last_change_cache = self 

582 # Update currently loaded 

583 if self.translation.stats.is_loaded: 583 ↛ 586line 583 didn't jump to line 586 because the condition on line 583 was always true

584 self.translation.stats.fetch_last_change() 

585 # Update stats at the end of transaction 

586 transaction.on_commit(self.update_cache_last_change) 

587 # Make sure stats is updated at the end of transaction 

588 self.translation.invalidate_cache() 

589 

590 if self.action == ActionEvents.RENAME_PROJECT: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

591 Change.objects.generate_project_rename_lookup() 

592 

593 self.log_event() 

594 

595 def get_absolute_url(self) -> str: 

596 """Return link either to unit or translation.""" 

597 if self.unit is not None: 

598 return self.unit.get_absolute_url() 

599 if self.screenshot is not None: 

600 return self.screenshot.get_absolute_url() 

601 if self.translation is not None: 

602 return self.translation.get_absolute_url() 

603 if self.component is not None: 

604 return self.component.get_absolute_url() 

605 if self.category is not None: 

606 return self.category.get_absolute_url() 

607 if self.project is not None: 

608 return self.project.get_absolute_url() 

609 return "/" 

610 

611 def log_event(self) -> None: 

612 if self.action in ACTIONS_LOG: 

613 message = self.get_action_display() 

614 if self.user: 614 ↛ 616line 614 didn't jump to line 616 because the condition on line 614 was always true

615 message = f"{message} ({self.user.username})" 

616 if self.author and self.author != self.user: 616 ↛ 617line 616 didn't jump to line 617 because the condition on line 616 was never true

617 message = f"{message} ({self.author.username})" 

618 if self.target: 

619 message = f"{message}: {self.target}" 

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

621 self.translation.log_info("%s", message) 

622 elif self.component: 

623 self.component.log_info("%s", message) 

624 elif self.project: 624 ↛ 627line 624 didn't jump to line 627 because the condition on line 624 was always true

625 self.project.log_info("%s", message) 

626 else: 

627 LOGGER.info("%s", message) 

628 

629 @property 

630 def path_object(self): 

631 """Return link either to unit or translation.""" 

632 if self.translation is not None: 

633 return self.translation 

634 if self.component is not None: 

635 return self.component 

636 if self.category is not None: 

637 return self.category 

638 if self.project is not None: 

639 return self.project 

640 return None 

641 

642 @staticmethod 

643 def get_last_change_cache_key(translation_id: int) -> str: 

644 return f"last-content-change-{translation_id}" 

645 

646 @classmethod 

647 def store_last_change(cls, translation: Translation, change: Change | None) -> None: 

648 translation.stats.last_change_cache = change 

649 cache_key = cls.get_last_change_cache_key(translation.id) 

650 cache.set(cache_key, change.pk if change else 0, 180 * 86400) 

651 

652 def is_last_content_change_storable(self) -> bool: 

653 return self.translation_id is not None 

654 

655 def update_cache_last_change(self) -> None: 

656 self.store_last_change(self.translation, self) 

657 

658 def fixup_references(self) -> None: 

659 """ 

660 Update references based to least specific one. 

661 

662 Update Change.fill_in_prefetched together with this one 

663 """ 

664 if self.unit: 

665 self.translation = self.unit.translation 

666 if self.screenshot: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true

667 self.translation = self.screenshot.translation 

668 if self.translation: 

669 self.component = self.translation.component 

670 self.language = self.translation.language 

671 if self.component: 

672 self.project = self.component.project 

673 self.category = self.component.category 

674 if (self.user is None or not self.user.is_authenticated) and ( 674 ↛ 677line 674 didn't jump to line 677 because the condition on line 674 was never true

675 ip_address := self.get_ip_address() 

676 ): 

677 self.details["ip_address"] = ip_address 

678 

679 @property 

680 def plural_count(self): 

681 return self.details.get("count", 1) 

682 

683 @property 

684 def auto_status(self): 

685 return self.details.get("auto", False) 

686 

687 def get_action_display(self): 

688 return str(self.ACTIONS_DICT.get(self.action, self.action)) 

689 

690 def get_state_display(self): 

691 state = self.details.get("state") 

692 if state is None: 

693 return "" 

694 return StringState(state).label 

695 

696 def is_merge_failure(self): 

697 return self.action in ACTIONS_MERGE_FAILURE 

698 

699 def can_revert(self): 

700 return self.unit is not None and self.old and self.action in ACTIONS_REVERTABLE 

701 

702 def show_source(self): 

703 """Whether to show content as source change.""" 

704 return self.action in { 

705 ActionEvents.SOURCE_CHANGE, 

706 ActionEvents.NEW_SOURCE, 

707 } 

708 

709 def show_diff(self): 

710 """Whether to show content as diff.""" 

711 return self.action in { 

712 ActionEvents.EXPLANATION, 

713 ActionEvents.EXTRA_FLAGS, 

714 } 

715 

716 def show_removed_string(self): 

717 """Whether to show content as source change.""" 

718 return self.action == ActionEvents.STRING_REMOVE 

719 

720 def show_content(self): 

721 """Whether to show content as translation.""" 

722 return self.action in ACTIONS_SHOW_CONTENT or self.action in ACTIONS_REVERTABLE 

723 

724 def get_details_display(self) -> str: 

725 from weblate.trans.change_display import ChangeDetailsRenderFactory 

726 

727 strategy = ChangeDetailsRenderFactory.get_strategy(self) 

728 return strategy.render_details(self) 

729 

730 def get_distance(self): 

731 return DamerauLevenshtein.distance(self.old, self.target) 

732 

733 def get_source(self): 

734 return self.details.get("source", self.unit.source) 

735 

736 def get_ip_address(self) -> str | None: 

737 if ip_address := self.details.get("ip_address"): 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true

738 return ip_address 

739 if self.suggestion and ( 739 ↛ 742line 739 didn't jump to line 742 because the condition on line 739 was never true

740 ip_address := self.suggestion.userdetails.get("address") 

741 ): 

742 return ip_address 

743 if self.comment and (ip_address := self.comment.userdetails.get("address")): 743 ↛ 744line 743 didn't jump to line 744 because the condition on line 743 was never true

744 return ip_address 

745 return None 

746 

747 def show_unit_state(self): 

748 return "state" in self.details and self.action not in { 

749 ActionEvents.SUGGESTION, 

750 ActionEvents.SUGGESTION_DELETE, 

751 ActionEvents.SUGGESTION_CLEANUP, 

752 } 

753 

754 def get_uuid(self) -> UUID: 

755 """Return uuid for this change.""" 

756 return uuid5(WEBLATE_UUID_NAMESPACE, f"{self.action}.{self.id}") 

757 

758 def fill_in_prefetched(self) -> None: 

759 """ 

760 Fill in prefetched data into nested objects. 

761 

762 - Based on fixup_references. 

763 - Uses data from prefetch_for_render 

764 """ 

765 if self.unit: 

766 self.unit.translation = self.translation 

767 if self.screenshot: 767 ↛ 768line 767 didn't jump to line 768 because the condition on line 767 was never true

768 self.screenshot.translation = self.translation 

769 if self.translation: 

770 self.translation.component = self.component 

771 self.translation.language = self.language 

772 if self.component: 

773 self.component.project = self.project 

774 self.component.category = self.category 

775 

776 

777@receiver(post_save, sender=Change) 

778@disable_for_loaddata 

779def change_notify(sender, instance, created=False, **kwargs) -> None: 

780 from weblate.accounts.notifications import dispatch_changes_notifications 

781 

782 dispatch_changes_notifications([instance])