Sync Indy integre dans l'interface web + anti-doublon

This commit is contained in:
2026-06-30 14:08:35 +02:00
parent 458f80f201
commit 75af558b49
13 changed files with 665 additions and 48 deletions
+118
View File
@@ -0,0 +1,118 @@
"""
Synchronisation Indy : récupère les transactions via l'API privée d'Indy et les
normalise au format attendu par l'import (réutilisé par la page /import/).
La « catégorie » Indy est un numéro de compte du plan comptable (PCG) ; on le
mappe vers nos catégories. Montants en centimes, signés. `tva_intracom` => UE.
⚠️ API non officielle d'Indy : pour les données de l'utilisateur. Le token JWT
expire (~1 h) et doit être renouvelé.
"""
from decimal import Decimal
import httpx
BASE_URL = "https://app.indy.fr"
TRANSACTIONS_PATH = "/api/transactions/transactions-list"
COMPTE_CATEGORIE = [
("701", "Vente de produits finis"), ("707", "Vente de produits finis"),
("706", "Prestation de services"),
("605", "Matériel et outillage"), ("606", "Matériel et outillage"),
("607", "Matériel et outillage"),
("6256", "Frais de repas hors domicile"), ("6253", "Frais de repas hors domicile"),
("626", "Télécom, fournitures, documentation"),
("6181", "Télécom, fournitures, documentation"),
("6183", "Télécom, fournitures, documentation"),
("6136", "Abonnement logiciel"), ("6226", "Abonnement logiciel"),
("646", "Cotisation sociale Urssaf"),
("44551", "TVA payée"), ("44558", "TVA payée"),
("44583", "Remboursement de TVA"),
("108", "Prélèvement personnel"),
]
COMPTE_ACTIVITE = [("701", "vente"), ("707", "vente"), ("706", "service_bic")]
def _cat(num):
num = str(num or "")
best = None
for pref, cat in COMPTE_CATEGORIE:
if num.startswith(pref) and (best is None or len(pref) > len(best[0])):
best = (pref, cat)
return best[1] if best else None
def _act(num):
num = str(num or "")
for pref, act in COMPTE_ACTIVITE:
if num.startswith(pref):
return act
return ""
def normaliser(tx):
"""Transaction Indy -> ligne normalisée, ou None si inexploitable."""
try:
montant = (Decimal(tx["totalAmountInCents"]) / 100).quantize(Decimal("0.01"))
except Exception:
return None
subs = [s for s in tx.get("subdivisions", [])
if not str((s.get("accounting_account") or {}).get("number", "")).startswith("512")]
if not subs:
return None
main = max(subs, key=lambda s: abs(s.get("amount_in_cents", 0)))
num = str((main.get("accounting_account") or {}).get("number", ""))
intra = main.get("tva_intracom")
if intra and intra.get("type") == "eu":
regime, taux = "intracom", Decimal(intra.get("rate", 0)) / 100
else:
regime, taux = "nationale", Decimal(main.get("tva_rate", 0)) / 100
return {
"indy_id": tx.get("_id", ""),
"date": tx.get("date", ""),
"libelle": tx.get("description") or tx.get("raw_description") or "(sans libellé)",
"categorie_nom": _cat(num),
"activite": _act(num),
"taux": taux,
"regime": regime,
"montant": montant,
"compte": num,
}
def fetch_transactions(token, date_debut=None, date_fin=None, search="", page=1,
base_url=BASE_URL):
"""Appelle l'API Indy. Retourne (status, liste_transactions_brutes, erreur)."""
headers = {
"Accept": "application/json, text/plain, */*",
"Authorization": f"Bearer {token}",
"User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/149.0 Safari/537.36"),
"x-client-app-type": "web",
}
params = {"search": search or "", "page": page}
if date_debut:
params["dateFrom"] = date_debut
if date_fin:
params["dateTo"] = date_fin
try:
with httpx.Client(base_url=base_url.rstrip("/"), headers=headers,
timeout=30, follow_redirects=True) as c:
r = c.get(TRANSACTIONS_PATH, params=params)
if r.status_code != 200:
return r.status_code, [], f"HTTP {r.status_code} (token expiré ?)"
data = r.json()
return 200, data.get("transactions", []), None
except Exception as ex: # noqa: BLE001
return None, [], f"{type(ex).__name__}: {ex}"
def lignes_normalisees(token, date_debut=None, date_fin=None, search="", page=1,
base_url=BASE_URL):
"""Retourne (lignes_normalisées, erreur)."""
status, txs, err = fetch_transactions(token, date_debut, date_fin, search, page, base_url)
if err:
return [], err
lignes = [n for n in (normaliser(t) for t in txs) if n]
return lignes, None
@@ -0,0 +1,33 @@
# Generated by Django 5.2.15 on 2026-06-30 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compta', '0008_alter_depense_montant_tva_alter_depense_taux_tva_and_more'),
]
operations = [
migrations.AddField(
model_name='depense',
name='indy_id',
field=models.CharField(blank=True, db_index=True, max_length=40),
),
migrations.AddField(
model_name='entreprise',
name='indy_token',
field=models.TextField(blank=True, help_text='Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.', verbose_name='Token Indy (API)'),
),
migrations.AddField(
model_name='facture',
name='indy_id',
field=models.CharField(blank=True, db_index=True, max_length=40),
),
migrations.AddField(
model_name='paiement',
name='indy_id',
field=models.CharField(blank=True, db_index=True, max_length=40),
),
]
+6 -1
View File
@@ -88,6 +88,9 @@ class Entreprise(models.Model):
num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True)
vue_tva = models.CharField(
"Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie")
indy_token = models.TextField(
"Token Indy (API)", blank=True,
help_text="Token JWT capté dans le navigateur, pour la synchronisation. Expire ~1 h.")
utilisateurs = models.ManyToManyField(
settings.AUTH_USER_MODEL, blank=True, related_name="entreprises",
verbose_name="Utilisateurs autorisés",
@@ -151,6 +154,7 @@ class Facture(models.Model):
montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
montant_ttc = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0"))
notes = models.TextField(blank=True)
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -207,6 +211,7 @@ class Paiement(models.Model):
categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True,
related_name="paiements",
help_text="Surtout pour les mouvements neutres.")
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -255,6 +260,7 @@ class Depense(models.Model):
"Taux de TVA (%)", max_digits=4, decimal_places=2, default=Decimal("20"),
choices=TAUX_TVA_CHOICES,
help_text="Achat national : la TVA déductible est calculée. UE/import : taux auto-liquidé.")
indy_id = models.CharField(max_length=40, blank=True, db_index=True)
cree_le = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -266,7 +272,6 @@ class Depense(models.Model):
return f"{self.date}{self.libelle}"
def save(self, *args, **kwargs):
# TVA déductible calculée : incluse dans le TTC (national), nulle en autoliq.
t = self.taux_tva or Decimal("0")
if self.autoliquidee:
self.montant_tva = Decimal("0.00")
+35 -14
View File
@@ -10,16 +10,18 @@
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,Segoe UI,Roboto,sans-serif;
background:var(--bg); color:var(--txt); padding:24px; }
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);}
h1 { margin:0 0 4px; font-size:22px; } h2{font-size:15px;color:var(--muted);margin:0 0 8px;}
a { color:var(--accent); }
.sub { color:var(--muted); margin-bottom:14px; }
.card { background:var(--card); border:1px solid #334155; border-radius:12px; padding:16px; margin-bottom:16px; }
.card.indy { border-color:#0e7490; }
label { display:block; font-size:12px; color:var(--muted); margin:8px 0 4px; }
input, select, textarea { background:#0f172a; color:var(--txt); border:1px solid #334155;
border-radius:8px; padding:8px 10px; font-size:14px; width:100%; }
textarea { min-height:110px; font-family:ui-monospace,monospace; }
textarea { min-height:90px; font-family:ui-monospace,monospace; }
.grid3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:12px; }
@media(max-width:700px){ .grid3{grid-template-columns:1fr;} }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@media(max-width:700px){ .grid3,.grid2{grid-template-columns:1fr;} }
button { cursor:pointer; border-radius:8px; border:1px solid #334155; background:var(--card);
color:var(--txt); padding:9px 16px; font-size:14px; }
button.primary { background:var(--accent); color:#04263a; border:none; font-weight:600; }
@@ -53,8 +55,11 @@
{% endif %}
{% if not lignes %}
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
<!-- ===== Synchroniser depuis Indy ===== -->
<form method="post" class="card indy">
{% csrf_token %}
<h2>① Synchroniser depuis Indy (recommandé)</h2>
<div class="sub">Récupère tes transactions directement depuis l'API Indy. Anti-doublon automatique.</div>
<div class="grid3">
<div>
<label>Entreprise</label>
@@ -62,12 +67,30 @@
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select>
</div>
<div><label>Du</label><input type="date" name="date_debut"></div>
<div><label>Au</label><input type="date" name="date_fin"></div>
</div>
<label>Token Indy (Bearer, sans « Bearer ») — mémorisé sur l'entreprise, expire ~1 h</label>
<textarea name="indy_token" placeholder="eyJ..."></textarea>
<div class="actions">
<button type="submit" name="action" value="indy" class="primary">Récupérer depuis Indy</button>
</div>
</form>
<!-- ===== CSV / capture ===== -->
<form method="post" enctype="multipart/form-data" class="card" id="srcForm">
{% csrf_token %}
<h2>② Ou : CSV / capture d'écran (OCR)</h2>
<div class="grid3">
<div>
<label>Année (pour l'OCR)</label>
<input type="number" name="annee" value="{{ annee_defaut }}">
<label>Entreprise</label>
<select name="entreprise" required>
{% for e in entreprises %}<option value="{{ e.pk }}" {% if e.pk == entreprise_id %}selected{% endif %}>{{ e.nom }}</option>{% endfor %}
</select>
</div>
<div><label>Année (OCR)</label><input type="number" name="annee" value="{{ annee_defaut }}"></div>
<div>
<label>Photo / capture (OCR) — fichier, appareil photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
<label>Photo / capture — fichier, photo, ou <b>coller</b> (Ctrl/Cmd+V)</label>
<input type="file" name="image" id="imgInput" accept="image/*" capture="environment">
<div id="pasteInfo" class="sub" style="margin-top:4px"></div>
</div>
@@ -79,7 +102,6 @@
<div class="actions">
<button type="submit" name="action" value="preview" class="primary">Analyser / Aperçu</button>
</div>
<div class="sub" style="margin-top:8px">L'OCR pré-remplit les lignes ; tu corriges la catégorie et le taux dans l'aperçu avant d'enregistrer.</div>
</form>
{% endif %}
@@ -87,7 +109,7 @@
<form method="post" class="card">
{% csrf_token %}
<input type="hidden" name="entreprise" value="{{ entreprise_id }}">
<h2>Aperçu éditable — {{ lignes|length }} ligne(s) {% if source == 'ocr' %}(OCR){% endif %}</h2>
<h2>Aperçu éditable — {{ lignes|length }} ligne(s){% if source == 'indy' %} (Indy){% elif source == 'ocr' %} (OCR){% endif %}</h2>
<table>
<thead><tr><th>Date</th><th>Libellé</th><th>Catégorie</th><th>Régime TVA</th><th>Taux %</th><th>Montant €</th><th>Type</th></tr></thead>
<tbody>
@@ -109,6 +131,8 @@
<td><input type="number" step="0.01" name="l_taux" value="{{ l.taux }}" class="num" style="width:70px"></td>
<td><input type="number" step="0.01" name="l_montant" value="{{ l.montant }}" class="num" style="width:100px"></td>
<td>{{ l.type }}</td>
<input type="hidden" name="l_activite" value="{{ l.activite }}">
<input type="hidden" name="l_indy_id" value="{{ l.indy_id }}">
</tr>
{% endfor %}
</tbody>
@@ -127,13 +151,10 @@
for(var i=0; i<e.clipboardData.items.length; i++){
var it = e.clipboardData.items[i];
if(it.type && it.type.indexOf('image') === 0){
var dt = new DataTransfer();
dt.items.add(it.getAsFile());
input.files = dt.files;
var dt = new DataTransfer(); dt.items.add(it.getAsFile()); input.files = dt.files;
var info = document.getElementById('pasteInfo');
if(info){ info.textContent = 'Image collée — clique « Analyser ».'; info.style.color = '#34d399'; }
e.preventDefault();
break;
e.preventDefault(); break;
}
}
});
+84 -33
View File
@@ -1,13 +1,6 @@
"""
Import d'opérations : CSV (collé/uploadé) ou OCR d'une capture d'écran.
OCR multi-format :
- LISTE : le tableau d'opérations (date empilée à gauche, lignes). Découpage
ancré sur le montant ; la date est reportée d'une ligne à l'autre
(Indy ne l'affiche qu'une fois par jour). « UE » => intracom.
- DÉTAIL : la fiche d'une opération (Libellé / Date / Montant / Ventilation).
Extraction au mieux (les petits libellés gris sont mal lus).
Dans tous les cas : aperçu ÉDITABLE + panneau de debug OCR.
Import d'opérations : CSV, OCR (capture), ou SYNC INDY (API).
Dans tous les cas : aperçu ÉDITABLE + anti-doublon (indy_id) + panneau debug OCR.
"""
import csv
import io
@@ -21,6 +14,7 @@ from django.shortcuts import render
from .models import (Entreprise, Client, Facture, LigneFacture, Paiement,
Depense, Categorie, Activite, RegimeTva)
from . import indy
CENT = Decimal("0.01")
MOIS = {"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5,
@@ -50,6 +44,12 @@ def _date(s):
return None
def _activite(s):
s = (s or "").strip().lower()
return {"vente": "vente", "service_bic": "service_bic", "service_bnc": "service_bnc",
"bic": "service_bic", "bnc": "service_bnc"}.get(s, "")
def _regime(s):
s = (s or "").strip().lower()
if s in ("intracom", "intracommunautaire", "ue"):
@@ -78,7 +78,7 @@ def _match_cat(text, cats):
def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE,
categorie=None, n=0):
categorie=None, activite="", indy_id="", n=0):
neutre = bool(categorie and categorie.exclure_calculs)
if montant > 0:
typ = "Mouvement neutre" if neutre else "Vente"
@@ -86,7 +86,8 @@ def _row(dt, libelle, montant, taux=Decimal("0"), regime=RegimeTva.NATIONALE,
typ = "Dépense neutre" if neutre else "Dépense"
return {"n": n, "date": dt.isoformat() if dt else "", "libelle": libelle,
"categorie_id": categorie.pk if categorie else "",
"taux": taux, "regime": regime, "montant": montant, "type": typ}
"taux": taux, "regime": regime, "montant": montant, "type": typ,
"activite": activite, "indy_id": indy_id}
# ---- CSV ----------------------------------------------------------------
@@ -109,7 +110,7 @@ def _parse_csv(text, cats):
erreurs.append(f"Ligne {n} : montant nul."); continue
cat = cats.get((c[2] or "").strip().lower())
lignes.append(_row(dt, (c[1] or "").strip() or "(sans libellé)", montant,
_dec(c[4]), _regime(c[5]), cat, n))
_dec(c[4]), _regime(c[5]), cat, _activite(c[3]), "", n))
return lignes, erreurs, []
@@ -198,7 +199,7 @@ def _parse_liste(words, annee, cats):
if cat:
libelle = re.sub(re.escape(cat.nom), "", texte, flags=re.I).strip(" -·,")
lignes.append(_row(dt, libelle or texte or "(sans libellé)", _dec(a["t"]),
taux, regime, cat, n))
taux, regime, cat, "", "", n))
return lignes
@@ -208,18 +209,17 @@ def _parse_detail(words, cats):
amounts = [w for w in words if "%" not in w["t"] and AMOUNT_RE.match(w["t"])]
montant = _dec(amounts[0]["t"]) if amounts else Decimal("0")
y_ref = dtok["yc"] if dtok else 99999
haut_gauche = [w for w in words if w["x"] < 450 and w["yc"] < y_ref
and w["t"].lower() not in ("informations", "date", "libellé", "libelle")]
libelle = " ".join(w["t"] for w in sorted(haut_gauche, key=lambda w: (w["yc"], w["x"])))
haut = [w for w in words if w["x"] < 450 and w["yc"] < y_ref
and w["t"].lower() not in ("informations", "date", "libellé", "libelle")]
libelle = " ".join(w["t"] for w in sorted(haut, key=lambda w: (w["yc"], w["x"])))
libelle = libelle.strip(" '\"") or "(à compléter)"
droite = [w for w in words if w["x"] > 480 and not AMOUNT_RE.match(w["t"]) and "%" not in w["t"]
and w["t"].lower() not in ("ventilation(s)", "ventilation", "catégorie",
"categorie", "montant", "ttc", "tva")]
texte_cat = " ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"])))
cat = _match_cat(texte_cat, cats)
cat = _match_cat(" ".join(w["t"] for w in sorted(droite, key=lambda w: (w["yc"], w["x"]))), cats)
if montant == 0:
return []
return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, 1)]
return [_row(dt, libelle, montant, Decimal("0"), RegimeTva.NATIONALE, cat, "", "", 1)]
def _parse_ocr(image_file, annee, cats):
@@ -229,25 +229,49 @@ def _parse_ocr(image_file, annee, cats):
debug = _debug_lines(words)
allt = " ".join(w["t"] for w in words).lower()
is_detail = ("ventilation" in allt) or ("informations" in allt and "montant" in allt)
if is_detail:
lignes = _parse_detail(words, cats); fmt = "détail"
else:
lignes = _parse_liste(words, annee, cats); fmt = "liste"
lignes = _parse_detail(words, cats) if is_detail else _parse_liste(words, annee, cats)
if not lignes:
erreurs.append(f"Format « {fmt} » détecté mais aucune ligne exploitable. "
f"Regarde le texte OCR ci-dessous et complète à la main, "
f"ou utilise le CSV.")
erreurs.append("Aucune ligne exploitable détectée. Regarde le texte OCR ci-dessous "
"et complète à la main, ou utilise le CSV.")
return lignes, erreurs, debug
# ---- INDY ---------------------------------------------------------------
def _parse_indy(entreprise, token, date_debut, date_fin, cats_par_nom):
lignes_norm, err = indy.lignes_normalisees(token, date_debut or None, date_fin or None)
if err:
return [], [f"Indy : {err}"]
lignes = []
for n, ln in enumerate(lignes_norm, start=1):
dt = _date(ln["date"])
cat = cats_par_nom.get((ln["categorie_nom"] or "").strip().lower())
lignes.append(_row(dt, ln["libelle"], ln["montant"], ln["taux"],
_regime(ln["regime"]), cat, ln["activite"] or "",
ln["indy_id"], n))
return lignes, []
# ---- Anti-doublon -------------------------------------------------------
def _deja_importe(entreprise, indy_id):
if not indy_id:
return False
return (Facture.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()
or Depense.objects.filter(entreprise=entreprise, indy_id=indy_id).exists()
or Paiement.objects.filter(entreprise=entreprise, indy_id=indy_id).exists())
# ---- Création -----------------------------------------------------------
def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
def _creer(entreprise, dt, libelle, montant, taux, regime, categorie,
activite="", indy_id=""):
neutre = bool(categorie and categorie.exclure_calculs)
if montant > 0:
if neutre:
Paiement.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
montant_total=montant, sens="credit", categorie=categorie)
montant_total=montant, sens="credit",
categorie=categorie, indy_id=indy_id)
return "neutre"
client, _ = Client.objects.get_or_create(entreprise=entreprise, nom=libelle)
ht = (montant / (Decimal("1") + taux / Decimal("100"))).quantize(CENT)
@@ -258,15 +282,16 @@ def _creer(entreprise, dt, libelle, montant, taux, regime, categorie):
while Facture.objects.filter(entreprise=entreprise, numero=numero).exists():
base += 1; numero = f"{annee}-{base:03d}"
f = Facture.objects.create(entreprise=entreprise, client=client, numero=numero,
activite=entreprise.activite, date_emission=dt,
date_encaissement=dt, statut="encaissee")
activite=activite or entreprise.activite,
date_emission=dt, date_encaissement=dt,
statut="encaissee", indy_id=indy_id)
LigneFacture.objects.create(facture=f, designation=libelle, quantite=Decimal("1"),
prix_unitaire_ht=ht, taux_tva=taux)
f.recompute_totaux()
return "vente"
Depense.objects.create(entreprise=entreprise, date=dt, libelle=libelle,
categorie=categorie, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant)
categorie=categorie, activite=activite, regime_tva=regime,
taux_tva=taux, montant_ttc=-montant, indy_id=indy_id)
return "depense"
@@ -296,21 +321,47 @@ def import_csv(request):
taux = request.POST.getlist("l_taux")
regimes = request.POST.getlist("l_regime")
montants = request.POST.getlist("l_montant")
activites = request.POST.getlist("l_activite")
indy_ids = request.POST.getlist("l_indy_id")
cats_by_id = {str(c.pk): c for c in Categorie.objects.all()}
compteur = {}
ignores = 0
for i in range(len(libelles)):
dt = _date(dates[i]) if i < len(dates) else None
montant = _dec(montants[i] if i < len(montants) else "0")
iid = indy_ids[i] if i < len(indy_ids) else ""
if dt is None or montant == 0:
continue
if iid and _deja_importe(entreprise, iid):
ignores += 1
continue
k = _creer(entreprise, dt, (libelles[i] or "").strip() or "(sans libellé)",
montant, _dec(taux[i] if i < len(taux) else "0"),
_regime(regimes[i] if i < len(regimes) else ""),
cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""))
cats_by_id.get(cat_ids[i] if i < len(cat_ids) else ""),
_activite(activites[i] if i < len(activites) else ""), iid)
compteur[k] = compteur.get(k, 0) + 1
if ignores:
compteur["déjà importées (ignorées)"] = ignores
ctx["resultat"] = compteur
return render(request, "compta/import_csv.html", ctx)
if action == "indy":
token = (request.POST.get("indy_token") or "").strip()
if entreprise and token:
entreprise.indy_token = token
entreprise.save(update_fields=["indy_token"])
elif entreprise and not token:
token = entreprise.indy_token
if not entreprise or not token:
ctx["erreurs"] = ["Choisis une entreprise et renseigne le token Indy."]
return render(request, "compta/import_csv.html", ctx)
lignes, erreurs = _parse_indy(entreprise, token, request.POST.get("date_debut"),
request.POST.get("date_fin"), cats)
ctx.update({"lignes": lignes, "erreurs": erreurs, "source": "indy"})
return render(request, "compta/import_csv.html", ctx)
# Aperçu CSV ou OCR
image = request.FILES.get("image")
if image:
annee = int(request.POST.get("annee") or date_cls.today().year)