Files

381 lines
14 KiB
Python

import csv
import io
from datetime import datetime
from flask import (
Blueprint,
Response,
current_app,
flash,
redirect,
render_template,
request,
url_for,
)
from sqlalchemy import text
from app import db
from app.decorators import login_required
historique_bp = Blueprint("historique", __name__)
# ---------------------------------------------------------------------------
# Import / Export des actions CSV
# ---------------------------------------------------------------------------
@historique_bp.route("/gestion-import-export-actions-csv", methods=["GET"])
@login_required
def gestion_import_export_actions_csv():
return render_template("gestion_import_export_actions_csv.html")
@historique_bp.route("/export-actions-csv", methods=["GET"])
@login_required
def export_actions_csv():
try:
query = text(
"SELECT id, isin, ticker, company_name, exchange, pays, currency, "
"updated_at FROM actions"
)
result = db.session.execute(query)
output = io.StringIO()
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
writer.writerow(
["id", "isin", "ticker", "company_name", "exchange", "pays", "currency", "updated_at"]
)
for row in result:
writer.writerow(
[
row.id,
row.isin or "",
row.ticker or "",
row.company_name or "",
row.exchange or "",
row.pays or "",
row.currency or "",
row.updated_at.strftime("%Y-%m-%d %H:%M:%S")
if row.updated_at
else "",
]
)
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment;filename=actions_export.csv"},
)
except Exception as e:
current_app.logger.error("Erreur export CSV : %s", e, exc_info=True)
flash("Erreur lors de l'exportation du fichier CSV.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
@historique_bp.route("/import-actions-csv", methods=["POST"])
@login_required
def import_actions_csv():
if "file" not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
file = request.files["file"]
if file.filename == "":
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
if not file.filename.endswith(".csv"):
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
try:
stream = io.TextIOWrapper(file.stream, encoding="utf-8")
csv_reader = csv.reader(stream, delimiter=";")
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
count = 0
for row in csv_reader:
if len(row) >= 6:
isin = row[0].strip() if row[0] != "" else None
ticker = row[1].strip() if row[1] != "" else None
company_name = row[2].strip() if row[2] != "" else None
exchange = row[3].strip() if row[3] != "" else ""
pays = row[4].strip() if row[4] != "" else ""
currency = row[5].strip() if row[5] != "" else ""
if not isin or not ticker:
continue
query = text(
"INSERT INTO actions (isin, ticker, company_name, exchange, "
"pays, currency, updated_at) "
"VALUES (:isin, :ticker, :company_name, :exchange, :pays, "
":currency, NOW()) "
"ON DUPLICATE KEY UPDATE ticker = :ticker, "
"company_name = :company_name, exchange = :exchange, "
"pays = :pays, currency = :currency, updated_at = NOW()"
)
db.session.execute(
query,
{
"isin": isin,
"ticker": ticker,
"company_name": company_name,
"exchange": exchange,
"pays": pays,
"currency": currency,
},
)
count += 1
db.session.commit()
flash(f"Importation réussie : {count} actions traitées.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur import CSV : %s", e, exc_info=True)
flash(f"Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for("historique.gestion_import_export_actions_csv"))
# ---------------------------------------------------------------------------
# Import historique CSV (cotation d'une action)
# ---------------------------------------------------------------------------
@historique_bp.route("/gestion-import-historique-csv", methods=["GET"])
@login_required
def gestion_import_historique_csv():
return render_template("gestion_import_historique_csv.html")
def _convertir_vol(valeur):
"""Convertit une valeur de volume du CSV en entier.
Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
"""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() == "N/A" or s.upper() == "NA":
return None
try:
if s.upper().endswith("K"):
return int(float(s[:-1]) * 1000)
if s.upper().endswith("M"):
return int(float(s[:-1]) * 1000000)
return int(float(s))
except (TypeError, ValueError):
return None
def _to_decimal(valeur):
"""Convertit une valeur CSV en float (Decimal), None si vide/invalide.
Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() in ("N/A", "NA"):
return None
# Suppression d'un éventuel suffixe '%' (et espaces autour)
if s.endswith("%"):
s = s[:-1].strip()
# Suppression d'un éventuel séparateur de milliers (virgule ou espace)
s = s.replace(" ", "").replace(",", "")
try:
return float(s)
except (TypeError, ValueError):
return None
@historique_bp.route("/import-historique-csv", methods=["POST"])
@login_required
def import_historique_csv():
isin = (request.form.get("isin") or "").strip().upper()
# Vérification que l'ISIN existe bien dans le référentiel actions
if not isin:
flash("Veuillez saisir un code ISIN.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
action = db.session.execute(
text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
).fetchone()
if not action:
flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
if "file" not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
file = request.files["file"]
if file.filename == "":
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
if not file.filename.lower().endswith(".csv"):
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for("historique.gestion_import_historique_csv"))
try:
# Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
raw_bytes = file.stream.read()
# Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
if raw_bytes.startswith(b"\xef\xbb\xbf"):
raw_bytes = raw_bytes[3:]
try:
content = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
content = raw_bytes.decode("latin-1")
stream = io.StringIO(content)
# Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
premiere_ligne = stream.readline()
stream.seek(0)
for delim in (",", ";", "\t"):
nb = len(premiere_ligne.split(delim))
if nb > 1:
separateur = delim
break
else:
separateur = ","
csv_reader = csv.reader(stream, delimiter=separateur)
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))
# Normalisation des en-têtes : strip + suppression des quotes + lower
def _norm_header(h):
h = h.strip()
if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
h = h[1:-1]
return h.strip().lower()
header_norm = [_norm_header(h) for h in header]
# Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
alias = {
"date": "date",
"price": "price", "close": "price", "last": "price",
"adj_close": "price", "adjusted_close": "price", "adj. close": "price",
"open": "open",
"hight": "hight", "high": "hight",
"low": "low",
"vol": "vol", "vol.": "vol", "volume": "vol",
"change": "change", "change %": "change", "change_pct": "change",
"pct_change": "change", "change_percent": "change", "chg": "change",
}
# Construction du dictionnaire nom_canonique -> index
idx = {}
for i, h in enumerate(header_norm):
canon = alias.get(h)
if canon and canon not in idx:
idx[canon] = i
def col(row, name):
"""Récupère la valeur d'une colonne par son nom canonique."""
if name not in idx:
return None
i = idx[name]
return row[i] if i < len(row) else None
# Vérification : la colonne 'date' est obligatoire
if "date" not in idx:
flash(
f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
f"En-tête détecté : {','.join(header)}",
"danger",
)
return redirect(url_for("historique.gestion_import_historique_csv"))
# Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
insert = text(
"INSERT IGNORE INTO historique "
"(isin, date, price, `open`, hight, low, vol, `change`) "
"VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
)
count_insere = 0
count_ignore = 0
count_erreurs = 0
exemples_erreurs = []
for row in csv_reader:
date_raw = col(row, "date")
if not date_raw:
count_erreurs += 1
if len(exemples_erreurs) < 5:
exemples_erreurs.append(f"Date vide | ligne: {row}")
continue
# Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
# L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
# l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
date_str = date_raw.strip()[:10]
date_val = None
for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
try:
date_val = datetime.strptime(date_str, fmt).date()
break
except ValueError:
continue
if date_val is None:
count_erreurs += 1
if len(exemples_erreurs) < 10:
exemples_erreurs.append(
f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
)
continue
price = _to_decimal(col(row, "price"))
open_ = _to_decimal(col(row, "open"))
hight = _to_decimal(col(row, "hight"))
low = _to_decimal(col(row, "low"))
vol = _convertir_vol(col(row, "vol"))
change = _to_decimal(col(row, "change"))
result = db.session.execute(
insert,
{
"isin": isin,
"date": date_val,
"price": price,
"open": open_,
"hight": hight,
"low": low,
"vol": vol,
"change": change,
},
)
# rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
if result.rowcount == 1:
count_insere += 1
else:
count_ignore += 1
db.session.commit()
detail = ""
if exemples_erreurs:
detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
flash(
f"OK - Importation réussie pour {action.company_name} ({isin}) : "
f"{count_insere} ligne(s) insérée(s), "
f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
f"{count_erreurs} ligne(s) en erreur. "
f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
f"{detail}",
"success",
)
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for("historique.gestion_import_historique_csv"))