loko/streetup/interventions/management/commands/send_daily_intervention_summary.py

349 lines
13 KiB
Python

"""
Commande Django pour envoyer le résumé journalier des interventions par contrat.
Pour les contrats configurés avec 'send_daily_summary = True', cette commande
regroupe toutes les fiches d'intervention du jour dans un seul email avec tous
les PDFs en pièces jointes.
Usage:
python manage.py send_daily_intervention_summary
python manage.py send_daily_intervention_summary --date 2026-04-13
python manage.py send_daily_intervention_summary --contract 42
"""
import io
import logging
from datetime import date, timedelta
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.core.management.base import BaseCommand
from django.utils import timezone
from django.utils.translation import gettext as _
from contracts.models import Contract
from interventions.models import Intervention, InterventionEmailLog
logger = logging.getLogger(__name__)
def _is_smtp_available(timeout=5):
"""Vérifie la disponibilité du serveur SMTP."""
import socket
import ssl
email_host = getattr(settings, 'EMAIL_HOST', None)
email_port = getattr(settings, 'EMAIL_PORT', 25)
use_ssl = bool(getattr(settings, 'EMAIL_USE_SSL', False))
if not email_host:
return False
try:
email_port = int(email_port) if email_port else 25
except (ValueError, TypeError):
email_port = 25
if email_port == 465:
use_ssl = True
try:
if use_ssl:
ctx = ssl.create_default_context()
with ctx.wrap_socket(socket.create_connection((email_host, email_port), timeout=timeout), server_hostname=email_host):
pass
else:
with socket.create_connection((email_host, email_port), timeout=timeout):
pass
return True
except Exception:
return False
def _get_recipients(contract):
"""Retourne la liste des destinataires pour un contrat."""
email_override = getattr(settings, 'EMAIL_OVERRIDE', None)
if email_override:
return [email_override]
if not contract or not getattr(contract, 'send_email_notifications', True):
return []
import re
emails = set()
if contract.notification_emails:
for e in re.split(r'[;,]', contract.notification_emails):
e = e.strip()
if e:
emails.add(e)
return [e for e in emails if e]
def _get_cc_recipients(contract):
"""Retourne la liste des destinataires CC pour un contrat."""
email_override = getattr(settings, 'EMAIL_OVERRIDE', None)
if email_override:
return []
if not contract or not getattr(contract, 'send_email_notifications', True):
return []
import re
emails = set()
if getattr(contract, 'cc_notification_emails', None):
for e in re.split(r'[;,]', contract.cc_notification_emails):
e = e.strip()
if e:
emails.add(e)
emails.add(getattr(settings, 'EMAIL_TECH_SUPPORT', 'bm.perm.tech@sprb.brussels'))
return [e for e in emails if e]
def _read_intervention_pdf(intervention):
"""Lit et retourne les octets du PDF de la fiche d'intervention."""
if not intervention.intervention_sheet:
return None, None
try:
fname = intervention.intervention_sheet.name.split('/')[-1]
intervention.intervention_sheet.open('rb')
data = intervention.intervention_sheet.read()
intervention.intervention_sheet.close()
return fname, data
except Exception as e:
logger.warning("Impossible de lire le PDF de l'intervention %s: %s", intervention.code, e)
return None, None
class Command(BaseCommand):
help = "Envoie le résumé journalier des interventions pour les contrats configurés en envoi groupé."
def add_arguments(self, parser):
parser.add_argument(
'--date',
type=str,
default=None,
help="Date des interventions à inclure (format YYYY-MM-DD). Par défaut: aujourd'hui.",
)
parser.add_argument(
'--contract',
type=int,
default=None,
help="ID du contrat à traiter (optionnel, traite tous les contrats par défaut).",
)
parser.add_argument(
'--dry-run',
action='store_true',
default=False,
help="Simulation : affiche ce qui serait envoyé sans envoyer les emails.",
)
def handle(self, *args, **options):
# Déterminer la date cible
if options['date']:
try:
target_date = date.fromisoformat(options['date'])
except ValueError:
self.stderr.write(self.style.ERROR(f"Format de date invalide: {options['date']}. Utilisez YYYY-MM-DD."))
return
else:
target_date = timezone.localdate()
dry_run = options['dry_run']
contract_id = options.get('contract')
self.stdout.write(self.style.WARNING(
f"\n=== Résumé journalier des interventions — {target_date} ===\n"
))
if dry_run:
self.stdout.write(self.style.WARNING(" [MODE SIMULATION — aucun email ne sera envoyé]\n"))
# Récupérer les contrats avec envoi groupé activé
contracts_qs = Contract.objects.filter(send_daily_summary=True, is_active=True)
if contract_id:
contracts_qs = contracts_qs.filter(pk=contract_id)
contracts = list(contracts_qs)
if not contracts:
self.stdout.write("Aucun contrat avec envoi groupé journalier actif trouvé.")
return
# Plage de recherche des interventions
tz = timezone.get_current_timezone()
if options['date']:
# Avec --date : plage de la journée complète (minuit à minuit)
day_start = timezone.make_aware(
timezone.datetime.combine(target_date, timezone.datetime.min.time()), tz
)
day_end = day_start + timedelta(days=1)
else:
# Sans --date : fenêtre glissante des dernières 24 heures
day_end = timezone.now()
day_start = day_end - timedelta(hours=24)
total_sent = 0
total_skipped = 0
for contract in contracts:
self.stdout.write(f"\nContrat {contract.contract_number} — {contract.company.name}")
# Interventions du jour pour ce contrat avec une fiche PDF générée
interventions_qs = Intervention.objects.filter(
contract=contract,
intervention_sheet__isnull=False,
transmission_time__gte=day_start,
transmission_time__lt=day_end,
).exclude(
# Exclure celles déjà envoyées (individuellement ou en résumé)
email_logs__email_type__in=['transmission', 'daily_summary'],
email_logs__success=True,
).order_by('transmission_time')
# Si le contrat est configuré pour n'envoyer que les urgences, filtrer
if getattr(contract, 'email_only_if_urgent', False):
interventions_qs = interventions_qs.filter(priority='1')
interventions = list(interventions_qs)
if not interventions:
self.stdout.write(" → Aucune nouvelle intervention à envoyer.")
total_skipped += 1
continue
self.stdout.write(f" → {len(interventions)} intervention(s) à inclure:")
for intv in interventions:
self.stdout.write(f" • {intv.code} — {intv.title}")
recipients = _get_recipients(contract)
cc_recipients = _get_cc_recipients(contract)
if not recipients:
self.stdout.write(self.style.WARNING(" → Aucun destinataire configuré pour ce contrat. Email ignoré."))
total_skipped += 1
continue
self.stdout.write(f" → Destinataires: {', '.join(recipients)}")
if dry_run:
self.stdout.write(self.style.SUCCESS(" → [SIMULATION] Email groupé simulé."))
continue
# Construire l'email groupé
subject = (
f"Résumé journalier des interventions / Dagelijks overzicht interventies "
f"— {target_date.strftime('%d/%m/%Y')} — {contract.contract_number}"
)
# Corps du message
body_lines = [
f"Résumé journalier des interventions du {target_date.strftime('%d/%m/%Y')}.",
f"Dagelijks overzicht van de interventies van {target_date.strftime('%d/%m/%Y')}.",
"",
f"Contrat / Contract: {contract.contract_number} — {contract.company.name}",
f"Nombre de fiches / Aantal fiches: {len(interventions)}",
"",
]
for intv in interventions:
body_lines.append(f" • {intv.code} — {intv.title}")
body_lines += [
"",
"Les fiches d'intervention sont jointes à cet email.",
"De interventiefiches zijn bijgevoegd bij deze e-mail.",
"",
"Permanence Technique / Technische Permanentie",
getattr(settings, 'EMAIL_TECH_SUPPORT', 'Bm.perm.tech@sprb.brussels'),
getattr(settings, 'EMAIL_TECH_SUPPORT_PHONE', '02/204.22.86'),
]
body = "\n".join(body_lines)
from_email = getattr(settings, "DEFAULT_FROM_EMAIL", 'StreetUp no-reply <no-reply@streetup.be>')
email = EmailMultiAlternatives(
subject=subject,
body=body,
from_email=from_email,
to=recipients,
cc=cc_recipients,
bcc=[getattr(settings, 'EMAIL_INTERNAL_FOLLOWUP', 'kdeterme@sprb.brussels')],
headers={
"Auto-Submitted": "auto-generated",
"X-Category": "daily-intervention-summary",
},
)
# Attacher tous les PDFs (respect d'une limite de taille totale)
MAX_TOTAL_BYTES = 25 * 1024 * 1024 # 25 Mo
total_bytes = 0
attached = []
skipped_large = []
for intv in interventions:
fname, pdf_data = _read_intervention_pdf(intv)
if fname is None or pdf_data is None:
self.stdout.write(self.style.WARNING(
f" → PDF manquant pour {intv.code}, ignoré."
))
continue
if total_bytes + len(pdf_data) > MAX_TOTAL_BYTES:
skipped_large.append(intv.code)
continue
email.attach(fname, pdf_data, "application/pdf")
total_bytes += len(pdf_data)
attached.append(intv)
if skipped_large:
self.stdout.write(self.style.WARNING(
f" → Taille totale dépassée. PDFs ignorés: {', '.join(skipped_large)}"
))
# Vérifier SMTP et envoyer
if not _is_smtp_available():
self.stderr.write(self.style.ERROR(
f" → SMTP non disponible. Email non envoyé pour le contrat {contract.contract_number}."
))
for intv in interventions:
InterventionEmailLog.objects.create(
intervention=intv,
email_type='daily_summary',
sent_by=None,
recipients=', '.join(recipients),
subject=subject,
success=False,
error_message="Serveur SMTP non disponible",
)
total_skipped += 1
continue
try:
email.send(fail_silently=False)
self.stdout.write(self.style.SUCCESS(
f" → Email envoyé à {', '.join(recipients)} ({len(attached)} PDF(s) joints)."
))
for intv in attached:
InterventionEmailLog.objects.create(
intervention=intv,
email_type='daily_summary',
sent_by=None,
recipients=', '.join(recipients),
subject=subject,
success=True,
)
total_sent += 1
except Exception as exc:
self.stderr.write(self.style.ERROR(
f" → Erreur lors de l'envoi pour le contrat {contract.contract_number}: {exc}"
))
for intv in interventions:
InterventionEmailLog.objects.create(
intervention=intv,
email_type='daily_summary',
sent_by=None,
recipients=', '.join(recipients),
subject=subject,
success=False,
error_message=str(exc),
)
total_skipped += 1
self.stdout.write(self.style.SUCCESS(
f"\n=== Terminé — {total_sent} email(s) envoyé(s), {total_skipped} contrat(s) ignoré(s). ===\n"
))