infrasynth-backend-kit/infrasynth/scheduler/receivers.py
jcv-dev a2930426b4 feat: tenant configs, live signals, and automatic permission management
- infrasynth.configs: typed multi-tenant config store (registry, service,
  secrets, cache) + public config_changed/config_reset signals and API
- emit the declared-but-dead signals (features flags/overrides, scheduler
  task_completed/task_failed, tenancy tenant_updated, audit model_changed)
  and per-model audit field exclusions
- security: permission catalog (security_permission), Django-style
  model-derived AutoPermission, PermissionRegistry, RoleAssignment,
  global-or-tenant Grant/Revoke, catalog API
- consolidate the permission surface: PermissionRegistry only (drop the
  settings dict), IsAuthenticatedAndPermitted aliases HybridPermission,
  require_permission replaced by required_permissions + require_all
- packaging: add [build-system]; add Forgejo publish workflow (.forgejo)
2026-09-29 17:06:54 -05:00

65 lines
2.3 KiB
Python

"""Receivers emitting terminal ``TaskExecution`` signals.
``task_completed``/``task_failed`` fire exactly once, on the transition into a
terminal status, so the sync/plain-callable path and any task that updates its
own ``TaskExecution`` row are both covered without duplicate emissions.
Note: for the ``apply_async`` branch the execution row currently stays
``RUNNING`` (no Celery callback), so completion is only observable when the task
updates its own ``TaskExecution`` or runs synchronously. Tracking async
completion is a separate enhancement (``PLAN.md`` §2.8).
"""
from __future__ import annotations
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from .models import TaskExecution
from .signals import task_completed, task_failed
@receiver(pre_save, sender=TaskExecution)
def _capture_previous_status(sender, instance, raw=False, **kwargs):
if raw or instance.pk is None:
return
previous = TaskExecution.all_objects.filter(pk=instance.pk).values_list("status", flat=True).first()
if previous is not None:
instance._previous_status = previous
@receiver(post_save, sender=TaskExecution)
def _emit_terminal_status(sender, instance, created, raw=False, **kwargs):
if raw or created:
return
previous = getattr(instance, "_previous_status", None)
if previous is None or previous == instance.status:
return
tenant_id = str(instance.tenant_id) if instance.tenant_id else None
task_name = getattr(instance.task, "name", "")
if instance.status == TaskExecution.Status.SUCCESS:
task_completed.send(
sender=TaskExecution,
tenant_id=tenant_id,
task_name=task_name,
task_id=instance.pk,
duration_ms=_duration_ms(instance),
result=instance.result,
)
elif instance.status == TaskExecution.Status.FAILURE:
task_failed.send(
sender=TaskExecution,
tenant_id=tenant_id,
task_name=task_name,
task_id=instance.pk,
error=instance.error_traceback,
traceback="",
)
def _duration_ms(execution: TaskExecution) -> int | None:
if execution.started_at is None or execution.completed_at is None:
return None
return int((execution.completed_at - execution.started_at).total_seconds() * 1000)