Tre strumenti, un solo problema

Quando in un'applicazione Django qualcosa deve succedere automaticamente — un'email da mandare, un record da aggiornare, un file da generare — la domanda è quasi sempre la stessa: lo faccio con un signal, un management command, o un task Celery?

La risposta sbagliata è scegliere uno dei tre e usarlo per tutto. La risposta giusta è capire che risolvono problemi diversi e si combinano naturalmente — spesso nello stesso flusso.

Automazione in Django: signals, management commands e Celery

Signals: reagire agli eventi del modello

I signals di Django sono il meccanismo di comunicazione tra componenti. Permettono a una parte del codice di notificare altre parti che qualcosa è successo, senza che ci sia un accoppiamento diretto tra i due.

Il caso più comune è post_save — viene emesso ogni volta che un oggetto viene salvato nel database:

from django.db.models.signals import post_save
from django.dispatch import receiver
from myapp.models import Progetto

@receiver(post_save, sender=Progetto)
def progetto_creato(sender, instance, created, **kwargs):
    if created:
        print(f"Nuovo progetto creato: {instance.titolo}")

Il decorator @receiver collega la funzione al signal. Ogni volta che un Progetto viene salvato per la prima volta (created=True), la funzione viene eseguita.

I signals disponibili più utili sono:

  • pre_save / post_save — prima e dopo il salvataggio
  • pre_delete / post_delete — prima e dopo l'eliminazione
  • m2m_changed — quando cambia una relazione ManyToMany
  • request_started / request_finished — inizio e fine di ogni request HTTP

Quando usare i signals

I signals hanno senso quando vuoi reagire a un evento del modello in modo trasversale — cioè senza modificare il codice del modello stesso o della view. Sono utili per:

  • Aggiornare dati derivati quando un modello cambia
  • Invalidare cache quando un oggetto viene modificato
  • Logging e auditing automatico
  • Notifiche leggere che non richiedono operazioni pesanti

Quando NON usare i signals

I signals hanno un costo nascosto: rendono il flusso del codice difficile da seguire. Se guardi una view e vedi progetto.save(), non sai quanti signal vengono scatenati e cosa fanno. Per operazioni critiche o complesse, è spesso più chiaro chiamare esplicitamente una funzione nel punto giusto del codice.

Un altro limite fondamentale: i signals vengono eseguiti sincronicamente, nel thread della request. Se il tuo signal fa qualcosa di lento — chiamate HTTP, generazione PDF, invio email — stai bloccando la response. Qui entrano in gioco i task Celery.

Management commands: operazioni batch e schedulabili

I management commands sono script Python che girano fuori dal ciclo request/response di Django, invocabili via python manage.py nome_comando. Django ne include molti (migrate, collectstatic, createsuperuser) e puoi crearne di custom.

La struttura base è semplice:

from django.core.management.base import BaseCommand
from myapp.models import Progetto
from django.utils import timezone

class Command(BaseCommand):
    help = "Chiude i progetti scaduti"

    def add_arguments(self, parser):
        parser.add_argument("--dry-run", action="store_true")

    def handle(self, *args, **options):
        scaduti = Progetto.objects.filter(
            data_scadenza__lt=timezone.now(),
            stato="attivo"
        )
        count = scaduti.count()

        if options["dry_run"]:
            self.stdout.write(f"Trovati {count} progetti da chiudere (dry run)")
            return

        scaduti.update(stato="scaduto")
        self.stdout.write(
            self.style.SUCCESS(f"Chiusi {count} progetti scaduti")
        )

Il file va in myapp/management/commands/chiudi_progetti_scaduti.py. La struttura delle directory è rigida — Django la cerca lì.

Quando usare i management commands

I management commands sono lo strumento giusto per:

  • Operazioni batch che agiscono su molti record — migrazione dati, pulizia, aggiornamenti massivi
  • Script di manutenzione che vuoi eseguire manualmente o schedulare con cron
  • Import/export dati
  • Seed del database in sviluppo
  • Operazioni one-shot dopo un deploy

Il vantaggio rispetto a uno script Python standalone è l'accesso diretto a tutto l'ambiente Django — ORM, settings, logging — senza configurazione aggiuntiva.

