diff --git a/.dockerignore b/.dockerignore index 243f487..416617c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,6 +23,6 @@ ENV/ .DS_Store # Docker -Dockerfile -docker-compose.yml +#Dockerfile +#docker-compose.yml .dockerignore diff --git a/app/__init__.py b/app/__init__.py index cfad237..4f82bd2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -54,9 +54,28 @@ def create_app(): else: raise Exception("Impossible d'initialiser la base de données après plusieurs tentatives.") - # Enregistrement des routes (Blueprint) - from app.routes import main - app.register_blueprint(main) + # Enregistrement des routes (Blueprints) + from app.routes import ( + actions_bp, + analyse_bp, + auth_bp, + comptes_bp, + historique_bp, + menu_bp, + ordres_bp, + societes_bp, + utilisateurs_bp, + ) + + app.register_blueprint(auth_bp) + app.register_blueprint(menu_bp) + app.register_blueprint(utilisateurs_bp) + app.register_blueprint(actions_bp) + app.register_blueprint(societes_bp) + app.register_blueprint(historique_bp) + app.register_blueprint(analyse_bp) + app.register_blueprint(ordres_bp) + app.register_blueprint(comptes_bp) return app diff --git a/app/decorators.py b/app/decorators.py new file mode 100644 index 0000000..4e149dc --- /dev/null +++ b/app/decorators.py @@ -0,0 +1,13 @@ +from functools import wraps + +from flask import redirect, session, url_for + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if "user_id" not in session: + return redirect(url_for("auth.index")) + return f(*args, **kwargs) + + return decorated \ No newline at end of file diff --git a/app/routes.py b/app/routes.py deleted file mode 100644 index ddf0420..0000000 --- a/app/routes.py +++ /dev/null @@ -1,1651 +0,0 @@ -from functools import wraps - -from flask import ( - Blueprint, - render_template, - request, - redirect, - url_for, - session, - flash, - Response, - jsonify, - current_app, -) -import io -import csv -from datetime import datetime -from werkzeug.security import check_password_hash, generate_password_hash -from sqlalchemy import text -from app import db, csrf -from app import models # noqa: F401 (assure l'enregistrement des modèles ORM) - -main = Blueprint("main", __name__) - - -# --------------------------------------------------------------------------- -# Décorateur d'authentification -# --------------------------------------------------------------------------- -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if "user_id" not in session: - return redirect(url_for("main.index")) - return f(*args, **kwargs) - - return decorated - - -# --------------------------------------------------------------------------- -# Route pour la page d'accueil (connexion) -# --------------------------------------------------------------------------- -@main.route("/", methods=["GET", "POST"]) -def index(): - if "user_id" in session: - return redirect(url_for("main.menu")) - - if "login_attempts" not in session: - session["login_attempts"] = 0 - - if request.method == "POST": - user_login = request.form.get("login") - user_pass = request.form.get("pass") - - query = text( - "SELECT id, pass AS user_pass, nom, prenom FROM login " - "WHERE login = :login AND actif = 1" - ) - result = db.session.execute(query, {"login": user_login}).fetchone() - - if result and check_password_hash(result.user_pass, user_pass): - # Régénération de session : protection contre la fixation de session - session.clear() - session["user_id"] = result.id - session["user_name"] = f"{result.prenom} {result.nom}" - session["login_attempts"] = 0 - return redirect(url_for("main.menu")) - else: - session["login_attempts"] += 1 - essais = session["login_attempts"] - if essais >= 3: - session["login_attempts"] = 0 - flash( - "Trop de tentatives. Réessayez dans quelques instants.", - "danger", - ) - else: - flash( - f"Identifiant ou mot de passe incorrect. " - f"Il vous reste {3 - essais} essai(s).", - "danger", - ) - - return render_template("index.html") - - -# --------------------------------------------------------------------------- -# Déconnexion -# --------------------------------------------------------------------------- -@main.route("/logout") -def logout(): - session.clear() - return redirect(url_for("main.index")) - - -# --------------------------------------------------------------------------- -# Menu principal -# --------------------------------------------------------------------------- -@main.route("/menu") -@login_required -def menu(): - return render_template("menu.html") - - -# --------------------------------------------------------------------------- -# Gestion des utilisateurs -# --------------------------------------------------------------------------- -@main.route("/gestion-utilisateurs", methods=["GET", "POST"]) -@login_required -def gestion_utilisateurs(): - if request.method == "POST": - action = request.form.get("action") - user_id = request.form.get("id") - user_actif = request.form.get("actif", "1") - user_prenom = request.form.get("prenom") - user_nom = request.form.get("nom") - user_login = request.form.get("login") - user_mail = request.form.get("mail") - user_pass = request.form.get("pass") - - if action == "creer": - if user_pass: - hashed_pass = generate_password_hash(user_pass) - query = text( - "INSERT INTO login (login, nom, prenom, mail, pass, actif) " - "VALUES (:login, :nom, :prenom, :mail, :pass, :actif)" - ) - db.session.execute( - query, - { - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "pass": hashed_pass, - "actif": user_actif, - }, - ) - db.session.commit() - flash("Utilisateur créé avec succès.", "success") - else: - flash( - "Le mot de passe est obligatoire pour créer un utilisateur.", - "danger", - ) - - elif action == "modifier" and user_id: - if user_pass: - hashed_pass = generate_password_hash(user_pass) - query = text( - "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " - "mail = :mail, pass = :pass, actif = :actif WHERE id = :id" - ) - db.session.execute( - query, - { - "id": user_id, - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "pass": hashed_pass, - "actif": user_actif, - }, - ) - else: - query = text( - "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " - "mail = :mail, actif = :actif WHERE id = :id" - ) - db.session.execute( - query, - { - "id": user_id, - "login": user_login, - "nom": user_nom, - "prenom": user_prenom, - "mail": user_mail, - "actif": user_actif, - }, - ) - db.session.commit() - flash("Utilisateur modifié avec succès.", "success") - - return redirect(url_for("main.gestion_utilisateurs")) - - users_query = text( - "SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC" - ) - result = db.session.execute(users_query) - - users = [ - { - "id": row.id, - "login": row.login if row.login else "", - "nom": row.nom if row.nom else "", - "prenom": row.prenom if row.prenom else "", - "mail": row.mail if row.mail else "", - "actif": row.actif if row.actif is not None else 1, - } - for row in result - ] - - return render_template("gestion_utilisateurs.html", users=users) - - -# --------------------------------------------------------------------------- -# Gestion des actions -# --------------------------------------------------------------------------- -@main.route("/gestion-actions", methods=["GET", "POST"]) -@login_required -def gestion_actions(): - if request.method == "POST": - action_type = request.form.get("action") - action_id = request.form.get("id") - isin = request.form.get("isin") or None - ticker = request.form.get("ticker") - company_name = request.form.get("company_name") - exchange = request.form.get("exchange") - pays = request.form.get("pays") - currency = request.form.get("currency") - - if action_type == "creer": - try: - query = text( - "INSERT INTO actions (isin, ticker, company_name, exchange, " - "pays, currency, updated_at) " - "VALUES (:isin, :ticker, :company_name, :exchange, :pays, " - ":currency, NOW())" - ) - db.session.execute( - query, - { - "isin": isin, - "ticker": ticker, - "company_name": company_name, - "exchange": exchange, - "pays": pays, - "currency": currency, - }, - ) - db.session.commit() - flash("Action créée avec succès", "success") - except Exception: - db.session.rollback() - flash("Erreur lors de la création de l'action.", "danger") - - elif action_type == "modifier" and action_id: - try: - query = text( - "UPDATE actions SET isin = :isin, ticker = :ticker, " - "company_name = :company_name, exchange = :exchange, " - "pays = :pays, currency = :currency, updated_at = NOW() " - "WHERE id = :id" - ) - db.session.execute( - query, - { - "id": action_id, - "isin": isin, - "ticker": ticker, - "company_name": company_name, - "exchange": exchange, - "pays": pays, - "currency": currency, - }, - ) - db.session.commit() - flash("Modification effectuée", "success") - except Exception: - db.session.rollback() - flash("Erreur lors de la modification.", "danger") - - return redirect(url_for("main.gestion_actions")) - - actions_query = text( - "SELECT id, isin, ticker, company_name, exchange, pays, currency, " - "updated_at FROM actions ORDER BY company_name ASC" - ) - result = db.session.execute(actions_query) - - actions_list = [ - { - "id": row.id, - "isin": row.isin if row.isin else "", - "ticker": row.ticker if row.ticker else "", - "company_name": row.company_name if row.company_name else "Sans nom", - "exchange": row.exchange if row.exchange else "", - "pays": row.pays if row.pays else "", - "currency": row.currency if row.currency else "", - "updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S") - if row.updated_at - else "", - } - for row in result - ] - - return render_template("gestion_actions.html", actions=actions_list) - - -# --------------------------------------------------------------------------- -# Gestion des sociétés -# --------------------------------------------------------------------------- -@main.route("/gestion-societes", methods=["GET", "POST"]) -@login_required -def gestion_societes(): - if request.method == "POST": - action = request.form.get("action") - soc_id = request.form.get("id") - - nom = request.form.get("nom") - forme = request.form.get("forme") - capital = request.form.get("capital") - siren = request.form.get("siren") - siret = request.form.get("siret") - rcs = request.form.get("rcs") - tva = request.form.get("tva") - naf = request.form.get("naf") - tel = request.form.get("tel") - web = request.form.get("web") - mail = request.form.get("mail") - - if action == "creer": - try: - query = text( - "INSERT INTO societe (nom, forme, capital, siren, siret, rcs, " - "tva, naf, tel, web, mail) " - "VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, " - ":naf, :tel, :web, :mail)" - ) - db.session.execute( - query, - { - "nom": nom, - "forme": forme, - "capital": capital, - "siren": siren, - "siret": siret, - "rcs": rcs, - "tva": tva, - "naf": naf, - "tel": tel, - "web": web, - "mail": mail, - }, - ) - db.session.commit() - flash("Société créée avec succès.", "success") - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur création société : %s", e, exc_info=True) - flash("Erreur lors de la création de la société.", "danger") - - elif action == "modifier" and soc_id: - try: - query = text( - "UPDATE societe SET nom = :nom, forme = :forme, " - "capital = :capital, siren = :siren, siret = :siret, " - "rcs = :rcs, tva = :tva, naf = :naf, tel = :tel, web = :web, " - "mail = :mail WHERE id = :id" - ) - db.session.execute( - query, - { - "id": soc_id, - "nom": nom, - "forme": forme, - "capital": capital, - "siren": siren, - "siret": siret, - "rcs": rcs, - "tva": tva, - "naf": naf, - "tel": tel, - "web": web, - "mail": mail, - }, - ) - db.session.commit() - flash("Société modifiée avec succès.", "success") - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur modification société : %s", e, exc_info=True) - flash("Erreur lors de la modification de la société.", "danger") - - return redirect(url_for("main.gestion_societes")) - - societes_query = text( - "SELECT id, nom, forme, capital, siren, siret, rcs, tva, naf, tel, web, " - "mail FROM societe ORDER BY id ASC" - ) - result = db.session.execute(societes_query) - - societes = [ - { - "id": row.id, - "nom": row.nom if row.nom else "", - "forme": row.forme if row.forme else "", - "capital": row.capital if row.capital is not None else 0, - "siren": row.siren if row.siren is not None else "", - "siret": row.siret if row.siret is not None else "", - "rcs": row.rcs if row.rcs else "", - "tva": row.tva if row.tva else "", - "naf": row.naf if row.naf else "", - "tel": row.tel if row.tel else "", - "web": row.web if row.web else "", - "mail": row.mail if row.mail else "", - } - for row in result - ] - - return render_template("gestion_societes.html", societes=societes) - - -# --------------------------------------------------------------------------- -# Import / Export des actions CSV -# --------------------------------------------------------------------------- -@main.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") - - -@main.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("main.gestion_import_export_actions_csv")) - - -@main.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("main.gestion_import_export_actions_csv")) - - file = request.files["file"] - - if file.filename == "": - flash("Aucun fichier sélectionné.", "danger") - return redirect(url_for("main.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("main.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("main.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("main.gestion_import_export_actions_csv")) - - -# --------------------------------------------------------------------------- -# Import historique CSV (cotation d'une action) -# --------------------------------------------------------------------------- -@main.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 - - -@main.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("main.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("main.gestion_import_historique_csv")) - - if "file" not in request.files: - flash("Aucun fichier sélectionné.", "danger") - return redirect(url_for("main.gestion_import_historique_csv")) - - file = request.files["file"] - if file.filename == "": - flash("Aucun fichier sélectionné.", "danger") - return redirect(url_for("main.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("main.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("main.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("main.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("main.gestion_import_historique_csv")) - - -# --------------------------------------------------------------------------- -# Analyse technique -# --------------------------------------------------------------------------- -@main.route("/analyse", methods=["GET"]) -@login_required -def analyse(): - """Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...), - on l'analyse et on affiche la fiche complète ; sinon on affiche la page - de recherche.""" - isin = request.args.get("isin", "").strip().upper() - - if not isin: - return render_template("analyse.html", resultat=None) - - from app.services.analysis import analyse_action - - print(f"[analyse] ISIN demandé : {isin}", flush=True) - resultat = analyse_action(isin) - - if resultat is None: - # ISIN absent du référentiel actions - return render_template( - "analyse.html", - resultat={ - "isin": isin, - "action": None, - "erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. " - f"Vérifiez le code ou importez d'abord l'historique.", - }, - isin_recherche=isin, - ) - - if resultat.get("erreur"): - # ISIN présent mais historique insuffisant - print(f"[analyse] {isin} : {resultat['erreur']}", flush=True) - else: - print( - f"[analyse] {isin} : cours={resultat.get('cours')} " - f"score={resultat.get('score')} tendance={resultat.get('tendance')}", - flush=True, - ) - - return render_template("analyse.html", resultat=resultat, isin_recherche=isin) - - -@main.route("/analyse/", methods=["GET"]) -@login_required -def analyse_isin(isin): - """Page d'analyse technique complète pour un ISIN donné.""" - from app.services.analysis import analyse_action - - isin = isin.strip().upper() - resultat = analyse_action(isin) - return render_template("analyse.html", resultat=resultat, isin_recherche=isin) - - -@main.route("/api/analyse/", methods=["GET"]) -@login_required -def api_analyse(isin): - """API JSON retournant l'analyse technique complète d'un ISIN.""" - from app.services.analysis import analyse_action - - isin = isin.strip().upper() - resultat = analyse_action(isin) - if resultat is None: - return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404 - if resultat.get("erreur"): - return jsonify(resultat), 200 - return jsonify(resultat) - - -# --------------------------------------------------------------------------- -# Gestion des ordres -# --------------------------------------------------------------------------- -@main.route("/gestion_ordres", methods=["GET", "POST"]) -@login_required -def gestion_ordres(): - selected_societe_id = ( - request.form.get("societe_id") - or request.args.get("societe_id") - or session.get("selected_societe_id") - ) - - selected_annee = ( - request.form.get("annee") - or request.args.get("annee") - or session.get("selected_annee", "Tout") - ) - session["selected_annee"] = selected_annee - - societes = [] - selected_societe = "" - groupes_dict = {} - total_plus_value_globale = 0.0 - - with db.engine.connect() as connection: - result_soc = connection.execute( - text("SELECT id, nom FROM societe ORDER BY nom ASC") - ) - societes = [dict(row._mapping) for row in result_soc] - - if societes: - if not selected_societe_id: - selected_societe_id = societes[0]["id"] - else: - try: - selected_societe_id = int(selected_societe_id) - except (TypeError, ValueError): - selected_societe_id = societes[0]["id"] - - session["selected_societe_id"] = selected_societe_id - - for soc in societes: - if soc["id"] == selected_societe_id: - selected_societe = soc["nom"] - break - - # --- CALCUL DE LA PLUS-VALUE TOTALE --- - annee_str = str(selected_annee).strip().lower() - - if not annee_str or annee_str == "tout": - query_pv = text( - "SELECT SUM(plus_value) AS total_pv FROM ordre " - "WHERE id_societe = :soc_id AND plus_value IS NOT NULL" - ) - res_pv = connection.execute( - query_pv, {"soc_id": selected_societe_id} - ).fetchone() - else: - query_pv = text( - "SELECT SUM(plus_value) AS total_pv FROM ordre " - "WHERE id_societe = :soc_id AND plus_value IS NOT NULL " - "AND LEFT(CAST(date_plus_value AS CHAR), 4) = :annee" - ) - res_pv = connection.execute( - query_pv, {"soc_id": selected_societe_id, "annee": annee_str} - ).fetchone() - - if res_pv and res_pv.total_pv is not None: - total_plus_value_globale = float(res_pv.total_pv) - - # --- CHARGEMENT DES ORDRES --- - query = text( - "SELECT o.id AS ordre_id, o.isin, o.plus_value, o.date_plus_value, " - "op.id AS pied_id, op.date_op, op.quantite, op.prix_brut, " - "op.frais_broker, op.frais_etat, op.frais_autre, " - "op.sens, op.position, op.etat, " - "a.ticker, a.company_name, a.currency " - "FROM ordre o " - "JOIN ordre_pied op ON o.id = op.id_entete " - "JOIN actions a ON o.isin = a.isin " - "WHERE o.id_societe = :soc_id " - "ORDER BY o.id DESC, op.date_op DESC" - ) - - result_ordres = connection.execute( - query, {"soc_id": selected_societe_id} - ) - - for row in result_ordres: - r = dict(row._mapping) - ordre_id = r["ordre_id"] - isin = r["isin"] - - quantite = float(r["quantite"]) if r["quantite"] is not None else 0.0 - prix_brut = float(r["prix_brut"]) if r["prix_brut"] is not None else 0.0 - frais_broker = ( - float(r["frais_broker"]) if r["frais_broker"] is not None else 0.0 - ) - frais_etat = ( - float(r["frais_etat"]) if r["frais_etat"] is not None else 0.0 - ) - frais_autre = ( - float(r["frais_autre"]) if r["frais_autre"] is not None else 0.0 - ) - - frais_total = frais_broker + frais_etat + frais_autre - sens_ordre = str(r["sens"]).strip().lower() - type_ordre = str(r["position"]).strip().lower() if r["position"] else "long" - - # Prix Net unitaire - if sens_ordre == "vente": - prix_net = prix_brut - frais_total - else: - prix_net = prix_brut + frais_total - - # Engagements ligne - eng_brut = quantite * prix_brut - eng_net = quantite * prix_net - - pied_data = { - "id": r["pied_id"], - "date_op": r["date_op"], - "quantite": quantite, - "prix_brut": prix_brut, - "frais": frais_total, - "frais_broker": frais_broker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "prix_net": prix_net, - "eng_brut": eng_brut, - "eng_net": eng_net, - "ordre": r["sens"], - "type_ordre": r["position"], - "etat": r["etat"], - } - - if ordre_id not in groupes_dict: - groupes_dict[ordre_id] = { - "id": ordre_id, - "isin": isin, - "company_name": r["company_name"], - "ticker": r["ticker"], - "pieds": [], - "total_quantite": 0.0, - "total_prix_brut": 0.0, - "total_frais": 0.0, - "total_prix_net": 0.0, - "total_eng_brut": 0.0, - "total_eng_net": 0.0, - "plus_value": r["plus_value"], - "date_plusvalue": r["date_plus_value"], - } - - groupes_dict[ordre_id]["pieds"].append(pied_data) - groupes_dict[ordre_id]["total_prix_brut"] += prix_brut - groupes_dict[ordre_id]["total_frais"] += frais_total - groupes_dict[ordre_id]["total_prix_net"] += prix_net - - # Règles de totaux - if sens_ordre == "achat": - groupes_dict[ordre_id]["total_quantite"] += quantite - groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut - groupes_dict[ordre_id]["total_eng_net"] -= eng_net - elif sens_ordre == "vente": - groupes_dict[ordre_id]["total_quantite"] -= quantite - groupes_dict[ordre_id]["total_eng_brut"] += eng_brut - groupes_dict[ordre_id]["total_eng_net"] += eng_net - - # --- MISE À JOUR AUTO DE LA PLUS-VALUE (ordres soldés) --- - try: - with db.engine.begin() as conn: - for ordre_id, groupe in groupes_dict.items(): - if abs(groupe["total_quantite"]) < 1e-9: - total_eng_net = groupe["total_eng_net"] - max_date = max( - (p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None), - default=None, - ) - - conn.execute( - text( - "UPDATE ordre_pied SET etat = :nouvel_etat " - "WHERE id_entete = :ordre_id" - ), - {"nouvel_etat": "soldé", "ordre_id": ordre_id}, - ) - conn.execute( - text( - "UPDATE ordre SET plus_value = :plus_value, " - "date_plus_value = :date_pv WHERE id = :ordre_id" - ), - { - "plus_value": total_eng_net, - "date_pv": max_date, - "ordre_id": ordre_id, - }, - ) - - groupe["plus_value"] = total_eng_net - groupe["date_plusvalue"] = max_date - for pied in groupe["pieds"]: - pied["etat"] = "soldé" - except Exception as e: - current_app.logger.error("Erreur MAJ plus-value : %s", e, exc_info=True) - - groupes_ordres = list(groupes_dict.values()) - annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)] - - return render_template( - "gestion_ordres.html", - societes=societes, - selected_societe_id=selected_societe_id, - selected_societe=selected_societe, - groupes_ordres=groupes_ordres, - total_plus_value_globale=total_plus_value_globale, - annees_combo=annees_combo, - selected_annee=selected_annee, - ) - - -# --------------------------------------------------------------------------- -# Création d'ordre (la saisie se fait via la modale de gestion_ordres ; -# cette route est conservée pour compatibilité et redirige vers la liste) -# --------------------------------------------------------------------------- -@main.route("/creer-ordre", methods=["GET", "POST"]) -@login_required -def creer_ordre(): - return redirect(url_for("main.gestion_ordres")) - - -# --------------------------------------------------------------------------- -# API : vérifier l'existence d'un ISIN (JSON, exemptée de CSRF) -# --------------------------------------------------------------------------- -@main.route("/api/verifier_isin", methods=["POST"]) -@csrf.exempt -def verifier_isin(): - data = request.get_json(silent=True) or {} - isin = (data.get("isin") or "").strip() - - query = text( - "SELECT company_name, ticker, currency FROM actions WHERE isin = :isin" - ) - action = db.session.execute(query, {"isin": isin}).fetchone() - - if action: - return jsonify( - { - "exists": True, - "company_name": action.company_name, - "ticker": action.ticker, - "currency": action.currency, - } - ) - return jsonify({"exists": False}) - - -# --------------------------------------------------------------------------- -# Traitement de la création d'un ordre (appelé par la modale) -# --------------------------------------------------------------------------- -@main.route("/creer-ordre-traitement", methods=["POST"]) -@login_required -def creer_ordre_traitement(): - isin = request.form.get("isin") - date_op = request.form.get("date_op") - selected_annee = request.form.get("annee", "tout") - - try: - quantite = float(request.form.get("quantite") or 0) - prix_brut = float(request.form.get("prix_brut") or 0) - frais_broker = float(request.form.get("frais_broker") or 0) - frais_etat = float(request.form.get("frais_etat") or 0) - frais_autre = float(request.form.get("frais_autre") or 0) - except ValueError: - flash( - "Veuillez saisir des valeurs numériques valides pour les quantités, " - "prix et frais.", - "danger", - ) - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - - sens = request.form.get("ordre") - position = request.form.get("type_ordre") - etat = request.form.get("etat") - - id_societe = session.get("selected_societe_id") - if not id_societe: - flash( - "Veuillez sélectionner une société active avant de créer un ordre.", - "danger", - ) - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - - try: - query_ordre = text( - "INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)" - ) - db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin}) - - result_id = db.session.execute(text("SELECT LAST_INSERT_ID()")).scalar() - if not result_id: - result_id = db.session.execute( - text( - "SELECT id FROM ordre WHERE isin = :isin " - "ORDER BY id DESC LIMIT 1" - ), - {"isin": isin}, - ).scalar() - - query_pied = text( - "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " - "frais_broker, frais_etat, frais_autre, sens, position, etat) " - "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " - ":frais_etat, :frais_autre, :sens, :position, :etat)" - ) - db.session.execute( - query_pied, - { - "id_entete": result_id, - "date_op": date_op, - "quantite": quantite, - "prix_brut": prix_brut, - "frais_broker": frais_broker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "sens": sens, - "position": position, - "etat": etat, - }, - ) - - db.session.commit() - flash("Ordre créé avec succès !", "success") - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur création ordre : %s", e, exc_info=True) - flash("Erreur lors de la création de l'ordre.", "danger") - - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - - -# --------------------------------------------------------------------------- -# Ajout d'une ligne à un ordre existant (POST depuis la modale) -# --------------------------------------------------------------------------- -@main.route("/ordre//ajouter-ligne", methods=["GET", "POST"]) -@login_required -def ajouter_ligne_ordre(ordre_id): - selected_annee = request.args.get("annee", "tout") - - if request.method == "POST": - selected_annee = request.form.get("annee", "tout") - try: - quantite = float(request.form.get("quantite") or 0) - prix_brut = float(request.form.get("prix_brut") or 0) - frais_broker = float(request.form.get("frais_broker") or 0) - frais_etat = float(request.form.get("frais_etat") or 0) - frais_autre = float(request.form.get("frais_autre") or 0) - except ValueError: - flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect( - url_for( - "main.ajouter_ligne_ordre", - ordre_id=ordre_id, - annee=selected_annee, - ) - ) - - try: - query_pied = text( - "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " - "frais_broker, frais_etat, frais_autre, sens, position, etat) " - "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " - ":frais_etat, :frais_autre, :sens, :position, :etat)" - ) - db.session.execute( - query_pied, - { - "id_entete": ordre_id, - "date_op": request.form.get("date_op"), - "quantite": quantite, - "prix_brut": prix_brut, - "frais_broker": frais_broker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "sens": request.form.get("ordre"), - "position": request.form.get("type_ordre"), - "etat": request.form.get("etat"), - }, - ) - db.session.commit() - flash("Ligne ajoutée avec succès à l'ordre !", "success") - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True) - flash("Erreur lors de l'ajout de la ligne.", "danger") - - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - - -# --------------------------------------------------------------------------- -# Modification d'une ligne d'ordre (POST depuis la modale) -# --------------------------------------------------------------------------- -@main.route("/modifier-ligne-ordre/", methods=["GET", "POST"]) -@login_required -def modifier_ligne_ordre(pied_id): - selected_annee = request.args.get("annee", "tout") - - if request.method == "POST": - selected_annee = request.form.get("annee", "tout") - try: - quantite = float(request.form.get("quantite") or 0) - prix_brut = float(request.form.get("prix_brut") or 0) - frais_broker = float(request.form.get("frais_broker") or 0) - frais_etat = float(request.form.get("frais_etat") or 0) - frais_autre = float(request.form.get("frais_autre") or 0) - except ValueError: - flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect( - url_for( - "main.modifier_ligne_ordre", - pied_id=pied_id, - annee=selected_annee, - ) - ) - - try: - query_update = text( - "UPDATE ordre_pied SET date_op = :date_op, quantite = :quantite, " - "prix_brut = :prix_brut, frais_broker = :frais_broker, " - "frais_etat = :frais_etat, frais_autre = :frais_autre, " - "sens = :sens, position = :position, etat = :etat " - "WHERE id = :pied_id" - ) - db.session.execute( - query_update, - { - "date_op": request.form.get("date_op"), - "quantite": quantite, - "prix_brut": prix_brut, - "frais_broker": frais_broker, - "frais_etat": frais_etat, - "frais_autre": frais_autre, - "sens": request.form.get("ordre"), - "position": request.form.get("type_ordre"), - "etat": request.form.get("etat"), - "pied_id": pied_id, - }, - ) - db.session.commit() - flash("Ligne modifiée avec succès !", "success") - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True) - flash("Erreur lors de la modification de la ligne.", "danger") - - return redirect(url_for("main.gestion_ordres", annee=selected_annee)) - - -# --------------------------------------------------------------------------- -# Gestion des comptes -# --------------------------------------------------------------------------- -@main.route("/gestion_comptes", methods=["GET", "POST"]) -@login_required -def gestion_comptes(): - from app.models import Societe, Compte - - societes = Societe.query.order_by(Societe.nom.asc()).all() - - # La société sélectionnée vient du formulaire (changement manuel), - # ou d'un paramètre d'URL (redirection après création/modification), - # ou de la session (mémorisation entre navigations). - selected_societe_id = ( - request.form.get("societe_id") - or request.args.get("societe_id") - or session.get("selected_compte_societe_id") - ) - if not selected_societe_id and societes: - selected_societe_id = societes[0].id - else: - try: - selected_societe_id = int(selected_societe_id) if selected_societe_id else None - except ValueError: - selected_societe_id = societes[0].id if societes else None - - # Mémorisation en session : la société active ne change que manuellement - if selected_societe_id is not None: - session["selected_compte_societe_id"] = selected_societe_id - - selected_societe_nom = "" - ecritures = [] - total_credit = 0.0 - total_debit = 0.0 - # Agrégats financiers (voir règles de calcul dans la documentation) - tcredit = 0.0 # Depot : somme des crédits des écritures manuelles - tdebit = 0.0 # Retrait : somme des débits des écritures manuelles - engagement = 0.0 # ENGA. : positions actives (achat +, vente -) - pv = 0.0 # PV : positions soldées (achat -, vente +) - dispo = 0.0 # DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) - solde = 0.0 # SOLDE = DISPO + ENGA. - - if selected_societe_id: - societe_obj = Societe.query.get(selected_societe_id) - if societe_obj: - selected_societe_nom = societe_obj.nom - - # Tri par date décroissante (du plus récent au plus ancien) - ecritures = ( - Compte.query.filter_by(id_societe=selected_societe_id) - .order_by(Compte.date_operation.desc(), Compte.id.desc()) - .all() - ) - # Totaux bruts du tableau (toutes écritures confondues) - total_credit = sum(float(e.credit or 0) for e in ecritures) - total_debit = sum(float(e.debit or 0) for e in ecritures) - - # --- Dépôts / Retraits : uniquement les écritures manuelles --- - for e in ecritures: - if e.source == "manuel": - tcredit += float(e.credit or 0) - tdebit += float(e.debit or 0) - - # --- DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) --- - dispo = total_credit - total_debit - - # --- Engagement & PV : depuis les lignes d'ordres (ordre_pied) --- - # Règles : - # etat='actif' : achat -> engagement += qté*(prix_brut+frais) - # vente -> engagement -= qté*(prix_brut-frais) - # etat='soldé' : achat -> PV -= qté*(prix_brut+frais) - # vente -> PV += qté*(prix_brut-frais) - # etat='annulé' : ignoré (filtré en SQL) - # Filtres : société en cours + lignes non annulées - query_ordres = text( - "SELECT op.sens, op.etat, op.quantite, op.prix_brut, " - "op.frais_broker, op.frais_etat, op.frais_autre " - "FROM ordre o " - "JOIN ordre_pied op ON op.id_entete = o.id " - "WHERE o.id_societe = :soc_id " - "AND op.etat <> 'annulé'" - ) - for row in db.session.execute(query_ordres, {"soc_id": selected_societe_id}): - quantite = float(row.quantite or 0) - prix_brut = float(row.prix_brut or 0) - frais_broker = float(row.frais_broker or 0) - frais_etat = float(row.frais_etat or 0) - frais_autre = float(row.frais_autre or 0) - - # Achat : on paie le prix brut + les frais - montant_achat = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) - # Vente : on encaisse le prix brut - les frais - montant_vente = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) - - sens = (row.sens or "").strip().lower() - etat = (row.etat or "").strip().lower() - - if etat == "actif": - if sens == "achat": - engagement += montant_achat - elif sens == "vente": - engagement -= montant_vente - elif etat == "soldé" or etat == "solde": - if sens == "achat": - pv -= montant_achat - elif sens == "vente": - pv += montant_vente - # etat == 'annulé' : ne rien faire (déjà filtré en SQL) - - solde = dispo + engagement - - return render_template( - "gestion_comptes.html", - societes=societes, - selected_societe_id=selected_societe_id, - selected_societe=selected_societe_nom, - ecritures=ecritures, - total_credit=total_credit, - total_debit=total_debit, - tcredit=tcredit, - tdebit=tdebit, - engagement=engagement, - pv=pv, - dispo=dispo, - solde=solde, - ) - - -# --------------------------------------------------------------------------- -# Génération automatique des écritures de compte depuis les ordres -# --------------------------------------------------------------------------- -@main.route("/generer_ecritures_ordres", methods=["POST"]) -@login_required -def generer_ecritures_ordres(): - """Génère les écritures comptables (table `compte`) à partir des ordres - et de leurs lignes (`ordre_pied`) pour la société actuellement sélectionnée. - - Règles : - - achat : debit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), credit = 0 - - vente : credit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), debit = 0 - - libelle= "Achat/Vente actions {isin} {company_name}" - - source = 'ordre' (non modifiable depuis l'IHM) - Idempotent : supprime d'abord les écritures source='ordre' de la société. - """ - id_societe = request.form.get("id_societe") or session.get( - "selected_compte_societe_id" - ) - - if not id_societe: - flash("Aucune société sélectionnée pour la génération.", "danger") - return redirect(url_for("main.gestion_comptes")) - - try: - id_societe_int = int(id_societe) - except (TypeError, ValueError): - flash("Identifiant de société invalide.", "danger") - return redirect(url_for("main.gestion_comptes")) - - try: - # 1. Suppression des anciennes écritures générées pour cette société - db.session.execute( - text( - "DELETE FROM compte WHERE id_societe = :soc_id AND source = 'ordre'" - ), - {"soc_id": id_societe_int}, - ) - - # 2. Parcours des ordres de la société, puis de leurs lignes - # Filtres : société en cours + lignes non annulées - query = text( - "SELECT o.id, o.isin, a.company_name, " - "op.date_op, op.quantite, op.prix_brut, " - "op.frais_broker, op.frais_etat, op.frais_autre, op.sens " - "FROM ordre o " - "JOIN ordre_pied op ON op.id_entete = o.id " - "JOIN actions a ON o.isin = a.isin " - "WHERE o.id_societe = :soc_id " - "AND op.etat <> 'annulé' " - "ORDER BY o.id, op.date_op" - ) - lignes = db.session.execute(query, {"soc_id": id_societe_int}).fetchall() - - insert = text( - "INSERT INTO compte (id_societe, date_operation, debit, credit, " - "libelle, source) " - "VALUES (:id_societe, :date_op, :debit, :credit, :libelle, 'ordre')" - ) - - count = 0 - from markupsafe import escape - - for row in lignes: - quantite = float(row.quantite or 0) - prix_brut = float(row.prix_brut or 0) - frais_broker = float(row.frais_broker or 0) - frais_etat = float(row.frais_etat or 0) - frais_autre = float(row.frais_autre or 0) - - sens = (row.sens or "").strip().lower() - isin = row.isin or "" - # Nom de l'action échappé puis mis en gras (affichage |safe côté template) - company = f"{escape(row.company_name or '')}" - - if sens == "achat": - # Achat : on paie le prix brut + les frais - montant = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) - debit = montant - credit = 0.0 - libelle = f"Achat actions {isin} {company}".strip() - elif sens == "vente": - # Vente : on encaisse le prix brut - les frais - montant = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) - debit = 0.0 - credit = montant - libelle = f"Vente actions {isin} {company}".strip() - else: - # Sens inattendu : on ignore la ligne - continue - - db.session.execute( - insert, - { - "id_societe": id_societe_int, - "date_op": row.date_op, - "debit": debit, - "credit": credit, - "libelle": libelle, - }, - ) - count += 1 - - db.session.commit() - flash( - f"Génération terminée : {count} écriture(s) créée(s) pour " - f"la société.", - "success", - ) - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur génération écritures : %s", e, exc_info=True) - flash("Erreur lors de la génération des écritures.", "danger") - - return redirect(url_for("main.gestion_comptes", societe_id=id_societe_int)) - - -# --------------------------------------------------------------------------- -# Création d'une écriture comptable -# --------------------------------------------------------------------------- -@main.route("/creer_compte_traitement", methods=["POST"]) -@login_required -def creer_compte_traitement(): - id_societe = request.form.get("id_societe") - date_operation = request.form.get("date_operation") - libelle = request.form.get("libelle", "").strip() - debit = request.form.get("debit", "0") - credit = request.form.get("credit", "0") - - if not id_societe or not date_operation: - flash("Veuillez remplir tous les champs obligatoires.", "danger") - return redirect(url_for("main.gestion_comptes", societe_id=id_societe or "")) - - try: - debit_val = float(debit) if debit else 0.0 - credit_val = float(credit) if credit else 0.0 - id_societe_int = int(id_societe) - except ValueError: - flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect(url_for("main.gestion_comptes", societe_id=id_societe or "")) - - try: - db.session.execute( - text( - "INSERT INTO compte (id_societe, date_operation, libelle, " - "debit, credit) VALUES (:id_societe, :date_op, :libelle, :debit, :credit)" - ), - { - "id_societe": id_societe_int, - "date_op": date_operation, - "libelle": libelle or None, - "debit": debit_val, - "credit": credit_val, - }, - ) - db.session.commit() - flash("Écriture créée avec succès !", "success") - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur création écriture : %s", e, exc_info=True) - flash("Erreur lors de la création de l'écriture.", "danger") - - # On reste sur la même société après validation - return redirect(url_for("main.gestion_comptes", societe_id=id_societe_int)) - - -# --------------------------------------------------------------------------- -# Modification d'une écriture comptable -# --------------------------------------------------------------------------- -@main.route("/modifier_compte/", methods=["POST"]) -@login_required -def modifier_compte(id): - date_operation = request.form.get("date_operation") - libelle = request.form.get("libelle", "").strip() - debit = request.form.get("debit", "0") - credit = request.form.get("credit", "0") - - # Récupération de la société de l'écriture avant modification (pour la redirection) - id_societe = request.form.get("id_societe") - if not id_societe: - row = db.session.execute( - text("SELECT id_societe FROM compte WHERE id = :id"), {"id": id} - ).fetchone() - id_societe = str(row.id_societe) if row else "" - - if not date_operation: - flash("Veuillez remplir tous les champs obligatoires.", "danger") - return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) - - try: - debit_val = float(debit) if debit else 0.0 - credit_val = float(credit) if credit else 0.0 - except ValueError: - flash("Veuillez saisir des valeurs numériques valides.", "danger") - return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) - - try: - db.session.execute( - text( - "UPDATE compte SET date_operation = :date_op, " - "libelle = :libelle, debit = :debit, credit = :credit WHERE id = :id" - ), - { - "date_op": date_operation, - "libelle": libelle or None, - "debit": debit_val, - "credit": credit_val, - "id": id, - }, - ) - db.session.commit() - flash("Écriture modifiée avec succès !", "success") - except Exception as e: - db.session.rollback() - current_app.logger.error("Erreur modification écriture : %s", e, exc_info=True) - flash("Erreur lors de la modification de l'écriture.", "danger") - - # On reste sur la même société après validation - return redirect(url_for("main.gestion_comptes", societe_id=id_societe)) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..8a7feaa --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,21 @@ +from .actions import actions_bp +from .analyse import analyse_bp +from .auth import auth_bp +from .comptes import comptes_bp +from .historique import historique_bp +from .menu import menu_bp +from .ordres import ordres_bp +from .societes import societes_bp +from .utilisateurs import utilisateurs_bp + +__all__ = [ + "auth_bp", + "menu_bp", + "utilisateurs_bp", + "actions_bp", + "societes_bp", + "historique_bp", + "analyse_bp", + "ordres_bp", + "comptes_bp", +] \ No newline at end of file diff --git a/app/routes/actions.py b/app/routes/actions.py new file mode 100644 index 0000000..9f58147 --- /dev/null +++ b/app/routes/actions.py @@ -0,0 +1,98 @@ +from flask import Blueprint, flash, redirect, render_template, request, url_for +from sqlalchemy import text + +from app import db +from app.decorators import login_required + +actions_bp = Blueprint("actions", __name__) + + +@actions_bp.route("/gestion-actions", methods=["GET", "POST"]) +@login_required +def gestion_actions(): + if request.method == "POST": + action_type = request.form.get("action") + action_id = request.form.get("id") + isin = request.form.get("isin") or None + ticker = request.form.get("ticker") + company_name = request.form.get("company_name") + exchange = request.form.get("exchange") + pays = request.form.get("pays") + currency = request.form.get("currency") + + if action_type == "creer": + try: + query = text( + "INSERT INTO actions (isin, ticker, company_name, exchange, " + "pays, currency, updated_at) " + "VALUES (:isin, :ticker, :company_name, :exchange, :pays, " + ":currency, NOW())" + ) + db.session.execute( + query, + { + "isin": isin, + "ticker": ticker, + "company_name": company_name, + "exchange": exchange, + "pays": pays, + "currency": currency, + }, + ) + db.session.commit() + flash("Action créée avec succès", "success") + except Exception: + db.session.rollback() + flash("Erreur lors de la création de l'action.", "danger") + + elif action_type == "modifier" and action_id: + try: + query = text( + "UPDATE actions SET isin = :isin, ticker = :ticker, " + "company_name = :company_name, exchange = :exchange, " + "pays = :pays, currency = :currency, updated_at = NOW() " + "WHERE id = :id" + ) + db.session.execute( + query, + { + "id": action_id, + "isin": isin, + "ticker": ticker, + "company_name": company_name, + "exchange": exchange, + "pays": pays, + "currency": currency, + }, + ) + db.session.commit() + flash("Modification effectuée", "success") + except Exception: + db.session.rollback() + flash("Erreur lors de la modification.", "danger") + + return redirect(url_for("actions.gestion_actions")) + + actions_query = text( + "SELECT id, isin, ticker, company_name, exchange, pays, currency, " + "updated_at FROM actions ORDER BY company_name ASC" + ) + result = db.session.execute(actions_query) + + actions_list = [ + { + "id": row.id, + "isin": row.isin if row.isin else "", + "ticker": row.ticker if row.ticker else "", + "company_name": row.company_name if row.company_name else "Sans nom", + "exchange": row.exchange if row.exchange else "", + "pays": row.pays if row.pays else "", + "currency": row.currency if row.currency else "", + "updated_at": row.updated_at.strftime("%Y-%m-%d %H:%M:%S") + if row.updated_at + else "", + } + for row in result + ] + + return render_template("gestion_actions.html", actions=actions_list) \ No newline at end of file diff --git a/app/routes/analyse.py b/app/routes/analyse.py new file mode 100644 index 0000000..d0e1a65 --- /dev/null +++ b/app/routes/analyse.py @@ -0,0 +1,73 @@ +from flask import Blueprint, jsonify, render_template, request + +from app.decorators import login_required + +analyse_bp = Blueprint("analyse", __name__) + + +@analyse_bp.route("/analyse", methods=["GET"]) +@login_required +def analyse(): + """Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...), + on l'analyse et on affiche la fiche complète ; sinon on affiche la page + de recherche.""" + isin = request.args.get("isin", "").strip().upper() + + if not isin: + return render_template("analyse.html", resultat=None) + + from app.services.analysis import analyse_action + + print(f"[analyse] ISIN demandé : {isin}", flush=True) + resultat = analyse_action(isin) + + if resultat is None: + # ISIN absent du référentiel actions + return render_template( + "analyse.html", + resultat={ + "isin": isin, + "action": None, + "erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. " + f"Vérifiez le code ou importez d'abord l'historique.", + }, + isin_recherche=isin, + ) + + if resultat.get("erreur"): + # ISIN présent mais historique insuffisant + print(f"[analyse] {isin} : {resultat['erreur']}", flush=True) + else: + print( + f"[analyse] {isin} : cours={resultat.get('cours')} " + f"score={resultat.get('score')} tendance={resultat.get('tendance')}", + flush=True, + ) + + return render_template("analyse.html", resultat=resultat, isin_recherche=isin) + + +@analyse_bp.route("/analyse/", methods=["GET"]) +@login_required +def analyse_isin(isin): + """Page d'analyse technique complète pour un ISIN donné.""" + from app.services.analysis import analyse_action + + isin = isin.strip().upper() + resultat = analyse_action(isin) + return render_template("analyse.html", resultat=resultat, isin_recherche=isin) + + +@analyse_bp.route("/api/analyse/", methods=["GET"]) +@login_required +def api_analyse(isin): + """API JSON retournant l'analyse technique complète d'un ISIN.""" + from app.services.analysis import analyse_action + + isin = isin.strip().upper() + resultat = analyse_action(isin) + if resultat is None: + return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404 + if resultat.get("erreur"): + return jsonify(resultat), 200 + return jsonify(resultat) \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..84a32cd --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,57 @@ +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from sqlalchemy import text +from werkzeug.security import check_password_hash + +from app import db + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/", methods=["GET", "POST"]) +def index(): + if "user_id" in session: + return redirect(url_for("menu.menu")) + + if "login_attempts" not in session: + session["login_attempts"] = 0 + + if request.method == "POST": + user_login = request.form.get("login") + user_pass = request.form.get("pass") + + query = text( + "SELECT id, pass AS user_pass, nom, prenom FROM login " + "WHERE login = :login AND actif = 1" + ) + result = db.session.execute(query, {"login": user_login}).fetchone() + + if result and check_password_hash(result.user_pass, user_pass): + # Régénération de session : protection contre la fixation de session + session.clear() + session["user_id"] = result.id + session["user_name"] = f"{result.prenom} {result.nom}" + session["login_attempts"] = 0 + return redirect(url_for("menu.menu")) + else: + session["login_attempts"] += 1 + essais = session["login_attempts"] + if essais >= 3: + session["login_attempts"] = 0 + flash( + "Trop de tentatives. Réessayez dans quelques instants.", + "danger", + ) + else: + flash( + f"Identifiant ou mot de passe incorrect. " + f"Il vous reste {3 - essais} essai(s).", + "danger", + ) + + return render_template("index.html") + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("auth.index")) \ No newline at end of file diff --git a/app/routes/comptes.py b/app/routes/comptes.py new file mode 100644 index 0000000..6c57862 --- /dev/null +++ b/app/routes/comptes.py @@ -0,0 +1,365 @@ +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from sqlalchemy import text + +from app import db +from app.decorators import login_required + +comptes_bp = Blueprint("comptes", __name__) + + +# --------------------------------------------------------------------------- +# Gestion des comptes +# --------------------------------------------------------------------------- +@comptes_bp.route("/gestion_comptes", methods=["GET", "POST"]) +@login_required +def gestion_comptes(): + from app.models import Societe, Compte + + societes = Societe.query.order_by(Societe.nom.asc()).all() + + # La société sélectionnée vient du formulaire (changement manuel), + # ou d'un paramètre d'URL (redirection après création/modification), + # ou de la session (mémorisation entre navigations). + selected_societe_id = ( + request.form.get("societe_id") + or request.args.get("societe_id") + or session.get("selected_compte_societe_id") + ) + if not selected_societe_id and societes: + selected_societe_id = societes[0].id + else: + try: + selected_societe_id = int(selected_societe_id) if selected_societe_id else None + except ValueError: + selected_societe_id = societes[0].id if societes else None + + # Mémorisation en session : la société active ne change que manuellement + if selected_societe_id is not None: + session["selected_compte_societe_id"] = selected_societe_id + + selected_societe_nom = "" + ecritures = [] + total_credit = 0.0 + total_debit = 0.0 + # Agrégats financiers (voir règles de calcul dans la documentation) + tcredit = 0.0 # Depot : somme des crédits des écritures manuelles + tdebit = 0.0 # Retrait : somme des débits des écritures manuelles + engagement = 0.0 # ENGA. : positions actives (achat +, vente -) + pv = 0.0 # PV : positions soldées (achat -, vente +) + dispo = 0.0 # DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) + solde = 0.0 # SOLDE = DISPO + ENGA. + + if selected_societe_id: + societe_obj = Societe.query.get(selected_societe_id) + if societe_obj: + selected_societe_nom = societe_obj.nom + + # Tri par date décroissante (du plus récent au plus ancien) + ecritures = ( + Compte.query.filter_by(id_societe=selected_societe_id) + .order_by(Compte.date_operation.desc(), Compte.id.desc()) + .all() + ) + # Totaux bruts du tableau (toutes écritures confondues) + total_credit = sum(float(e.credit or 0) for e in ecritures) + total_debit = sum(float(e.debit or 0) for e in ecritures) + + # --- Dépôts / Retraits : uniquement les écritures manuelles --- + for e in ecritures: + if e.source == "manuel": + tcredit += float(e.credit or 0) + tdebit += float(e.debit or 0) + + # --- DISPO = TOTAL CREDIT - TOTAL DEBIT (toutes écritures) --- + dispo = total_credit - total_debit + + # --- Engagement & PV : depuis les lignes d'ordres (ordre_pied) --- + # Règles : + # etat='actif' : achat -> engagement += qté*(prix_brut+frais) + # vente -> engagement -= qté*(prix_brut-frais) + # etat='soldé' : achat -> PV -= qté*(prix_brut+frais) + # vente -> PV += qté*(prix_brut-frais) + # etat='annulé' : ignoré (filtré en SQL) + # Filtres : société en cours + lignes non annulées + query_ordres = text( + "SELECT op.sens, op.etat, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre " + "FROM ordre o " + "JOIN ordre_pied op ON op.id_entete = o.id " + "WHERE o.id_societe = :soc_id " + "AND op.etat <> 'annulé'" + ) + for row in db.session.execute(query_ordres, {"soc_id": selected_societe_id}): + quantite = float(row.quantite or 0) + prix_brut = float(row.prix_brut or 0) + frais_broker = float(row.frais_broker or 0) + frais_etat = float(row.frais_etat or 0) + frais_autre = float(row.frais_autre or 0) + + # Achat : on paie le prix brut + les frais + montant_achat = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) + # Vente : on encaisse le prix brut - les frais + montant_vente = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) + + sens = (row.sens or "").strip().lower() + etat = (row.etat or "").strip().lower() + + if etat == "actif": + if sens == "achat": + engagement += montant_achat + elif sens == "vente": + engagement -= montant_vente + elif etat == "soldé" or etat == "solde": + if sens == "achat": + pv -= montant_achat + elif sens == "vente": + pv += montant_vente + # etat == 'annulé' : ne rien faire (déjà filtré en SQL) + + solde = dispo + engagement + + return render_template( + "gestion_comptes.html", + societes=societes, + selected_societe_id=selected_societe_id, + selected_societe=selected_societe_nom, + ecritures=ecritures, + total_credit=total_credit, + total_debit=total_debit, + tcredit=tcredit, + tdebit=tdebit, + engagement=engagement, + pv=pv, + dispo=dispo, + solde=solde, + ) + + +# --------------------------------------------------------------------------- +# Génération automatique des écritures de compte depuis les ordres +# --------------------------------------------------------------------------- +@comptes_bp.route("/generer_ecritures_ordres", methods=["POST"]) +@login_required +def generer_ecritures_ordres(): + """Génère les écritures comptables (table `compte`) à partir des ordres + et de leurs lignes (`ordre_pied`) pour la société actuellement sélectionnée. + + Règles : + - achat : debit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), credit = 0 + - vente : credit = quantite * (prix_brut + frais_broker + frais_etat + frais_autre), debit = 0 + - libelle= "Achat/Vente actions {isin} {company_name}" + - source = 'ordre' (non modifiable depuis l'IHM) + Idempotent : supprime d'abord les écritures source='ordre' de la société. + """ + id_societe = request.form.get("id_societe") or session.get( + "selected_compte_societe_id" + ) + + if not id_societe: + flash("Aucune société sélectionnée pour la génération.", "danger") + return redirect(url_for("comptes.gestion_comptes")) + + try: + id_societe_int = int(id_societe) + except (TypeError, ValueError): + flash("Identifiant de société invalide.", "danger") + return redirect(url_for("comptes.gestion_comptes")) + + try: + # 1. Suppression des anciennes écritures générées pour cette société + db.session.execute( + text( + "DELETE FROM compte WHERE id_societe = :soc_id AND source = 'ordre'" + ), + {"soc_id": id_societe_int}, + ) + + # 2. Parcours des ordres de la société, puis de leurs lignes + # Filtres : société en cours + lignes non annulées + query = text( + "SELECT o.id, o.isin, a.company_name, " + "op.date_op, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre, op.sens " + "FROM ordre o " + "JOIN ordre_pied op ON op.id_entete = o.id " + "JOIN actions a ON o.isin = a.isin " + "WHERE o.id_societe = :soc_id " + "AND op.etat <> 'annulé' " + "ORDER BY o.id, op.date_op" + ) + lignes = db.session.execute(query, {"soc_id": id_societe_int}).fetchall() + + insert = text( + "INSERT INTO compte (id_societe, date_operation, debit, credit, " + "libelle, source) " + "VALUES (:id_societe, :date_op, :debit, :credit, :libelle, 'ordre')" + ) + + count = 0 + from markupsafe import escape + + for row in lignes: + quantite = float(row.quantite or 0) + prix_brut = float(row.prix_brut or 0) + frais_broker = float(row.frais_broker or 0) + frais_etat = float(row.frais_etat or 0) + frais_autre = float(row.frais_autre or 0) + + sens = (row.sens or "").strip().lower() + isin = row.isin or "" + # Nom de l'action échappé puis mis en gras (affichage |safe côté template) + company = f"{escape(row.company_name or '')}" + + if sens == "achat": + # Achat : on paie le prix brut + les frais + montant = quantite * (prix_brut + frais_broker + frais_etat + frais_autre) + debit = montant + credit = 0.0 + libelle = f"Achat actions {isin} {company}".strip() + elif sens == "vente": + # Vente : on encaisse le prix brut - les frais + montant = quantite * (prix_brut - frais_broker - frais_etat - frais_autre) + debit = 0.0 + credit = montant + libelle = f"Vente actions {isin} {company}".strip() + else: + # Sens inattendu : on ignore la ligne + continue + + db.session.execute( + insert, + { + "id_societe": id_societe_int, + "date_op": row.date_op, + "debit": debit, + "credit": credit, + "libelle": libelle, + }, + ) + count += 1 + + db.session.commit() + flash( + f"Génération terminée : {count} écriture(s) créée(s) pour " + f"la société.", + "success", + ) + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur génération écritures : %s", e, exc_info=True) + flash("Erreur lors de la génération des écritures.", "danger") + + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe_int)) + + +# --------------------------------------------------------------------------- +# Création d'une écriture comptable +# --------------------------------------------------------------------------- +@comptes_bp.route("/creer_compte_traitement", methods=["POST"]) +@login_required +def creer_compte_traitement(): + id_societe = request.form.get("id_societe") + date_operation = request.form.get("date_operation") + libelle = request.form.get("libelle", "").strip() + debit = request.form.get("debit", "0") + credit = request.form.get("credit", "0") + + if not id_societe or not date_operation: + flash("Veuillez remplir tous les champs obligatoires.", "danger") + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe or "")) + + try: + debit_val = float(debit) if debit else 0.0 + credit_val = float(credit) if credit else 0.0 + id_societe_int = int(id_societe) + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe or "")) + + try: + db.session.execute( + text( + "INSERT INTO compte (id_societe, date_operation, libelle, " + "debit, credit) VALUES (:id_societe, :date_op, :libelle, :debit, :credit)" + ), + { + "id_societe": id_societe_int, + "date_op": date_operation, + "libelle": libelle or None, + "debit": debit_val, + "credit": credit_val, + }, + ) + db.session.commit() + flash("Écriture créée avec succès !", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur création écriture : %s", e, exc_info=True) + flash("Erreur lors de la création de l'écriture.", "danger") + + # On reste sur la même société après validation + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe_int)) + + +# --------------------------------------------------------------------------- +# Modification d'une écriture comptable +# --------------------------------------------------------------------------- +@comptes_bp.route("/modifier_compte/", methods=["POST"]) +@login_required +def modifier_compte(id): + date_operation = request.form.get("date_operation") + libelle = request.form.get("libelle", "").strip() + debit = request.form.get("debit", "0") + credit = request.form.get("credit", "0") + + # Récupération de la société de l'écriture avant modification (pour la redirection) + id_societe = request.form.get("id_societe") + if not id_societe: + row = db.session.execute( + text("SELECT id_societe FROM compte WHERE id = :id"), {"id": id} + ).fetchone() + id_societe = str(row.id_societe) if row else "" + + if not date_operation: + flash("Veuillez remplir tous les champs obligatoires.", "danger") + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe)) + + try: + debit_val = float(debit) if debit else 0.0 + credit_val = float(credit) if credit else 0.0 + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe)) + + try: + db.session.execute( + text( + "UPDATE compte SET date_operation = :date_op, " + "libelle = :libelle, debit = :debit, credit = :credit WHERE id = :id" + ), + { + "date_op": date_operation, + "libelle": libelle or None, + "debit": debit_val, + "credit": credit_val, + "id": id, + }, + ) + db.session.commit() + flash("Écriture modifiée avec succès !", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur modification écriture : %s", e, exc_info=True) + flash("Erreur lors de la modification de l'écriture.", "danger") + + # On reste sur la même société après validation + return redirect(url_for("comptes.gestion_comptes", societe_id=id_societe)) \ No newline at end of file diff --git a/app/routes/historique.py b/app/routes/historique.py new file mode 100644 index 0000000..2e6046e --- /dev/null +++ b/app/routes/historique.py @@ -0,0 +1,381 @@ +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")) \ No newline at end of file diff --git a/app/routes/menu.py b/app/routes/menu.py new file mode 100644 index 0000000..0b7381a --- /dev/null +++ b/app/routes/menu.py @@ -0,0 +1,11 @@ +from flask import Blueprint, render_template + +from app.decorators import login_required + +menu_bp = Blueprint("menu", __name__) + + +@menu_bp.route("/menu") +@login_required +def menu(): + return render_template("menu.html") \ No newline at end of file diff --git a/app/routes/ordres.py b/app/routes/ordres.py new file mode 100644 index 0000000..ef7e40c --- /dev/null +++ b/app/routes/ordres.py @@ -0,0 +1,476 @@ +from flask import ( + Blueprint, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from sqlalchemy import text + +from app import csrf, db +from app.decorators import login_required + +ordres_bp = Blueprint("ordres", __name__) + + +# --------------------------------------------------------------------------- +# Gestion des ordres +# --------------------------------------------------------------------------- +@ordres_bp.route("/gestion_ordres", methods=["GET", "POST"]) +@login_required +def gestion_ordres(): + selected_societe_id = ( + request.form.get("societe_id") + or request.args.get("societe_id") + or session.get("selected_societe_id") + ) + + selected_annee = ( + request.form.get("annee") + or request.args.get("annee") + or session.get("selected_annee", "Tout") + ) + session["selected_annee"] = selected_annee + + societes = [] + selected_societe = "" + groupes_dict = {} + total_plus_value_globale = 0.0 + + with db.engine.connect() as connection: + result_soc = connection.execute( + text("SELECT id, nom FROM societe ORDER BY nom ASC") + ) + societes = [dict(row._mapping) for row in result_soc] + + if societes: + if not selected_societe_id: + selected_societe_id = societes[0]["id"] + else: + try: + selected_societe_id = int(selected_societe_id) + except (TypeError, ValueError): + selected_societe_id = societes[0]["id"] + + session["selected_societe_id"] = selected_societe_id + + for soc in societes: + if soc["id"] == selected_societe_id: + selected_societe = soc["nom"] + break + + # --- CALCUL DE LA PLUS-VALUE TOTALE --- + annee_str = str(selected_annee).strip().lower() + + if not annee_str or annee_str == "tout": + query_pv = text( + "SELECT SUM(plus_value) AS total_pv FROM ordre " + "WHERE id_societe = :soc_id AND plus_value IS NOT NULL" + ) + res_pv = connection.execute( + query_pv, {"soc_id": selected_societe_id} + ).fetchone() + else: + query_pv = text( + "SELECT SUM(plus_value) AS total_pv FROM ordre " + "WHERE id_societe = :soc_id AND plus_value IS NOT NULL " + "AND LEFT(CAST(date_plus_value AS CHAR), 4) = :annee" + ) + res_pv = connection.execute( + query_pv, {"soc_id": selected_societe_id, "annee": annee_str} + ).fetchone() + + if res_pv and res_pv.total_pv is not None: + total_plus_value_globale = float(res_pv.total_pv) + + # --- CHARGEMENT DES ORDRES --- + query = text( + "SELECT o.id AS ordre_id, o.isin, o.plus_value, o.date_plus_value, " + "op.id AS pied_id, op.date_op, op.quantite, op.prix_brut, " + "op.frais_broker, op.frais_etat, op.frais_autre, " + "op.sens, op.position, op.etat, " + "a.ticker, a.company_name, a.currency " + "FROM ordre o " + "JOIN ordre_pied op ON o.id = op.id_entete " + "JOIN actions a ON o.isin = a.isin " + "WHERE o.id_societe = :soc_id " + "ORDER BY o.id DESC, op.date_op DESC" + ) + + result_ordres = connection.execute( + query, {"soc_id": selected_societe_id} + ) + + for row in result_ordres: + r = dict(row._mapping) + ordre_id = r["ordre_id"] + isin = r["isin"] + + quantite = float(r["quantite"]) if r["quantite"] is not None else 0.0 + prix_brut = float(r["prix_brut"]) if r["prix_brut"] is not None else 0.0 + frais_broker = ( + float(r["frais_broker"]) if r["frais_broker"] is not None else 0.0 + ) + frais_etat = ( + float(r["frais_etat"]) if r["frais_etat"] is not None else 0.0 + ) + frais_autre = ( + float(r["frais_autre"]) if r["frais_autre"] is not None else 0.0 + ) + + frais_total = frais_broker + frais_etat + frais_autre + sens_ordre = str(r["sens"]).strip().lower() + type_ordre = str(r["position"]).strip().lower() if r["position"] else "long" + + # Prix Net unitaire + if sens_ordre == "vente": + prix_net = prix_brut - frais_total + else: + prix_net = prix_brut + frais_total + + # Engagements ligne + eng_brut = quantite * prix_brut + eng_net = quantite * prix_net + + pied_data = { + "id": r["pied_id"], + "date_op": r["date_op"], + "quantite": quantite, + "prix_brut": prix_brut, + "frais": frais_total, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "prix_net": prix_net, + "eng_brut": eng_brut, + "eng_net": eng_net, + "ordre": r["sens"], + "type_ordre": r["position"], + "etat": r["etat"], + } + + if ordre_id not in groupes_dict: + groupes_dict[ordre_id] = { + "id": ordre_id, + "isin": isin, + "company_name": r["company_name"], + "ticker": r["ticker"], + "pieds": [], + "total_quantite": 0.0, + "total_prix_brut": 0.0, + "total_frais": 0.0, + "total_prix_net": 0.0, + "total_eng_brut": 0.0, + "total_eng_net": 0.0, + "plus_value": r["plus_value"], + "date_plusvalue": r["date_plus_value"], + } + + groupes_dict[ordre_id]["pieds"].append(pied_data) + groupes_dict[ordre_id]["total_prix_brut"] += prix_brut + groupes_dict[ordre_id]["total_frais"] += frais_total + groupes_dict[ordre_id]["total_prix_net"] += prix_net + + # Règles de totaux + if sens_ordre == "achat": + groupes_dict[ordre_id]["total_quantite"] += quantite + groupes_dict[ordre_id]["total_eng_brut"] -= eng_brut + groupes_dict[ordre_id]["total_eng_net"] -= eng_net + elif sens_ordre == "vente": + groupes_dict[ordre_id]["total_quantite"] -= quantite + groupes_dict[ordre_id]["total_eng_brut"] += eng_brut + groupes_dict[ordre_id]["total_eng_net"] += eng_net + + # --- MISE À JOUR AUTO DE LA PLUS-VALUE (ordres soldés) --- + try: + with db.engine.begin() as conn: + for ordre_id, groupe in groupes_dict.items(): + if abs(groupe["total_quantite"]) < 1e-9: + total_eng_net = groupe["total_eng_net"] + max_date = max( + (p["date_op"] for p in groupe["pieds"] if p["date_op"] is not None), + default=None, + ) + + conn.execute( + text( + "UPDATE ordre_pied SET etat = :nouvel_etat " + "WHERE id_entete = :ordre_id" + ), + {"nouvel_etat": "soldé", "ordre_id": ordre_id}, + ) + conn.execute( + text( + "UPDATE ordre SET plus_value = :plus_value, " + "date_plus_value = :date_pv WHERE id = :ordre_id" + ), + { + "plus_value": total_eng_net, + "date_pv": max_date, + "ordre_id": ordre_id, + }, + ) + + groupe["plus_value"] = total_eng_net + groupe["date_plusvalue"] = max_date + for pied in groupe["pieds"]: + pied["etat"] = "soldé" + except Exception as e: + current_app.logger.error("Erreur MAJ plus-value : %s", e, exc_info=True) + + groupes_ordres = list(groupes_dict.values()) + annees_combo = ["Tout"] + [str(an) for an in range(2020, 2041)] + + return render_template( + "gestion_ordres.html", + societes=societes, + selected_societe_id=selected_societe_id, + selected_societe=selected_societe, + groupes_ordres=groupes_ordres, + total_plus_value_globale=total_plus_value_globale, + annees_combo=annees_combo, + selected_annee=selected_annee, + ) + + +# --------------------------------------------------------------------------- +# Création d'ordre (la saisie se fait via la modale de gestion_ordres ; +# cette route est conservée pour compatibilité et redirige vers la liste) +# --------------------------------------------------------------------------- +@ordres_bp.route("/creer-ordre", methods=["GET", "POST"]) +@login_required +def creer_ordre(): + return redirect(url_for("ordres.gestion_ordres")) + + +# --------------------------------------------------------------------------- +# API : vérifier l'existence d'un ISIN (JSON, exemptée de CSRF) +# --------------------------------------------------------------------------- +@ordres_bp.route("/api/verifier_isin", methods=["POST"]) +@csrf.exempt +def verifier_isin(): + data = request.get_json(silent=True) or {} + isin = (data.get("isin") or "").strip() + + query = text( + "SELECT company_name, ticker, currency FROM actions WHERE isin = :isin" + ) + action = db.session.execute(query, {"isin": isin}).fetchone() + + if action: + return jsonify( + { + "exists": True, + "company_name": action.company_name, + "ticker": action.ticker, + "currency": action.currency, + } + ) + return jsonify({"exists": False}) + + +# --------------------------------------------------------------------------- +# Traitement de la création d'un ordre (appelé par la modale) +# --------------------------------------------------------------------------- +@ordres_bp.route("/creer-ordre-traitement", methods=["POST"]) +@login_required +def creer_ordre_traitement(): + isin = request.form.get("isin") + date_op = request.form.get("date_op") + selected_annee = request.form.get("annee", "tout") + + try: + quantite = float(request.form.get("quantite") or 0) + prix_brut = float(request.form.get("prix_brut") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) + frais_etat = float(request.form.get("frais_etat") or 0) + frais_autre = float(request.form.get("frais_autre") or 0) + except ValueError: + flash( + "Veuillez saisir des valeurs numériques valides pour les quantités, " + "prix et frais.", + "danger", + ) + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + + sens = request.form.get("ordre") + position = request.form.get("type_ordre") + etat = request.form.get("etat") + + id_societe = session.get("selected_societe_id") + if not id_societe: + flash( + "Veuillez sélectionner une société active avant de créer un ordre.", + "danger", + ) + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + + try: + query_ordre = text( + "INSERT INTO ordre (id_societe, isin) VALUES (:id_societe, :isin)" + ) + db.session.execute(query_ordre, {"id_societe": id_societe, "isin": isin}) + + result_id = db.session.execute(text("SELECT LAST_INSERT_ID()")).scalar() + if not result_id: + result_id = db.session.execute( + text( + "SELECT id FROM ordre WHERE isin = :isin " + "ORDER BY id DESC LIMIT 1" + ), + {"isin": isin}, + ).scalar() + + query_pied = text( + "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " + "frais_broker, frais_etat, frais_autre, sens, position, etat) " + "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " + ":frais_etat, :frais_autre, :sens, :position, :etat)" + ) + db.session.execute( + query_pied, + { + "id_entete": result_id, + "date_op": date_op, + "quantite": quantite, + "prix_brut": prix_brut, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "sens": sens, + "position": position, + "etat": etat, + }, + ) + + db.session.commit() + flash("Ordre créé avec succès !", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur création ordre : %s", e, exc_info=True) + flash("Erreur lors de la création de l'ordre.", "danger") + + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + + +# --------------------------------------------------------------------------- +# Ajout d'une ligne à un ordre existant (POST depuis la modale) +# --------------------------------------------------------------------------- +@ordres_bp.route("/ordre//ajouter-ligne", methods=["GET", "POST"]) +@login_required +def ajouter_ligne_ordre(ordre_id): + selected_annee = request.args.get("annee", "tout") + + if request.method == "POST": + selected_annee = request.form.get("annee", "tout") + try: + quantite = float(request.form.get("quantite") or 0) + prix_brut = float(request.form.get("prix_brut") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) + frais_etat = float(request.form.get("frais_etat") or 0) + frais_autre = float(request.form.get("frais_autre") or 0) + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect( + url_for( + "ordres.ajouter_ligne_ordre", + ordre_id=ordre_id, + annee=selected_annee, + ) + ) + + try: + query_pied = text( + "INSERT INTO ordre_pied (id_entete, date_op, quantite, prix_brut, " + "frais_broker, frais_etat, frais_autre, sens, position, etat) " + "VALUES (:id_entete, :date_op, :quantite, :prix_brut, :frais_broker, " + ":frais_etat, :frais_autre, :sens, :position, :etat)" + ) + db.session.execute( + query_pied, + { + "id_entete": ordre_id, + "date_op": request.form.get("date_op"), + "quantite": quantite, + "prix_brut": prix_brut, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "sens": request.form.get("ordre"), + "position": request.form.get("type_ordre"), + "etat": request.form.get("etat"), + }, + ) + db.session.commit() + flash("Ligne ajoutée avec succès à l'ordre !", "success") + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur ajout ligne : %s", e, exc_info=True) + flash("Erreur lors de l'ajout de la ligne.", "danger") + + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + + +# --------------------------------------------------------------------------- +# Modification d'une ligne d'ordre (POST depuis la modale) +# --------------------------------------------------------------------------- +@ordres_bp.route("/modifier-ligne-ordre/", methods=["GET", "POST"]) +@login_required +def modifier_ligne_ordre(pied_id): + selected_annee = request.args.get("annee", "tout") + + if request.method == "POST": + selected_annee = request.form.get("annee", "tout") + try: + quantite = float(request.form.get("quantite") or 0) + prix_brut = float(request.form.get("prix_brut") or 0) + frais_broker = float(request.form.get("frais_broker") or 0) + frais_etat = float(request.form.get("frais_etat") or 0) + frais_autre = float(request.form.get("frais_autre") or 0) + except ValueError: + flash("Veuillez saisir des valeurs numériques valides.", "danger") + return redirect( + url_for( + "ordres.modifier_ligne_ordre", + pied_id=pied_id, + annee=selected_annee, + ) + ) + + try: + query_update = text( + "UPDATE ordre_pied SET date_op = :date_op, quantite = :quantite, " + "prix_brut = :prix_brut, frais_broker = :frais_broker, " + "frais_etat = :frais_etat, frais_autre = :frais_autre, " + "sens = :sens, position = :position, etat = :etat " + "WHERE id = :pied_id" + ) + db.session.execute( + query_update, + { + "date_op": request.form.get("date_op"), + "quantite": quantite, + "prix_brut": prix_brut, + "frais_broker": frais_broker, + "frais_etat": frais_etat, + "frais_autre": frais_autre, + "sens": request.form.get("ordre"), + "position": request.form.get("type_ordre"), + "etat": request.form.get("etat"), + "pied_id": pied_id, + }, + ) + db.session.commit() + flash("Ligne modifiée avec succès !", "success") + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur modification ligne : %s", e, exc_info=True) + flash("Erreur lors de la modification de la ligne.", "danger") + + return redirect(url_for("ordres.gestion_ordres", annee=selected_annee)) \ No newline at end of file diff --git a/app/routes/societes.py b/app/routes/societes.py new file mode 100644 index 0000000..6a94060 --- /dev/null +++ b/app/routes/societes.py @@ -0,0 +1,118 @@ +from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for +from sqlalchemy import text + +from app import db +from app.decorators import login_required + +societes_bp = Blueprint("societes", __name__) + + +@societes_bp.route("/gestion-societes", methods=["GET", "POST"]) +@login_required +def gestion_societes(): + if request.method == "POST": + action = request.form.get("action") + soc_id = request.form.get("id") + + nom = request.form.get("nom") + forme = request.form.get("forme") + capital = request.form.get("capital") + siren = request.form.get("siren") + siret = request.form.get("siret") + rcs = request.form.get("rcs") + tva = request.form.get("tva") + naf = request.form.get("naf") + tel = request.form.get("tel") + web = request.form.get("web") + mail = request.form.get("mail") + + if action == "creer": + try: + query = text( + "INSERT INTO societe (nom, forme, capital, siren, siret, rcs, " + "tva, naf, tel, web, mail) " + "VALUES (:nom, :forme, :capital, :siren, :siret, :rcs, :tva, " + ":naf, :tel, :web, :mail)" + ) + db.session.execute( + query, + { + "nom": nom, + "forme": forme, + "capital": capital, + "siren": siren, + "siret": siret, + "rcs": rcs, + "tva": tva, + "naf": naf, + "tel": tel, + "web": web, + "mail": mail, + }, + ) + db.session.commit() + flash("Société créée avec succès.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur création société : %s", e, exc_info=True) + flash("Erreur lors de la création de la société.", "danger") + + elif action == "modifier" and soc_id: + try: + query = text( + "UPDATE societe SET nom = :nom, forme = :forme, " + "capital = :capital, siren = :siren, siret = :siret, " + "rcs = :rcs, tva = :tva, naf = :naf, tel = :tel, web = :web, " + "mail = :mail WHERE id = :id" + ) + db.session.execute( + query, + { + "id": soc_id, + "nom": nom, + "forme": forme, + "capital": capital, + "siren": siren, + "siret": siret, + "rcs": rcs, + "tva": tva, + "naf": naf, + "tel": tel, + "web": web, + "mail": mail, + }, + ) + db.session.commit() + flash("Société modifiée avec succès.", "success") + except Exception as e: + db.session.rollback() + current_app.logger.error("Erreur modification société : %s", e, exc_info=True) + flash("Erreur lors de la modification de la société.", "danger") + + return redirect(url_for("societes.gestion_societes")) + + societes_query = text( + "SELECT id, nom, forme, capital, siren, siret, rcs, tva, naf, tel, web, " + "mail FROM societe ORDER BY id ASC" + ) + result = db.session.execute(societes_query) + + societes = [ + { + "id": row.id, + "nom": row.nom if row.nom else "", + "forme": row.forme if row.forme else "", + "capital": row.capital if row.capital is not None else 0, + "siren": row.siren if row.siren is not None else "", + "siret": row.siret if row.siret is not None else "", + "rcs": row.rcs if row.rcs else "", + "tva": row.tva if row.tva else "", + "naf": row.naf if row.naf else "", + "tel": row.tel if row.tel else "", + "web": row.web if row.web else "", + "mail": row.mail if row.mail else "", + } + for row in result + ] + + return render_template("gestion_societes.html", societes=societes) \ No newline at end of file diff --git a/app/routes/utilisateurs.py b/app/routes/utilisateurs.py new file mode 100644 index 0000000..439034e --- /dev/null +++ b/app/routes/utilisateurs.py @@ -0,0 +1,107 @@ +from flask import Blueprint, flash, redirect, render_template, request, url_for +from sqlalchemy import text +from werkzeug.security import generate_password_hash + +from app import db +from app.decorators import login_required + +utilisateurs_bp = Blueprint("utilisateurs", __name__) + + +@utilisateurs_bp.route("/gestion-utilisateurs", methods=["GET", "POST"]) +@login_required +def gestion_utilisateurs(): + if request.method == "POST": + action = request.form.get("action") + user_id = request.form.get("id") + user_actif = request.form.get("actif", "1") + user_prenom = request.form.get("prenom") + user_nom = request.form.get("nom") + user_login = request.form.get("login") + user_mail = request.form.get("mail") + user_pass = request.form.get("pass") + + if action == "creer": + if user_pass: + hashed_pass = generate_password_hash(user_pass) + query = text( + "INSERT INTO login (login, nom, prenom, mail, pass, actif) " + "VALUES (:login, :nom, :prenom, :mail, :pass, :actif)" + ) + db.session.execute( + query, + { + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "pass": hashed_pass, + "actif": user_actif, + }, + ) + db.session.commit() + flash("Utilisateur créé avec succès.", "success") + else: + flash( + "Le mot de passe est obligatoire pour créer un utilisateur.", + "danger", + ) + + elif action == "modifier" and user_id: + if user_pass: + hashed_pass = generate_password_hash(user_pass) + query = text( + "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " + "mail = :mail, pass = :pass, actif = :actif WHERE id = :id" + ) + db.session.execute( + query, + { + "id": user_id, + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "pass": hashed_pass, + "actif": user_actif, + }, + ) + else: + query = text( + "UPDATE login SET login = :login, nom = :nom, prenom = :prenom, " + "mail = :mail, actif = :actif WHERE id = :id" + ) + db.session.execute( + query, + { + "id": user_id, + "login": user_login, + "nom": user_nom, + "prenom": user_prenom, + "mail": user_mail, + "actif": user_actif, + }, + ) + db.session.commit() + flash("Utilisateur modifié avec succès.", "success") + + return redirect(url_for("utilisateurs.gestion_utilisateurs")) + + users_query = text( + "SELECT id, login, nom, prenom, mail, actif FROM login ORDER BY id ASC" + ) + result = db.session.execute(users_query) + + users = [ + { + "id": row.id, + "login": row.login if row.login else "", + "nom": row.nom if row.nom else "", + "prenom": row.prenom if row.prenom else "", + "mail": row.mail if row.mail else "", + "actif": row.actif if row.actif is not None else 1, + } + for row in result + ] + + return render_template("gestion_utilisateurs.html", users=users) \ No newline at end of file diff --git a/app/templates/analyse.html b/app/templates/analyse.html index 976a00e..cf65ad8 100644 --- a/app/templates/analyse.html +++ b/app/templates/analyse.html @@ -19,7 +19,7 @@
-
+
@@ -32,7 +32,7 @@ Analyser Retour au menu diff --git a/app/templates/base.html b/app/templates/base.html index 948c557..6f6fe65 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,7 +30,7 @@ Retour au menu diff --git a/app/templates/gestion_comptes.html b/app/templates/gestion_comptes.html index 633368c..f337524 100644 --- a/app/templates/gestion_comptes.html +++ b/app/templates/gestion_comptes.html @@ -40,7 +40,7 @@ content %} Retour au menu @@ -124,7 +124,7 @@ endblock %} {% block header_title %}Gestion CSV{% endblock %} {% block content >
Retour au menu @@ -44,7 +44,7 @@ @@ -80,7 +80,7 @@ @@ -112,7 +112,7 @@ Retour au menu @@ -304,7 +304,7 @@ @@ -599,7 +599,7 @@ const btnValider = document.getElementById("btn-valider"); modal.classList.remove("hidden"); - form.action = "{{ url_for('main.creer_ordre_traitement') }}"; + form.action = "{{ url_for('ordres.creer_ordre_traitement') }}"; title.innerHTML = ' Créer un Nouvel Ordre'; diff --git a/app/templates/gestion_societes.html b/app/templates/gestion_societes.html index 9c32e12..1897894 100644 --- a/app/templates/gestion_societes.html +++ b/app/templates/gestion_societes.html @@ -34,7 +34,7 @@ content %}

