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