Il limite è che un management command non è distribuibile e non scala orizzontalmente — gira su una sola macchina, in modo sincrono. Per operazioni che richiedono parallelismo o distribuzione, servono i task Celery.

Celery: task asincroni e distribuiti

Celery è un sistema di code di task. Invece di eseguire un'operazione immediatamente nel thread corrente, la metti in coda e un worker separato la esegue quando può. Il vantaggio principale è che la response HTTP non aspetta — l'utente ottiene la risposta subito, il lavoro pesante avviene in background.

La configurazione base con Redis come broker:

CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND = "redis://localhost:6379/0"

Un task semplice:

from celery import shared_task
from myapp.models import Progetto

@shared_task
def genera_report_progetto(progetto_id: int):
    progetto = Progetto.objects.get(id=progetto_id)
    pdf = genera_pdf(progetto)
    progetto.report = pdf
    progetto.save()
    return f"Report generato per {progetto.titolo}"

Per eseguirlo in background dalla view:

genera_report_progetto.delay(progetto.id)

.delay() mette il task in coda e ritorna immediatamente. Il worker Celery lo eseguirà appena disponibile.

Quando usare Celery

Celery è lo strumento giusto per:

  • Operazioni lente che non devono bloccare la response — invio email, generazione PDF, chiamate API esterne
  • Operazioni che possono fallire e richiedono retry automatici
  • Task che devono girare su più worker in parallelo
  • Operazioni schedulate con Celery Beat (cronjob in Python)

Il costo di Celery è la complessità infrastrutturale — richiede un broker (Redis o RabbitMQ), worker separati, e un sistema di monitoraggio. Non è lo strumento giusto per operazioni semplici che si risolvono in pochi millisecondi.

Come si combinano: il flusso completo

La potenza vera emerge quando i tre strumenti lavorano insieme. Un esempio concreto da Skara: quando una commissione di selezione viene finalizzata, bisogna generare i verbali PDF, inviare le notifiche ai candidati, e aggiornare le statistiche del progetto.

Il signal reagisce all'evento:

from django.db.models.signals import post_save
from django.dispatch import receiver
from myapp.models import Commissione
from myapp.tasks import processa_commissione_finalizzata

@receiver(post_save, sender=Commissione)
def commissione_finalizzata(sender, instance, **kwargs):
    if instance.stato == "finalizzata":
        processa_commissione_finalizzata.delay(instance.id)

Il task Celery fa il lavoro pesante in background:

from celery import shared_task
from myapp.models import Commissione

@shared_task(bind=True, max_retries=3)
def processa_commissione_finalizzata(self, commissione_id: int):
    try:
        commissione = Commissione.objects.select_related(
            "progetto"
        ).prefetch_related("candidati").get(id=commissione_id)

        genera_verbali_pdf(commissione)
        invia_notifiche_candidati(commissione)
        aggiorna_statistiche_progetto(commissione.progetto)

    except Exception as exc:
        raise self.retry(exc=exc, countdown=60)

Il management command per la manutenzione:

class Command(BaseCommand):
    help = "Processa le commissioni finalizzate non elaborate"

    def handle(self, *args, **options):
        commissioni = Commissione.objects.filter(
            stato="finalizzata",
            elaborata=False
        )
        for c in commissioni:
            processa_commissione_finalizzata.delay(c.id)
            self.stdout.write(f"Schedulata elaborazione commissione {c.id}")

Il management command è il safety net — se per qualche motivo un task è andato perso o un worker era giù, questo comando recupera i record non elaborati e li rimette in coda. Si può schedulare con cron ogni ora come failsafe.

La regola pratica

Scegli il tool in base a quando e dove deve succedere l'automazione:

  • Signal — reagisci a un evento del modello, sincrono, leggero
  • Management command — operazione batch, schedulabile, manuale o via cron
  • Celery task — operazione lenta, asincrona, con retry, distribuibile

E ricorda: i tre si combinano. Il pattern signal → Celery task è uno dei più usati in Django proprio perché sfrutta i punti di forza di entrambi — il signal intercetta l'evento senza modificare il modello, Celery gestisce il lavoro pesante senza bloccare la request.