349 lines
13 KiB
Python
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", '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"
|
|
))
|