Robustesse (backup, export Excel, journal) + notes libres et relances

- Sauvegarde automatique du CSV avant chaque écriture (backups/, 30 versions)
- Export Excel .xlsx sans dépendance (module excel.py)
- Journal de scraping (scrape.log) + panneau et endpoints /api/logs
- Note libre par prospect (colonne Notes, zone de texte)
- Notification de relance in-app (statut Contacté/En discussion + délai configurable)
This commit is contained in:
jerem
2026-06-13 15:47:49 +02:00
parent 1cf427a0f2
commit 02180f1c7b
5 changed files with 317 additions and 9 deletions

78
excel.py Normal file
View File

@@ -0,0 +1,78 @@
"""Génération d'un classeur .xlsx minimal, sans dépendance.
Un fichier .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
« inline » (t="inlineStr") pour éviter la table des chaînes partagées : le résultat
s'ouvre dans Excel, Numbers et LibreOffice. Bibliothèque standard uniquement.
"""
import io
import zipfile
from xml.sax.saxutils import escape
def _ref(col, ligne):
"""Référence de cellule façon tableur : (0, 1) -> « A1 », (27, 3) -> « AB3 »."""
lettres, n = "", col
while True:
n, reste = divmod(n, 26)
lettres = chr(65 + reste) + lettres
if n == 0:
break
n -= 1
return f"{lettres}{ligne}"
def _cellule(col, ligne, valeur):
texte = escape("" if valeur is None else str(valeur))
return (f'<c r="{_ref(col, ligne)}" t="inlineStr">'
f'<is><t xml:space="preserve">{texte}</t></is></c>')
def construire_xlsx(colonnes, lignes, nom_feuille="Prospects"):
"""Octets d'un classeur .xlsx : une feuille avec en-tête puis une ligne par dict."""
rangs = ['<row r="1">' + "".join(_cellule(c, 1, colonnes[c]) for c in range(len(colonnes))) + "</row>"]
for i, ligne in enumerate(lignes, start=2):
cellules = "".join(_cellule(c, i, ligne.get(colonnes[c], "")) for c in range(len(colonnes)))
rangs.append(f'<row r="{i}">{cellules}</row>')
feuille = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
f'<sheetData>{"".join(rangs)}</sheetData></worksheet>'
)
content_types = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'</Types>'
)
rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'</Relationships>'
)
workbook = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
f'<sheets><sheet name="{escape(nom_feuille[:31])}" sheetId="1" r:id="rId1"/></sheets></workbook>'
)
workbook_rels = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
'</Relationships>'
)
tampon = io.BytesIO()
with zipfile.ZipFile(tampon, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", content_types)
z.writestr("_rels/.rels", rels)
z.writestr("xl/workbook.xml", workbook)
z.writestr("xl/_rels/workbook.xml.rels", workbook_rels)
z.writestr("xl/worksheets/sheet1.xml", feuille)
return tampon.getvalue()