Retour au menu diff --git a/app/templates/gestion_utilisateurs.html b/app/templates/gestion_utilisateurs.html index 109ca23..ec20d5c 100644 --- a/app/templates/gestion_utilisateurs.html +++ b/app/templates/gestion_utilisateurs.html @@ -34,7 +34,7 @@ content %}

Retour au menu diff --git a/app/templates/index.html b/app/templates/index.html index dc6abeb..489b53f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -17,7 +17,7 @@ block content %} {% endif %} {% endwith %} - +
1. Gérer les actions @@ -23,7 +23,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 2. Ordres @@ -31,7 +31,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 3. Comptes @@ -39,7 +39,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 4. Gestion sociétés @@ -47,7 +47,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 5. Gestion utilisateurs @@ -55,7 +55,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 6. Stat historique @@ -63,7 +63,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 7. Import/Export Actions CSV @@ -71,7 +71,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 8. Import historique CSV diff --git a/arborescence.txt b/arborescence.txt new file mode 100644 index 0000000..52cdb4a --- /dev/null +++ b/arborescence.txt @@ -0,0 +1,381 @@ +. +├── AGENTS.md +├── app +│   ├── __init__.py +│   ├── models.py +│   ├── __pycache__ +│   │   ├── __init__.cpython-311.pyc +│   │   └── routes.cpython-311.pyc +│   ├── routes.py +│   ├── services +│   │   ├── analysis.py +│   │   ├── database.py +│   │   ├── indicators.py +│   │   ├── __init__.py +│   │   └── __pycache__ +│   │   ├── analysis.cpython-311.pyc +│   │   ├── database.cpython-311.pyc +│   │   ├── indicators.cpython-311.pyc +│   │   └── __init__.cpython-311.pyc +│   ├── static +│   │   ├── css +│   │   ├── images +│   │   │   ├── favicon.png +│   │   │   └── GFBlancLogo.png +│   │   └── js +│   │   └── charts.js +│   └── templates +│   ├── analyse.html +│   ├── base.html +│   ├── gestion_actions.html +│   ├── gestion_comptes.html +│   ├── gestion_import_export_actions_csv.html +│   ├── gestion_import_historique_csv.html +│   ├── gestion_ordres.html +│   ├── gestion_societes.html +│   ├── gestion_utilisateurs.html +│   ├── index.html +│   └── menu.html +├── arborescence.txt +├── .devcontainer +│   └── devcontainer.json +├── docker-compose.yml +├── Dockerfile +├── .dockerignore +├── .env +├── .git +│   ├── COMMIT_EDITMSG +│   ├── config +│   ├── description +│   ├── HEAD +│   ├── hooks +│   │   ├── applypatch-msg.sample +│   │   ├── commit-msg.sample +│   │   ├── fsmonitor-watchman.sample +│   │   ├── post-update.sample +│   │   ├── pre-applypatch.sample +│   │   ├── pre-commit.sample +│   │   ├── pre-merge-commit.sample +│   │   ├── prepare-commit-msg.sample +│   │   ├── pre-push.sample +│   │   ├── pre-rebase.sample +│   │   ├── pre-receive.sample +│   │   ├── push-to-checkout.sample +│   │   ├── sendemail-validate.sample +│   │   └── update.sample +│   ├── index +│   ├── info +│   │   └── exclude +│   ├── logs +│   │   ├── HEAD +│   │   └── refs +│   │   ├── heads +│   │   │   └── main +│   │   └── remotes +│   │   └── origin +│   │   ├── HEAD +│   │   └── main +│   ├── objects +│   │   ├── 00 +│   │   │   └── bd6ff6fdd29e050127343d217b47a267df712c +│   │   ├── 01 +│   │   │   └── e0502fd1dd04a4689ab4093c65b049888d2848 +│   │   ├── 02 +│   │   │   └── 07cb7e5bc7d04931b6485cf180d1884e9689f9 +│   │   ├── 04 +│   │   │   ├── 08ddf18487e77856a3410c54a512343f0add37 +│   │   │   └── e6dccc12264d2ac6554b7120b5f3b73833f2a9 +│   │   ├── 07 +│   │   │   └── 9031ef84904e911299a303bfb5096834df5d70 +│   │   ├── 0a +│   │   │   └── dc0e5948aba4d913acd5c47ea3084ef42072c8 +│   │   ├── 0c +│   │   │   └── 134ee9dce2f8ecafb266fa9fcca840416135dd +│   │   ├── 0d +│   │   │   └── a3b25bfcd0156e1868a116b69e2ba57b14d001 +│   │   ├── 10 +│   │   │   └── 9ca2310b66a98ec003c85f0f1113cbde00b24d +│   │   ├── 11 +│   │   │   └── 2ab5b90d0d231b89e053775c3a4abe3f7fb0ad +│   │   ├── 12 +│   │   │   └── f8d7e25f2d157a304a8167bd16c888b37fa2fa +│   │   ├── 15 +│   │   │   └── f531461d36bd5067dcf9b1d7df8d284682c700 +│   │   ├── 17 +│   │   │   └── 902472fb99110700c56b93b6edd5eed36e865f +│   │   ├── 1b +│   │   │   └── 21a3e17e1ecc4f961b8575ce0cd8ae0ac8e79f +│   │   ├── 1e +│   │   │   └── b57085067042a37dbb9960d814ca9bf4b89747 +│   │   ├── 1f +│   │   │   ├── 07e48b3a620bf69ec029e531e3ebbcf9718a3e +│   │   │   ├── 2089047f9abbd69b117158a36a2d8a529ca90b +│   │   │   └── 47a7c5e161f973eefdc3f8074fb31e4c7ec92b +│   │   ├── 20 +│   │   │   └── 57d693cd0b411a9c468ddec6f3732ec7117e03 +│   │   ├── 24 +│   │   │   └── 3f4879653fdb1f4b1b5ce5d06a1a7ebe4aea57 +│   │   ├── 26 +│   │   │   ├── b6cd9e153bd5a18968289008c4868e643700d7 +│   │   │   └── f28323844f6bfd9eec20261f44e3e57b6974b5 +│   │   ├── 28 +│   │   │   └── 7192f782efafb53ae63f2c879fb8c5e0cdf69e +│   │   ├── 2a +│   │   │   └── 1425272ff8d7a216715148ff59c7bb53ab3686 +│   │   ├── 34 +│   │   │   └── eba76fb863d7f19e15e72456dc97e8392d7fdd +│   │   ├── 36 +│   │   │   └── 2ccfc03743c02d80b01ed9dfcd92caa35b14ca +│   │   ├── 37 +│   │   │   └── e5ecfa8dbe2a7f89f1b2738ed01227cd8880e3 +│   │   ├── 3b +│   │   │   ├── 0b0bad6147de77557e8edfbaca6194b2530c1f +│   │   │   ├── 16a2452244bf4c7f34e7931a5d92523c85ffd6 +│   │   │   └── 517e20c82a2cbf1915dc8e31cf1b6fb86c7ebb +│   │   ├── 3d +│   │   │   └── e8bf9d59f8409f1dc3f72e95ee0156e5bedab8 +│   │   ├── 3f +│   │   │   └── 1b0becfcf2ce89ce53dee705f727da43b57065 +│   │   ├── 40 +│   │   │   ├── cd5eed2a199166541eedd182945a7568546f03 +│   │   │   └── ed00b9c9a18bf78ea2806b05e4eb9f638018fe +│   │   ├── 41 +│   │   │   └── 39ac631013376273c279668af6fce888eb61fb +│   │   ├── 44 +│   │   │   └── 5b141a30570cf3c4c430f38aca3998e40c5365 +│   │   ├── 46 +│   │   │   └── 885453b18d8ff255ed537b1f06abc5de83f803 +│   │   ├── 49 +│   │   │   ├── 3740d03f7d957864dd6cd0c187a4bea57c0bab +│   │   │   └── e440e17c5be6abc96049f11a244e2ea580ee52 +│   │   ├── 4c +│   │   │   └── 78c73f7e5be001c434da09bd9ddfe5153fc25c +│   │   ├── 4d +│   │   │   ├── 3937c88f7b778219e034addb342d3b90b3ada2 +│   │   │   ├── 71371f2136c3ef10bf2e1376ebaa3f2958bca6 +│   │   │   └── e00820c85d27e1917915962b70d753364ea7fe +│   │   ├── 50 +│   │   │   └── 5b9801c3db189ea858001626ae4741b3918bf6 +│   │   ├── 52 +│   │   │   ├── 1ae0f442e731579df467694823a050b3665f12 +│   │   │   └── 3957cd018743a763ec75e678d75b1c70b8ba82 +│   │   ├── 54 +│   │   │   └── 814d5f0580b785d5b8fe123ca89af21f3e11ac +│   │   ├── 55 +│   │   │   ├── 636030fef0de78019f78e8d5ef947f67e12eb2 +│   │   │   └── f58e0a29126613c2d9517e1bebda9cc47b7802 +│   │   ├── 56 +│   │   │   └── 4f4846027c485e92cb280f84a470e40bd62447 +│   │   ├── 57 +│   │   │   └── 2e8acf0e6194567bd8d59b3ac342195f6d3966 +│   │   ├── 59 +│   │   │   └── af163824c44be62dfbb06df9e71769563a33e1 +│   │   ├── 5a +│   │   │   └── 26a100fa4c7811822dd8dd3c895f60c364de15 +│   │   ├── 5b +│   │   │   └── 6ac4bd24d2dfcc5dd2e94cd16b123e85bec09d +│   │   ├── 60 +│   │   │   ├── 615fd6903be90071138712ae7175ae321d0645 +│   │   │   └── 7fe553d40f715855d6fc92058e190eb08f2669 +│   │   ├── 63 +│   │   │   └── 3368c1d7cd2077e27d9d3ceb03a4032a2cc9c9 +│   │   ├── 65 +│   │   │   └── 92ffbcc5cb696c191ef2b69194de0b810b19f8 +│   │   ├── 66 +│   │   │   └── e2180d741309c85885cff73321ae9b198fff92 +│   │   ├── 67 +│   │   │   └── a0147412bd348f67a3b168a1c7496b0b7082f3 +│   │   ├── 6b +│   │   │   └── 0cea8b3fe318c88578bff8627532672199f8ba +│   │   ├── 6d +│   │   │   └── 018a514d3d9a98f2d3f4d5822be8a83b6deb8c +│   │   ├── 6e +│   │   │   └── 5d15c70803ba6c93947cd46f891524f9534ae9 +│   │   ├── 70 +│   │   │   └── 6bdce8292187fd421e751d94ee444632e7b945 +│   │   ├── 71 +│   │   │   └── 1ff3311b54b32fff3606cf4c635359d60f4c60 +│   │   ├── 72 +│   │   │   ├── 352c8c35a4cde1575d9b205785ba6474b8b637 +│   │   │   └── 47f112f4f746c8ae33031011a3b0af449fc5d4 +│   │   ├── 74 +│   │   │   └── 536771507e4561928a31c31e3cd1c9e5f89428 +│   │   ├── 75 +│   │   │   ├── 92ef5ca86e1351534de19233c580f0c259d4f0 +│   │   │   └── c801d89931e2c327fed73a95da59e6b830e089 +│   │   ├── 7e +│   │   │   ├── 39538816f4b9dacf025dff4100efa246a44c0b +│   │   │   └── 694b15fcebcd3ac03ddbc248154aac4d698206 +│   │   ├── 82 +│   │   │   └── bfeb65501ede38ef71f68d75161df39992917e +│   │   ├── 83 +│   │   │   ├── 218e12f47b9ea37d97a9e71a138e0343a4ef41 +│   │   │   └── 7368aef4264810159f3951edbed316977aaa44 +│   │   ├── 85 +│   │   │   ├── 38f78cce5d41ea9e620668a243e160dda226e0 +│   │   │   ├── eb889e34fb76fde82c995c0e0d3496aeb21a09 +│   │   │   └── f8daca661ff70b9692ac0d02cee98d3215c7ec +│   │   ├── 86 +│   │   │   └── 57696025f6bc6d93d2141302f7f746a0ded69b +│   │   ├── 87 +│   │   │   └── b390931d5dd16697c7e346a91309843f1da5a0 +│   │   ├── 8b +│   │   │   └── 084e5b50e0ce6a5a1a6349540fe8ef9daffcae +│   │   ├── 8c +│   │   │   └── 69706e4f8b5599372f65127837a1645debdabb +│   │   ├── 8d +│   │   │   └── 0bddd1b2f664f31617688340dbe10e67007e68 +│   │   ├── 90 +│   │   │   └── ebd9162d702e2ab49c4d5db4ab282997e65640 +│   │   ├── 94 +│   │   │   └── 8c55763940965d16649effe28b26f4ba1cbbbb +│   │   ├── 97 +│   │   │   ├── 6a00e0fa6d49a08d7711fed68e6e01847c2865 +│   │   │   └── 7599c17a31a9a5e12a5a34bcbcc2ced5c2d641 +│   │   ├── 99 +│   │   │   └── 426023929b3e0ec04abf4a8b3f29ced2799a87 +│   │   ├── 9c +│   │   │   └── 32e129e3dd47b2e5e09c61ea5f1b4bef7c2c33 +│   │   ├── a1 +│   │   │   └── 0d068b5d17148c5a6f9452e9ad0b39794fc9b2 +│   │   ├── a8 +│   │   │   ├── 37fe05c7e3115b53f8955f83d0c4a39d11ef10 +│   │   │   └── c06d8055c22bc855d46eee3f611dc95902e454 +│   │   ├── a9 +│   │   │   └── 0bec8d42e49980ab4f00d64decd060ebe70057 +│   │   ├── b0 +│   │   │   └── 6517db23c58555c268884c502e5020a94b4fa4 +│   │   ├── b1 +│   │   │   ├── 20c2bd728d41675a7be4d4497b1340c7472910 +│   │   │   └── 6b6eef9fc8833a6dc404fa3f6ec2228b396e37 +│   │   ├── b2 +│   │   │   └── c241797b5f9473c35ffeff236d8c2097c41002 +│   │   ├── b6 +│   │   │   └── 7ecfc5d115379a92ffa17747bb7c294b42a6cb +│   │   ├── b8 +│   │   │   └── e0eba8c9f85c6ecf2fe3d353824f542fb57c15 +│   │   ├── b9 +│   │   │   └── 74611196cf8def7d9dcaa4ac270f2a0c863cc1 +│   │   ├── ba +│   │   │   └── 4279c3c3b5125d11c5c025798fcd2a33d2fab4 +│   │   ├── bb +│   │   │   └── e0360e7535ce1604e3f66451e0ab6f32dbac12 +│   │   ├── be +│   │   │   ├── d31a9eeaf84dee7039b8809110286c469eee69 +│   │   │   └── ebbdc835054832a5b92348b8752f2622f88636 +│   │   ├── c1 +│   │   │   └── 54b83412cb4198a6f2e29deb09606ccc2d2a23 +│   │   ├── c2 +│   │   │   └── 2368611269a9fb12cfc034289cf671469704a1 +│   │   ├── c3 +│   │   │   └── 2a4fffdd5d82253a687a8c7c39d71f3cd889f7 +│   │   ├── c4 +│   │   │   └── 1f306b9abb67f5ccb24b3b35ab18b1524399ad +│   │   ├── c8 +│   │   │   └── d18a6cef831ca56e18f7d4ff5d7c7ec58b3010 +│   │   ├── cb +│   │   │   └── 33281ba30cd46880876234e8d2ea7065ecf3de +│   │   ├── cd +│   │   │   └── 902d78081683c69945b34ce67a14add6426b28 +│   │   ├── ce +│   │   │   └── dab25514e00d824c2f9d345f64e756e473173a +│   │   ├── cf +│   │   │   └── ad237cf6df989cb1d54aad4bc5eaa55b8b5c98 +│   │   ├── d0 +│   │   │   └── c784fa00768246e4c95c168a38206f055c166f +│   │   ├── d6 +│   │   │   └── b8bc493ff42da63649b8be6ec989a12319a567 +│   │   ├── d7 +│   │   │   └── d031b8e073f1e07f73ad468b8031e1950c6eff +│   │   ├── d8 +│   │   │   └── 65d3501ada082e077cdaa32439dc22a4966561 +│   │   ├── d9 +│   │   │   └── d851c33add105e23ba383feb3cd131bfc6a8fc +│   │   ├── dc +│   │   │   ├── 14d536b88178d3d74870613cfc938fd90c793a +│   │   │   └── 6abeb9e2c702591fe216dd10b8b04941e117f7 +│   │   ├── dd +│   │   │   ├── 736b72374acab09a8e84bd1bda04327c63c804 +│   │   │   └── f0420841e632270a504ac90e7e8ae4837d56e8 +│   │   ├── df +│   │   │   ├── 76b34a23247f8020fc8b7aefbde2074c812837 +│   │   │   └── 8b33f6a40ca1c710ebc114d16b9a548db16a37 +│   │   ├── e0 +│   │   │   └── e7d51df0cdcc54399d200e1c1604d0fa813378 +│   │   ├── e3 +│   │   │   ├── 1e4572cabd86823c7be31f034c223b1e57b3d7 +│   │   │   └── 8c247ebbc0189b3af17e3294e1429f22e5f61b +│   │   ├── e6 +│   │   │   └── 9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +│   │   ├── ef +│   │   │   └── 58a8a234dd56e699430061b86b005f73939464 +│   │   ├── f2 +│   │   │   └── fd513263d428a680a98a494a7492f8da0de43a +│   │   ├── f3 +│   │   │   └── 7f5b1e4509262eb65122f91220f04f080182ed +│   │   ├── f7 +│   │   │   └── 2cd45703375c00e975a09cee50da3619d3277e +│   │   ├── f8 +│   │   │   └── 91e8a154f2d4357cbb7e4af8c1805dfa310baf +│   │   ├── fa +│   │   │   ├── e895d8ca270a7538d299e63ab2dd4663523f95 +│   │   │   └── f1bce15949cbe30f2cf840aa5e9e9e975829ab +│   │   ├── fb +│   │   │   └── 4fd6e9a038e3fd4660396a0afd78f0f272aa48 +│   │   ├── fc +│   │   │   └── 1ca97cb1f9f2d092206a57833214b2fa28972a +│   │   ├── ff +│   │   │   └── 14f22d999bb7f217cc67c7a2cb02e5a3f1cf4b +│   │   ├── info +│   │   └── pack +│   ├── opencode +│   └── refs +│   ├── heads +│   │   └── main +│   ├── remotes +│   │   └── origin +│   │   ├── HEAD +│   │   └── main +│   └── tags +├── .gitignore +├── migrations +│   ├── apply_migrations.py +│   └── schema.sql +├── mysql_data +│   ├── auto.cnf +│   ├── binlog.000001 +│   ├── binlog.000002 +│   ├── binlog.000003 +│   ├── binlog.000004 +│   ├── binlog.000005 +│   ├── binlog.index +│   ├── bourse_db [error opening dir] +│   ├── ca-key.pem +│   ├── ca.pem +│   ├── client-cert.pem +│   ├── client-key.pem +│   ├── #ib_16384_0.dblwr +│   ├── #ib_16384_1.dblwr +│   ├── ib_buffer_pool +│   ├── ibdata1 +│   ├── #innodb_redo [error opening dir] +│   ├── #innodb_temp [error opening dir] +│   ├── mysql [error opening dir] +│   ├── mysql.ibd +│   ├── mysql.sock -> /var/run/mysqld/mysqld.sock +│   ├── performance_schema [error opening dir] +│   ├── private_key.pem +│   ├── public_key.pem +│   ├── server-cert.pem +│   ├── server-key.pem +│   ├── sys [error opening dir] +│   ├── undo_001 +│   └── undo_002 +├── requirements.txt +├── run.py +└── .vscode + └── settings.json + +147 directories, 230 files + +tree -a > arborescence.txt