456 lines
14 KiB
Python
456 lines
14 KiB
Python
import threading
|
|
import queue
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
import smtplib
|
|
import ssl
|
|
import socket
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.mime.application import MIMEApplication
|
|
from config.config import settings
|
|
import logging
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.pdfgen import canvas
|
|
from io import BytesIO
|
|
|
|
from reportlab.lib.units import mm
|
|
from reportlab.lib.colors import HexColor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmailQueue:
|
|
def __init__(self):
|
|
self.queue = queue.Queue()
|
|
self.workers = []
|
|
self.running = False
|
|
self.session_factory = None
|
|
self.sage_client = None
|
|
|
|
def start(self, num_workers: int = 3):
|
|
if self.running:
|
|
return
|
|
|
|
self.running = True
|
|
for i in range(num_workers):
|
|
worker = threading.Thread(
|
|
target=self._worker, name=f"EmailWorker-{i}", daemon=True
|
|
)
|
|
worker.start()
|
|
self.workers.append(worker)
|
|
|
|
logger.info(f"Queue email démarrée avec {num_workers} worker(s)")
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
try:
|
|
self.queue.join()
|
|
except Exception:
|
|
pass
|
|
|
|
def enqueue(self, email_log_id: str):
|
|
self.queue.put(email_log_id)
|
|
|
|
def _worker(self):
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
|
|
try:
|
|
while self.running:
|
|
try:
|
|
email_log_id = self.queue.get(timeout=1)
|
|
loop.run_until_complete(self._process_email(email_log_id))
|
|
self.queue.task_done()
|
|
except queue.Empty:
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f"Erreur worker: {e}")
|
|
try:
|
|
self.queue.task_done()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
loop.close()
|
|
|
|
async def _process_email(self, email_log_id: str):
|
|
from database import EmailLog, StatutEmail
|
|
from sqlalchemy import select
|
|
|
|
if not self.session_factory:
|
|
logger.error("session_factory non configuré")
|
|
return
|
|
|
|
async with self.session_factory() as session:
|
|
result = await session.execute(
|
|
select(EmailLog).where(EmailLog.id == email_log_id)
|
|
)
|
|
email_log = result.scalar_one_or_none()
|
|
|
|
if not email_log:
|
|
logger.error(f"Email log {email_log_id} introuvable")
|
|
return
|
|
|
|
email_log.statut = StatutEmail.EN_COURS
|
|
email_log.nb_tentatives += 1
|
|
await session.commit()
|
|
|
|
try:
|
|
await self._send_with_retry(email_log)
|
|
email_log.statut = StatutEmail.ENVOYE
|
|
email_log.date_envoi = datetime.now()
|
|
email_log.derniere_erreur = None
|
|
|
|
except Exception as e:
|
|
error_msg = str(e)
|
|
email_log.statut = StatutEmail.ERREUR
|
|
email_log.derniere_erreur = error_msg[:1000]
|
|
|
|
if email_log.nb_tentatives < settings.max_retry_attempts:
|
|
delay = settings.retry_delay_seconds * (
|
|
2 ** (email_log.nb_tentatives - 1)
|
|
)
|
|
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
|
|
|
|
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
|
|
timer.daemon = True
|
|
timer.start()
|
|
|
|
await session.commit()
|
|
|
|
async def _send_with_retry(self, email_log):
|
|
msg = MIMEMultipart()
|
|
msg["From"] = settings.smtp_from
|
|
msg["To"] = email_log.destinataire
|
|
msg["Subject"] = email_log.sujet
|
|
msg.attach(MIMEText(email_log.corps_html, "html"))
|
|
|
|
# Attachement des PDFs
|
|
if email_log.document_ids:
|
|
document_ids = email_log.document_ids.split(",")
|
|
type_doc = email_log.type_document
|
|
|
|
for doc_id in document_ids:
|
|
doc_id = doc_id.strip()
|
|
if not doc_id:
|
|
continue
|
|
|
|
try:
|
|
pdf_bytes = await asyncio.to_thread(
|
|
self._generate_pdf, doc_id, type_doc
|
|
)
|
|
|
|
if pdf_bytes:
|
|
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
|
part["Content-Disposition"] = (
|
|
f'attachment; filename="{doc_id}.pdf"'
|
|
)
|
|
msg.attach(part)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur génération PDF {doc_id}: {e}")
|
|
|
|
# Envoi SMTP
|
|
await asyncio.to_thread(self._send_smtp, msg)
|
|
|
|
def _send_smtp(self, msg):
|
|
server = None
|
|
|
|
try:
|
|
# Résolution DNS
|
|
socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
|
|
|
# Connexion
|
|
server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30)
|
|
|
|
# EHLO
|
|
server.ehlo()
|
|
|
|
# STARTTLS
|
|
if settings.smtp_use_tls:
|
|
if server.has_extn("STARTTLS"):
|
|
context = ssl.create_default_context()
|
|
server.starttls(context=context)
|
|
server.ehlo()
|
|
|
|
# Authentification
|
|
if settings.smtp_user and settings.smtp_password:
|
|
server.login(settings.smtp_user, settings.smtp_password)
|
|
|
|
# Envoi
|
|
refused = server.send_message(msg)
|
|
if refused:
|
|
raise Exception(f"Destinataires refusés: {refused}")
|
|
|
|
# Fermeture
|
|
server.quit()
|
|
|
|
except Exception as e:
|
|
if server:
|
|
try:
|
|
server.quit()
|
|
except Exception:
|
|
pass
|
|
raise Exception(f"Erreur SMTP: {str(e)}")
|
|
|
|
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
|
if not self.sage_client:
|
|
raise Exception("sage_client non disponible")
|
|
|
|
try:
|
|
doc = self.sage_client.lire_document(doc_id, type_doc)
|
|
except Exception as e:
|
|
raise Exception(f"Document {doc_id} inaccessible : {e}")
|
|
|
|
if not doc:
|
|
raise Exception(f"Document {doc_id} introuvable")
|
|
|
|
buffer = BytesIO()
|
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
|
width, height = A4
|
|
|
|
# Couleurs
|
|
green_color = HexColor("#2A6F4F")
|
|
gray_400 = HexColor("#9CA3AF")
|
|
gray_600 = HexColor("#4B5563")
|
|
gray_800 = HexColor("#1F2937")
|
|
|
|
# Marges
|
|
margin = 8 * mm
|
|
content_width = width - 2 * margin
|
|
|
|
y = height - margin
|
|
|
|
# ===== HEADER =====
|
|
y -= 20 * mm
|
|
|
|
# Logo/Nom entreprise à gauche
|
|
pdf.setFont("Helvetica-Bold", 18)
|
|
pdf.setFillColor(green_color)
|
|
pdf.drawString(margin, y, "Bijou S.A.S")
|
|
|
|
# Informations document à droite
|
|
pdf.setFillColor(gray_800)
|
|
pdf.setFont("Helvetica-Bold", 20)
|
|
numero = doc.get("numero") or "BROUILLON"
|
|
pdf.drawRightString(width - margin, y, numero.upper())
|
|
|
|
y -= 7 * mm
|
|
pdf.setFont("Helvetica", 9)
|
|
pdf.setFillColor(gray_600)
|
|
|
|
date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y")
|
|
pdf.drawRightString(width - margin, y, f"Date : {date_str}")
|
|
|
|
y -= 5 * mm
|
|
date_livraison = (
|
|
doc.get("date_livraison") or doc.get("date_echeance") or date_str
|
|
)
|
|
pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}")
|
|
|
|
y -= 5 * mm
|
|
reference = doc.get("reference") or "—"
|
|
pdf.drawRightString(width - margin, y, f"Réf : {reference}")
|
|
|
|
# ===== ADDRESSES =====
|
|
y -= 20 * mm
|
|
|
|
# Émetteur (gauche)
|
|
col1_x = margin
|
|
col2_x = margin + content_width / 2 + 6 * mm
|
|
col_width = content_width / 2 - 6 * mm
|
|
|
|
pdf.setFont("Helvetica-Bold", 8)
|
|
pdf.setFillColor(gray_400)
|
|
pdf.drawString(col1_x, y, "ÉMETTEUR")
|
|
|
|
y_emetteur = y - 5 * mm
|
|
pdf.setFont("Helvetica-Bold", 10)
|
|
pdf.setFillColor(gray_800)
|
|
pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S")
|
|
|
|
y_emetteur -= 5 * mm
|
|
pdf.setFont("Helvetica", 9)
|
|
pdf.setFillColor(gray_600)
|
|
pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République")
|
|
|
|
y_emetteur -= 4 * mm
|
|
pdf.drawString(col1_x, y_emetteur, "75011 Paris, France")
|
|
|
|
y_emetteur -= 5 * mm
|
|
pdf.drawString(col1_x, y_emetteur, "contact@bijou.com")
|
|
|
|
# Destinataire (droite, avec fond gris)
|
|
box_y = y - 4 * mm
|
|
box_height = 28 * mm
|
|
pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50
|
|
pdf.roundRect(
|
|
col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0
|
|
)
|
|
|
|
pdf.setFillColor(gray_400)
|
|
pdf.setFont("Helvetica-Bold", 8)
|
|
pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE")
|
|
|
|
y_dest = y - 5 * mm
|
|
pdf.setFont("Helvetica-Bold", 10)
|
|
pdf.setFillColor(gray_800)
|
|
client_name = doc.get("client_intitule") or "Client"
|
|
pdf.drawString(col2_x + 4 * mm, y_dest, client_name)
|
|
|
|
y_dest -= 5 * mm
|
|
pdf.setFont("Helvetica", 9)
|
|
pdf.setFillColor(gray_600)
|
|
pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients")
|
|
|
|
y_dest -= 4 * mm
|
|
pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris")
|
|
|
|
# ===== LIGNES D'ARTICLES =====
|
|
y = min(y_emetteur, y_dest) - 20 * mm
|
|
|
|
# En-têtes des colonnes
|
|
col_designation = margin
|
|
col_quantite = width - margin - 80 * mm
|
|
col_prix_unit = width - margin - 64 * mm
|
|
col_taux_taxe = width - margin - 40 * mm
|
|
col_montant = width - margin - 24 * mm
|
|
|
|
pdf.setFont("Helvetica-Bold", 9)
|
|
pdf.setFillColor(gray_800)
|
|
pdf.drawString(col_designation, y, "Désignation")
|
|
pdf.drawRightString(col_quantite, y, "Qté")
|
|
pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT")
|
|
pdf.drawRightString(col_taux_taxe, y, "TVA")
|
|
pdf.drawRightString(col_montant, y, "Montant HT")
|
|
|
|
y -= 7 * mm
|
|
|
|
# Lignes d'articles
|
|
pdf.setFont("Helvetica", 8)
|
|
lignes = doc.get("lignes", [])
|
|
|
|
for ligne in lignes:
|
|
if y < 60 * mm: # Nouvelle page si nécessaire
|
|
pdf.showPage()
|
|
y = height - margin - 20 * mm
|
|
pdf.setFont("Helvetica", 8)
|
|
|
|
designation = (
|
|
ligne.get("designation") or ligne.get("designation_article") or ""
|
|
)
|
|
if len(designation) > 60:
|
|
designation = designation[:57] + "..."
|
|
|
|
pdf.setFillColor(gray_800)
|
|
pdf.setFont("Helvetica-Bold", 8)
|
|
pdf.drawString(col_designation, y, designation)
|
|
|
|
y -= 4 * mm
|
|
|
|
# Description (si différente)
|
|
description = ligne.get("description", "")
|
|
if description and description != designation:
|
|
pdf.setFont("Helvetica", 7)
|
|
pdf.setFillColor(gray_600)
|
|
if len(description) > 70:
|
|
description = description[:67] + "..."
|
|
pdf.drawString(col_designation, y, description)
|
|
y -= 4 * mm
|
|
|
|
# Valeurs
|
|
y += 4 * mm # Remonter pour aligner avec la désignation
|
|
pdf.setFont("Helvetica", 8)
|
|
pdf.setFillColor(gray_800)
|
|
|
|
quantite = ligne.get("quantite") or 0
|
|
pdf.drawRightString(col_quantite, y, str(quantite))
|
|
|
|
prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0)
|
|
pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f} €")
|
|
|
|
taux_taxe = ligne.get("taux_taxe1") or 20
|
|
pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%")
|
|
|
|
montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0)
|
|
pdf.setFont("Helvetica-Bold", 8)
|
|
pdf.drawRightString(col_montant, y, f"{montant:.2f} €")
|
|
|
|
y -= 8 * mm
|
|
|
|
# Si aucune ligne
|
|
if not lignes:
|
|
pdf.setFont("Helvetica-Oblique", 9)
|
|
pdf.setFillColor(gray_400)
|
|
pdf.drawCentredString(width / 2, y, "Aucune ligne")
|
|
y -= 15 * mm
|
|
|
|
# ===== TOTAUX =====
|
|
y -= 10 * mm
|
|
|
|
totals_x = width - margin - 64 * mm
|
|
totals_label_width = 40 * mm
|
|
|
|
pdf.setFont("Helvetica", 9)
|
|
pdf.setFillColor(gray_600)
|
|
|
|
# Total HT
|
|
pdf.drawString(totals_x, y, "Total HT")
|
|
total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0
|
|
pdf.drawRightString(width - margin, y, f"{total_ht:.2f} €")
|
|
|
|
y -= 6 * mm
|
|
|
|
# TVA
|
|
pdf.drawString(totals_x, y, "TVA")
|
|
total_ttc = doc.get("total_ttc") or 0
|
|
tva = total_ttc - total_ht
|
|
pdf.drawRightString(width - margin, y, f"{tva:.2f} €")
|
|
|
|
y -= 8 * mm
|
|
|
|
# Ligne de séparation
|
|
pdf.setStrokeColor(gray_400)
|
|
pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm)
|
|
|
|
# Net à payer
|
|
pdf.setFont("Helvetica-Bold", 12)
|
|
pdf.setFillColor(green_color)
|
|
pdf.drawString(totals_x, y, "Net à payer")
|
|
pdf.drawRightString(width - margin, y, f"{total_ttc:.2f} €")
|
|
|
|
# ===== NOTES =====
|
|
notes = doc.get("notes_publique") or doc.get("notes")
|
|
if notes:
|
|
y -= 15 * mm
|
|
pdf.setStrokeColor(gray_400)
|
|
pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm)
|
|
|
|
y -= 5 * mm
|
|
pdf.setFont("Helvetica-Bold", 8)
|
|
pdf.setFillColor(gray_400)
|
|
pdf.drawString(margin, y, "NOTES & CONDITIONS")
|
|
|
|
y -= 5 * mm
|
|
pdf.setFont("Helvetica", 8)
|
|
pdf.setFillColor(gray_600)
|
|
|
|
# Gérer les sauts de ligne dans les notes
|
|
for line in notes.split("\n"):
|
|
if y < 25 * mm:
|
|
break
|
|
pdf.drawString(margin, y, line[:100])
|
|
y -= 4 * mm
|
|
|
|
# ===== FOOTER =====
|
|
pdf.setFont("Helvetica", 7)
|
|
pdf.setFillColor(gray_400)
|
|
pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1")
|
|
|
|
pdf.save()
|
|
buffer.seek(0)
|
|
|
|
return buffer.read()
|
|
|
|
|
|
email_queue = EmailQueue()
|