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
« 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
5from __future__ import annotations
7import logging
8from datetime import date, datetime, timedelta
9from typing import TYPE_CHECKING, ClassVar, overload
10from uuid import uuid5
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
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
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
44 from django_stubs_ext import StrOrPromise
46 from weblate.auth.models import User
47 from weblate.trans.models import Translation
49LOGGER = logging.getLogger("weblate.change")
51CHANGE_PROJECT_LOOKUP_KEY = "change:project-lookup"
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)
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}
77def dt_as_day_range(dt: datetime | date) -> tuple[datetime, datetime]:
78 """
79 Convert given datetime/date to a range for that day.
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 )
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)
100 def for_category(self, category) -> ChangeQuerySet:
101 return self.filter(
102 Q(component_id__in=category.all_component_ids) | Q(category=category)
103 )
105 def filter_announcements(self) -> ChangeQuerySet:
106 return self.filter(action=ActionEvents.ANNOUNCEMENT)
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)
119 # Count changes
120 int_base = self.filter(timestamp__range=(int_start, int_end))
121 count = int_base.aggregate(Count("id"))
123 # Append to result
124 result.append((int_start, count["id__count"]))
126 # Advance to next interval
127 dtstart = int_end
129 return result
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)
145 # Base for filtering
146 base = self.all()
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)
156 # Filter by language
157 if language is not None:
158 base = base.filter(language=language)
160 # Filter by language
161 if user is not None:
162 base = base.filter(user=user)
164 return base.count_stats(days, step, dtstart)
166 def prefetch_for_render(self) -> ChangeQuerySet:
167 """
168 Prefetch needed related fields for rendering.
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 )
180 def prefetch(self) -> ChangeQuerySet:
181 """
182 Fetch related fields at once to avoid loading them individually.
184 Call prefetch or prefetch_list later on paginated results to complete.
185 """
186 return self.prefetch_related(*PREFETCH_FIELDS)
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
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 )
230 def order(self) -> ChangeQuerySet:
231 return self.order_by("-timestamp")
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.
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)
250 def bulk_create(self, *args, **kwargs) -> list[Change]:
251 """
252 Bulk creation of changes.
254 Add processing to bulk creation.
255 """
256 from weblate.accounts.notifications import dispatch_changes_notifications
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)
261 # Dispatch notifications
262 dispatch_changes_notifications(changes)
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)
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)
286 # Log to the log
287 for change in changes:
288 change.log_event()
290 return changes
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 )
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)
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
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
325 def filter_by_day(self, dt: datetime | date) -> ChangeQuerySet:
326 """
327 Filter changes by given date.
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))
333 def since_day(self, dt: datetime | date) -> ChangeQuerySet:
334 """
335 Filter changes since given date.
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])
341 def count_users(self) -> int:
342 """
343 Count contributing users.
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 )
355class ChangeManager(models.Manager["Change"]):
356 def create(self, *, user=None, **kwargs):
357 """
358 Create a change object.
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)
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.
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()
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 }
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
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 }
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)
494 objects = ChangeManager.from_queryset(ChangeQuerySet)()
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"
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 }
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()
573 def save(self, *args, **kwargs) -> None:
574 self.fixup_references()
576 super().save(*args, **kwargs)
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()
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()
593 self.log_event()
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 "/"
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)
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
642 @staticmethod
643 def get_last_change_cache_key(translation_id: int) -> str:
644 return f"last-content-change-{translation_id}"
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)
652 def is_last_content_change_storable(self) -> bool:
653 return self.translation_id is not None
655 def update_cache_last_change(self) -> None:
656 self.store_last_change(self.translation, self)
658 def fixup_references(self) -> None:
659 """
660 Update references based to least specific one.
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
679 @property
680 def plural_count(self):
681 return self.details.get("count", 1)
683 @property
684 def auto_status(self):
685 return self.details.get("auto", False)
687 def get_action_display(self):
688 return str(self.ACTIONS_DICT.get(self.action, self.action))
690 def get_state_display(self):
691 state = self.details.get("state")
692 if state is None:
693 return ""
694 return StringState(state).label
696 def is_merge_failure(self):
697 return self.action in ACTIONS_MERGE_FAILURE
699 def can_revert(self):
700 return self.unit is not None and self.old and self.action in ACTIONS_REVERTABLE
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 }
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 }
716 def show_removed_string(self):
717 """Whether to show content as source change."""
718 return self.action == ActionEvents.STRING_REMOVE
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
724 def get_details_display(self) -> str:
725 from weblate.trans.change_display import ChangeDetailsRenderFactory
727 strategy = ChangeDetailsRenderFactory.get_strategy(self)
728 return strategy.render_details(self)
730 def get_distance(self):
731 return DamerauLevenshtein.distance(self.old, self.target)
733 def get_source(self):
734 return self.details.get("source", self.unit.source)
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
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 }
754 def get_uuid(self) -> UUID:
755 """Return uuid for this change."""
756 return uuid5(WEBLATE_UUID_NAMESPACE, f"{self.action}.{self.id}")
758 def fill_in_prefetched(self) -> None:
759 """
760 Fill in prefetched data into nested objects.
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
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
782 dispatch_changes_notifications([instance])