Coverage for app/venv/lib/python3.14/site-packages/weblate/addons/models.py: 45%
284 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 typing import TYPE_CHECKING, Literal, Protocol, overload
10import sentry_sdk
11from appconf import AppConf
12from django.db import Error as DjangoDatabaseError
13from django.db import models, transaction
14from django.db.models import Q
15from django.db.models.signals import post_save
16from django.dispatch import receiver
17from django.urls import reverse
18from django.utils.functional import cached_property
20from weblate.logger import LOGGER
21from weblate.trans.actions import ActionEvents
22from weblate.trans.models import Alert, Change, Component, Project, Unit
23from weblate.trans.signals import (
24 change_bulk_create,
25 component_post_update,
26 translation_post_add,
27 unit_post_sync,
28 unit_pre_create,
29 vcs_post_commit,
30 vcs_post_push,
31 vcs_post_update,
32 vcs_pre_commit,
33 vcs_pre_push,
34 vcs_pre_update,
35)
36from weblate.utils.classloader import ClassLoader
37from weblate.utils.decorators import disable_for_loaddata
38from weblate.utils.errors import report_error
40from .base import BaseAddon
41from .events import AddonEvent
43if TYPE_CHECKING: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 from collections.abc import Callable, Iterable
46 from django.db.models import QuerySet
47 from django_stubs_ext import StrOrPromise
49 from weblate.auth.models import User
50 from weblate.trans.models import Translation
52# Initialize addons registry
53ADDONS = ClassLoader("WEBLATE_ADDONS", construct=False, base_class=BaseAddon)
56class AddonQuerySet(models.QuerySet):
57 def filter_for_execution(self, component):
58 query = (
59 Q(component=component)
60 | Q(project=component.project)
61 | (Q(component__linked_component=component) & Q(repo_scope=True))
62 | (Q(component__isnull=True) & Q(project__isnull=True))
63 )
64 if component.linked_component: 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true
65 query |= Q(component=component.linked_component) & Q(repo_scope=True)
66 return self.filter(query).prefetch_related("event_set")
68 def filter_component(self, component):
69 return self.prefetch_related("event_set").filter(component=component)
71 def filter_project(self, project):
72 return self.prefetch_related("event_set").filter(project=project)
74 def filter_sitewide(self):
75 return self.prefetch_related("event_set").filter(
76 component__isnull=True, project__isnull=True
77 )
79 def filter_event(self, component, event):
80 return component.addons_cache[event]
83class Addon(models.Model):
84 component = models.ForeignKey(
85 Component, on_delete=models.deletion.CASCADE, null=True
86 )
87 project = models.ForeignKey(Project, on_delete=models.deletion.CASCADE, null=True)
88 name = models.CharField(max_length=100)
89 configuration = models.JSONField(default=dict)
90 state = models.JSONField(default=dict)
91 repo_scope = models.BooleanField(default=False, db_index=True)
93 objects = AddonQuerySet.as_manager()
95 class Meta:
96 verbose_name = "add-on"
97 verbose_name_plural = "add-ons"
99 def __str__(self) -> str:
100 return f"{self.addon.verbose}: {self.project or self.component or 'site-wide'}"
102 def __init__(self, *args, acting_user: User | None = None, **kwargs) -> None:
103 super().__init__(*args, **kwargs)
104 self.acting_user = acting_user
106 def save(
107 self, force_insert=False, force_update=False, using=None, update_fields=None
108 ):
109 cls = self.addon_class
110 self.repo_scope = cls.repo_scope
112 original_component = None
113 if cls.project_scope:
114 original_component = self.component
115 if self.component:
116 self.project = self.component.project
117 self.component = None
119 # Reallocate to repository
120 if cls.repo_scope and self.component and self.component.linked_component:
121 original_component = self.component
122 self.component = self.component.linked_component
124 # Store history (if not updating state only)
125 if update_fields != ["state"]:
126 self.store_change(
127 ActionEvents.ADDON_CREATE
128 if not self.pk or force_insert
129 else ActionEvents.ADDON_CHANGE
130 )
132 # Clear add-on cache, needs to be after creating Change
133 if self.component:
134 self.component.drop_addons_cache()
135 if original_component:
136 original_component.drop_addons_cache()
138 return super().save(
139 force_insert=force_insert,
140 force_update=force_update,
141 using=using,
142 update_fields=update_fields,
143 )
145 def get_absolute_url(self) -> str:
146 return reverse("addon-detail", kwargs={"pk": self.pk})
148 def store_change(self, action) -> None:
149 Change.objects.create(
150 action=action,
151 user=self.acting_user,
152 project=self.project,
153 component=self.component,
154 target=self.name,
155 details=self.configuration,
156 )
158 def configure_events(self, events: set[AddonEvent]) -> None:
159 for event in events:
160 Event.objects.get_or_create(addon=self, event=event)
161 self.event_set.exclude(event__in=events).delete()
163 @cached_property
164 def addon_class(self) -> type[BaseAddon]:
165 return ADDONS[self.name]
167 @cached_property
168 def addon(self) -> BaseAddon:
169 return self.addon_class(self)
171 @property
172 def is_valid(self) -> bool:
173 return self.name in ADDONS
175 @property
176 def addon_name(self) -> str:
177 if not self.is_valid:
178 return self.name
179 return self.addon.name
181 def delete(self, using=None, keep_parents=False):
182 # Store history
183 self.store_change(ActionEvents.ADDON_REMOVE)
184 # Delete any addon alerts
185 if self.is_valid and self.addon.alert:
186 if self.component:
187 self.component.delete_alert(self.addon.alert)
188 elif self.project:
189 Alert.objects.filter(
190 component__project=self.project,
191 name=self.addon.alert,
192 ).delete()
193 else:
194 Alert.objects.filter(name=self.addon.alert).delete()
196 result = super().delete(using=using, keep_parents=keep_parents)
197 if self.component:
198 self.component.drop_addons_cache()
199 # Trigger post uninstall action
200 if self.is_valid:
201 self.addon.post_uninstall()
202 return result
204 def disable(self) -> None:
205 self.log_warning("disabling no longer compatible add-on: %s", self.name)
206 self.delete()
208 @cached_property
209 def logger(self) -> logging.Logger:
210 return logging.getLogger("weblate.addons")
212 def log_warning(self, message: str, *args) -> None:
213 if self.project:
214 self.project.log_warning(message, *args)
215 elif self.component:
216 self.component.log_warning(message, *args)
217 else:
218 self.logger.warning(message, *args)
220 def log_debug(self, message: str, *args) -> None:
221 if self.project:
222 self.project.log_debug(message, *args)
223 elif self.component:
224 self.component.log_debug(message, *args)
225 else:
226 self.logger.debug(message, *args)
228 def get_addon_activity_logs(self) -> QuerySet[AddonActivityLog]:
229 """Return activity logs for add-on."""
230 return self.addonactivitylog_set.order_by("-created")
233class Event(models.Model):
234 addon = models.ForeignKey(Addon, on_delete=models.deletion.CASCADE, db_index=False)
235 event = models.IntegerField(choices=AddonEvent.choices)
237 class Meta:
238 unique_together = [ # noqa: RUF012
239 ("addon", "event"),
240 ]
241 verbose_name = "add-on event"
242 verbose_name_plural = "add-on events"
244 def __str__(self) -> str:
245 return f"{self.addon}: {self.get_event_display()}"
248class AddonsConf(AppConf):
249 WEBLATE_ADDONS = (
250 "weblate.addons.gettext.GenerateMoAddon",
251 "weblate.addons.gettext.UpdateLinguasAddon",
252 "weblate.addons.gettext.UpdateConfigureAddon",
253 "weblate.addons.gettext.MsgmergeAddon",
254 "weblate.addons.gettext.GettextAuthorComments",
255 "weblate.addons.cleanup.CleanupAddon",
256 "weblate.addons.cleanup.RemoveBlankAddon",
257 "weblate.addons.consistency.LanguageConsistencyAddon",
258 "weblate.addons.discovery.DiscoveryAddon",
259 "weblate.addons.autotranslate.AutoTranslateAddon",
260 "weblate.addons.flags.SourceEditAddon",
261 "weblate.addons.flags.TargetEditAddon",
262 "weblate.addons.flags.SameEditAddon",
263 "weblate.addons.flags.BulkEditAddon",
264 "weblate.addons.flags.TargetRepoUpdateAddon",
265 "weblate.addons.generate.GenerateFileAddon",
266 "weblate.addons.generate.PseudolocaleAddon",
267 "weblate.addons.generate.PrefillAddon",
268 "weblate.addons.generate.FillReadOnlyAddon",
269 "weblate.addons.properties.PropertiesSortAddon",
270 "weblate.addons.git.GitSquashAddon",
271 "weblate.addons.removal.RemoveComments",
272 "weblate.addons.removal.RemoveSuggestions",
273 "weblate.addons.resx.ResxUpdateAddon",
274 "weblate.addons.cdn.CDNJSAddon",
275 "weblate.addons.webhooks.WebhookAddon",
276 "weblate.addons.webhooks.SlackWebhookAddon",
277 )
279 LOCALIZE_CDN_URL = None
280 LOCALIZE_CDN_PATH = None
282 # How long to keep add-on activity log entries
283 ADDON_ACTIVITY_LOG_EXPIRY = 180
285 class Meta:
286 prefix = ""
289# Events to exclude from logging
290NO_LOG_EVENTS = {
291 AddonEvent.EVENT_UNIT_PRE_CREATE,
292 AddonEvent.EVENT_UNIT_POST_SAVE,
293}
295# Repository scoped events
296REPO_EVENTS = {
297 AddonEvent.EVENT_PRE_UPDATE,
298 AddonEvent.EVENT_POST_UPDATE,
299 AddonEvent.EVENT_PRE_PUSH,
300 AddonEvent.EVENT_POST_PUSH,
301 AddonEvent.EVENT_COMPONENT_UPDATE,
302}
305class AddonCallbackMethod(Protocol):
306 def __call__( 306 ↛ exitline 306 didn't return from function '__call__' because
307 self, addon: Addon, component: Component, *, activity_log_id: int
308 ) -> dict | None: ...
311def execute_addon_event(
312 addon: Addon,
313 component: Component | None,
314 scope: Translation | Component | Project | None,
315 event: AddonEvent,
316 method: str | AddonCallbackMethod,
317 args: tuple | None = None,
318) -> None:
319 from weblate.addons.tasks import update_addon_activity_log
321 # Trigger repository scoped add-ons only on the main component
322 if (
323 addon.repo_scope
324 and component
325 and component.linked_component
326 and event in REPO_EVENTS
327 ):
328 return
330 def addon_logger(
331 level: Literal["debug", "error"], message: str, *args: StrOrPromise
332 ) -> None:
333 if scope is None:
334 if level == "debug":
335 LOGGER.debug(message, *args)
336 else:
337 LOGGER.error(message, *args)
338 elif level == "debug":
339 scope.log_debug(message, *args)
340 else:
341 scope.log_error(message, *args)
343 # Log logging result and error flag for add-on activity log
344 log_result = None
345 error_occurred = False
346 if args is None:
347 args = ()
349 activity_log = AddonActivityLog.objects.create(
350 addon=addon,
351 component=component,
352 event=event,
353 pending=True,
354 )
356 with transaction.atomic():
357 addon_logger("debug", "running %s add-on: %s", event.label, addon.name)
358 # Skip unsupported components silently
359 if (
360 component
361 and not addon.component
362 and not addon.addon.can_install(component, None)
363 ):
364 addon_logger(
365 "debug",
366 "Skipping incompatible %s add-on: %s for component: %s",
367 event.label,
368 addon.name,
369 component.name,
370 )
371 return
373 try:
374 # Execute event in senty span to track performance
375 with sentry_sdk.start_span(op=f"addon.{event.name}", name=addon.name):
376 if isinstance(method, str):
377 log_result = getattr(addon.addon, method)(
378 *args, activity_log_id=activity_log.pk
379 )
380 elif component is None:
381 log_result = "Missing component parameter!"
382 error_occurred = True
383 else:
384 # Callback is used in tasks
385 log_result = method(
386 addon, component, activity_log_id=activity_log.pk
387 )
388 except DjangoDatabaseError:
389 raise
390 except Exception as error:
391 # Log failure
392 error_occurred = True
393 log_result = str(error)
394 addon_logger(
395 "error", "failed %s add-on: %s: %s", event.label, addon.name, str(error)
396 )
397 report_error(
398 f"add-on {addon.name} failed",
399 project=component.project if component else None,
400 )
401 # Uninstall no longer compatible add-ons
402 if component and not addon.addon.can_install(component, None):
403 addon.disable()
404 component.drop_addons_cache()
405 else:
406 addon_logger("debug", "completed %s add-on: %s", event.label, addon.name)
407 finally:
408 # Check if add-on is still installed and log activity
409 if event not in NO_LOG_EVENTS and addon.pk is not None:
410 update_addon_activity_log(
411 activity_log.pk,
412 log_result,
413 pending=False,
414 error_occurred=error_occurred,
415 )
418@overload
419def handle_addon_event( 419 ↛ anywhereline 419 didn't jump anywhere: it always raised an exception.
420 event: AddonEvent,
421 method: str,
422 args: tuple,
423 *,
424 project: Project | None = None,
425 component: Component | None = None,
426 translation: Translation | None = None,
427 addon_queryset: AddonQuerySet | list[Addon] | None = None,
428 auto_scope: bool = False,
429) -> None: ...
432@overload
433def handle_addon_event( 433 ↛ anywhereline 433 didn't jump anywhere: it always raised an exception.
434 event: AddonEvent,
435 method: Callable[[Addon, Component], None],
436 args: None = None,
437 *,
438 project: None = None,
439 component: None = None,
440 translation: None = None,
441 addon_queryset: AddonQuerySet | None,
442 auto_scope: bool,
443) -> None: ...
446@transaction.atomic
447def handle_addon_event(
448 event,
449 method,
450 args=None,
451 *,
452 project=None,
453 component=None,
454 translation=None,
455 addon_queryset=None,
456 auto_scope=False,
457) -> None:
458 # Scope is used for logging
459 scope: Translation | Component | Project | None = (
460 translation or component or project
461 )
463 # Shortcuts for frequently used variables
464 if component is None and translation is not None:
465 component = translation.component
467 # EVENT_DAILY and EVENT_CHANGE use custom queryset because it is not
468 # triggered from the object scope
469 if addon_queryset is None:
470 addon_queryset = Addon.objects.filter_event(component, event)
472 for addon in addon_queryset: 472 ↛ 473line 472 didn't jump to line 473 because the loop on line 472 never started
473 if not auto_scope:
474 execute_addon_event(addon, component, scope, event, method, args)
475 else:
476 components: Iterable[Component]
477 if addon.component:
478 components = [addon.component]
479 elif addon.project:
480 components = addon.project.component_set.iterator()
481 else:
482 components = Component.objects.iterator()
484 for scope_component in components:
485 execute_addon_event(
486 addon, scope_component, scope_component, event, method, args
487 )
490@receiver(vcs_pre_push)
491def pre_push(sender, component: Component, **kwargs) -> None:
492 handle_addon_event(
493 AddonEvent.EVENT_PRE_PUSH,
494 "pre_push",
495 (component,),
496 component=component,
497 )
500@receiver(vcs_post_push)
501def post_push(sender, component: Component, **kwargs) -> None:
502 handle_addon_event(
503 AddonEvent.EVENT_POST_PUSH,
504 "post_push",
505 (component,),
506 component=component,
507 )
510@receiver(vcs_post_update)
511def post_update(
512 sender,
513 component: Component,
514 previous_head: str,
515 skip_push: bool = False,
516 **kwargs,
517) -> None:
518 handle_addon_event(
519 AddonEvent.EVENT_POST_UPDATE,
520 "post_update",
521 (component, previous_head, skip_push),
522 component=component,
523 )
526@receiver(component_post_update)
527def component_update(sender, component: Component, **kwargs) -> None:
528 handle_addon_event(
529 AddonEvent.EVENT_COMPONENT_UPDATE,
530 "component_update",
531 (component,),
532 component=component,
533 )
536@receiver(vcs_pre_update)
537def pre_update(sender, component: Component, **kwargs) -> None:
538 handle_addon_event(
539 AddonEvent.EVENT_PRE_UPDATE,
540 "pre_update",
541 (component,),
542 component=component,
543 )
546@receiver(vcs_pre_commit)
547def pre_commit(
548 sender, translation: Translation, author: str, store_hash: bool, **kwargs
549) -> None:
550 handle_addon_event(
551 AddonEvent.EVENT_PRE_COMMIT,
552 "pre_commit",
553 (translation, author, store_hash),
554 translation=translation,
555 )
558@receiver(vcs_post_commit)
559def post_commit(sender, component: Component, store_hash: bool, **kwargs) -> None:
560 handle_addon_event(
561 AddonEvent.EVENT_POST_COMMIT,
562 "post_commit",
563 (component, store_hash),
564 component=component,
565 )
568@receiver(translation_post_add)
569def post_add(sender, translation: Translation, **kwargs) -> None:
570 handle_addon_event(
571 AddonEvent.EVENT_POST_ADD,
572 "post_add",
573 (translation,),
574 translation=translation,
575 )
578@receiver(unit_pre_create)
579def unit_pre_create_handler(sender, unit: Unit, **kwargs) -> None:
580 handle_addon_event(
581 AddonEvent.EVENT_UNIT_PRE_CREATE,
582 "unit_pre_create",
583 (unit,),
584 translation=unit.translation,
585 )
588@receiver(post_save, sender=Unit)
589@disable_for_loaddata
590def unit_post_save_handler(sender, instance: Unit, created, **kwargs) -> None:
591 handle_addon_event(
592 AddonEvent.EVENT_UNIT_POST_SAVE,
593 "unit_post_save",
594 (instance, created),
595 translation=instance.translation,
596 )
599@receiver(post_save, sender=Change)
600@disable_for_loaddata
601def change_post_save_handler(sender, instance: Change, created, **kwargs) -> None:
602 """Handle Change post save signal."""
603 if created: # ignore Change updates, they should not be updated anyway 603 ↛ exitline 603 didn't return from function 'change_post_save_handler' because the condition on line 603 was always true
604 bulk_change_create_handler(sender, [instance])
607@receiver(change_bulk_create)
608@disable_for_loaddata
609def bulk_change_create_handler(sender, instances: list[Change], **kwargs) -> None:
610 """Handle Change bulk create signal."""
611 from weblate.addons.tasks import addon_change
613 # Filter out events that have a subscriber
614 # It currently also includes all project and site-wide events as there is currently
615 # no effective way to filter and these are not that frequent.
616 filtered = [
617 change.pk
618 for change in instances
619 if change.component is None
620 or AddonEvent.EVENT_CHANGE in change.component.addons_cache
621 ]
623 if filtered:
624 addon_change.delay_on_commit(filtered)
627@receiver(unit_post_sync)
628def unit_post_sync_handler(sender, unit: Unit, updated_attr: str, **kwargs) -> None:
629 handle_addon_event(
630 AddonEvent.EVENT_UNIT_POST_SYNC,
631 "unit_post_sync",
632 (unit, updated_attr),
633 translation=unit.translation,
634 )
637class AddonActivityLog(models.Model):
638 addon = models.ForeignKey(Addon, on_delete=models.deletion.CASCADE)
639 component = models.ForeignKey(
640 Component, on_delete=models.deletion.CASCADE, null=True
641 )
642 event = models.IntegerField(choices=AddonEvent.choices)
643 created = models.DateTimeField(auto_now_add=True)
644 details = models.JSONField(default=dict)
645 pending = models.BooleanField(default=False)
647 class Meta:
648 verbose_name = "add-on activity log"
649 verbose_name_plural = "add-on activity logs"
650 ordering = [ # noqa: RUF012
651 "-created"
652 ]
654 def __str__(self) -> str:
655 return f"{self.addon}: {self.get_event_display()} at {self.created}"
657 def get_details_display(self) -> str:
658 return self.addon.addon.render_activity_log(self)
660 def update_result(self, result: str) -> None:
661 """Update the result field in the details JSON."""
662 details = self.details or {}
663 if current_result := details.get("result"):
664 result = f"{current_result}\n{result}"
666 details["result"] = result
667 self.details = details