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

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 typing import TYPE_CHECKING, Literal, Protocol, overload 

9 

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 

19 

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 

39 

40from .base import BaseAddon 

41from .events import AddonEvent 

42 

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 

45 

46 from django.db.models import QuerySet 

47 from django_stubs_ext import StrOrPromise 

48 

49 from weblate.auth.models import User 

50 from weblate.trans.models import Translation 

51 

52# Initialize addons registry 

53ADDONS = ClassLoader("WEBLATE_ADDONS", construct=False, base_class=BaseAddon) 

54 

55 

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") 

67 

68 def filter_component(self, component): 

69 return self.prefetch_related("event_set").filter(component=component) 

70 

71 def filter_project(self, project): 

72 return self.prefetch_related("event_set").filter(project=project) 

73 

74 def filter_sitewide(self): 

75 return self.prefetch_related("event_set").filter( 

76 component__isnull=True, project__isnull=True 

77 ) 

78 

79 def filter_event(self, component, event): 

80 return component.addons_cache[event] 

81 

82 

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) 

92 

93 objects = AddonQuerySet.as_manager() 

94 

95 class Meta: 

96 verbose_name = "add-on" 

97 verbose_name_plural = "add-ons" 

98 

99 def __str__(self) -> str: 

100 return f"{self.addon.verbose}: {self.project or self.component or 'site-wide'}" 

101 

102 def __init__(self, *args, acting_user: User | None = None, **kwargs) -> None: 

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

104 self.acting_user = acting_user 

105 

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 

111 

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 

118 

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 

123 

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 ) 

131 

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() 

137 

138 return super().save( 

139 force_insert=force_insert, 

140 force_update=force_update, 

141 using=using, 

142 update_fields=update_fields, 

143 ) 

144 

145 def get_absolute_url(self) -> str: 

146 return reverse("addon-detail", kwargs={"pk": self.pk}) 

147 

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 ) 

157 

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() 

162 

163 @cached_property 

164 def addon_class(self) -> type[BaseAddon]: 

165 return ADDONS[self.name] 

166 

167 @cached_property 

168 def addon(self) -> BaseAddon: 

169 return self.addon_class(self) 

170 

171 @property 

172 def is_valid(self) -> bool: 

173 return self.name in ADDONS 

174 

175 @property 

176 def addon_name(self) -> str: 

177 if not self.is_valid: 

178 return self.name 

179 return self.addon.name 

180 

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() 

195 

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 

203 

204 def disable(self) -> None: 

205 self.log_warning("disabling no longer compatible add-on: %s", self.name) 

206 self.delete() 

207 

208 @cached_property 

209 def logger(self) -> logging.Logger: 

210 return logging.getLogger("weblate.addons") 

211 

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) 

219 

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) 

227 

228 def get_addon_activity_logs(self) -> QuerySet[AddonActivityLog]: 

229 """Return activity logs for add-on.""" 

230 return self.addonactivitylog_set.order_by("-created") 

231 

232 

233class Event(models.Model): 

234 addon = models.ForeignKey(Addon, on_delete=models.deletion.CASCADE, db_index=False) 

235 event = models.IntegerField(choices=AddonEvent.choices) 

236 

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" 

243 

244 def __str__(self) -> str: 

245 return f"{self.addon}: {self.get_event_display()}" 

246 

247 

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 ) 

278 

279 LOCALIZE_CDN_URL = None 

280 LOCALIZE_CDN_PATH = None 

281 

282 # How long to keep add-on activity log entries 

283 ADDON_ACTIVITY_LOG_EXPIRY = 180 

284 

285 class Meta: 

286 prefix = "" 

287 

288 

289# Events to exclude from logging 

290NO_LOG_EVENTS = { 

291 AddonEvent.EVENT_UNIT_PRE_CREATE, 

292 AddonEvent.EVENT_UNIT_POST_SAVE, 

293} 

294 

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} 

303 

304 

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: ... 

309 

310 

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 

320 

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 

329 

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) 

342 

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 = () 

348 

349 activity_log = AddonActivityLog.objects.create( 

350 addon=addon, 

351 component=component, 

352 event=event, 

353 pending=True, 

354 ) 

355 

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 

372 

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 ) 

416 

417 

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: ... 

430 

431 

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: ... 

444 

445 

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 ) 

462 

463 # Shortcuts for frequently used variables 

464 if component is None and translation is not None: 

465 component = translation.component 

466 

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) 

471 

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() 

483 

484 for scope_component in components: 

485 execute_addon_event( 

486 addon, scope_component, scope_component, event, method, args 

487 ) 

488 

489 

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 ) 

498 

499 

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 ) 

508 

509 

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 ) 

524 

525 

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 ) 

534 

535 

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 ) 

544 

545 

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 ) 

556 

557 

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 ) 

566 

567 

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 ) 

576 

577 

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 ) 

586 

587 

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 ) 

597 

598 

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]) 

605 

606 

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 

612 

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 ] 

622 

623 if filtered: 

624 addon_change.delay_on_commit(filtered) 

625 

626 

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 ) 

635 

636 

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) 

646 

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 ] 

653 

654 def __str__(self) -> str: 

655 return f"{self.addon}: {self.get_event_display()} at {self.created}" 

656 

657 def get_details_display(self) -> str: 

658 return self.addon.addon.render_activity_log(self) 

659 

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}" 

665 

666 details["result"] = result 

667 self.details = details