This commit is contained in:
2026-03-27 17:52:41 +01:00
parent 3cf8233054
commit 8320738acb
32 changed files with 5113 additions and 1385 deletions
+198
View File
@@ -0,0 +1,198 @@
"""
DragonBank - Modele Beneficiaire
==================================
Classe DAO gerant les operations en base de donnees
relatives aux beneficiaires de virements.
Version : 3.0
"""
import logging
from database import serialiser_ligne, serialiser_lignes
journaliseur = logging.getLogger('dragonbank.beneficiaire')
class BeneficiaireDAO:
"""
Objet d'acces aux donnees pour la table beneficiaries.
Applique les regles metier :
- Interdiction de s'ajouter soi-meme comme beneficiaire.
- Unicite du numero de compte par utilisateur.
"""
# =========================================================
# LECTURE
# =========================================================
def lister_par_utilisateur(self, connexion, id_utilisateur):
"""
Retourne tous les beneficiaires enregistres par un utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
Returns:
list[dict]: Liste des beneficiaires tries alphabetiquement.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, beneficiary_name, bank_name, account_number,
iban, bic, status, created_at
FROM beneficiaries
WHERE user_id = %s
ORDER BY beneficiary_name ASC
""",
(id_utilisateur,)
)
return serialiser_lignes(curseur.fetchall())
def trouver_approuve(self, connexion, id_beneficiaire, id_utilisateur):
"""
Recupere un beneficiaire approuve appartenant a l'utilisateur.
Utilise lors des virements pour verifier que le beneficiaire
est bien enregistre et approuve.
Args:
connexion : Connexion PostgreSQL active.
id_beneficiaire : UUID du beneficiaire.
id_utilisateur : UUID de l'utilisateur proprietaire.
Returns:
dict : Donnees du beneficiaire.
None : Si introuvable, non approuve ou acces refuse.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, beneficiary_name, account_number, bank_name
FROM beneficiaries
WHERE id = %s AND user_id = %s AND status = 'approved'
""",
(id_beneficiaire, id_utilisateur)
)
return curseur.fetchone()
# =========================================================
# CREATION
# =========================================================
def ajouter(self, connexion, id_utilisateur, nom, numero_compte,
nom_banque='DragonBank', iban=None, bic=None):
"""
Enregistre un nouveau beneficiaire.
Verifie que l'utilisateur ne s'ajoute pas lui-meme
et qu'il n'y a pas de doublon sur le numero de compte.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
nom (str) : Nom du beneficiaire.
numero_compte (str): Numero de compte du beneficiaire.
nom_banque (str) : Nom de la banque (defaut: DragonBank).
iban (str) : Code IBAN (optionnel).
bic (str) : Code BIC/SWIFT (optionnel).
Returns:
dict: Les donnees du beneficiaire cree.
Raises:
ValueError: Si auto-ajout ou doublon detecte.
"""
# Interdiction de s'ajouter soi-meme
if self._est_propre_compte(connexion, id_utilisateur, numero_compte):
raise ValueError(
"Vous ne pouvez pas vous ajouter vous-meme comme beneficiaire"
)
# Verification de l'absence de doublon
if self._existe_deja(connexion, id_utilisateur, numero_compte):
raise ValueError("Ce beneficiaire est deja enregistre")
curseur = connexion.cursor()
curseur.execute(
"""
INSERT INTO beneficiaries
(user_id, beneficiary_name, bank_name, account_number, iban, bic)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, beneficiary_name, bank_name, account_number,
iban, bic, status, created_at
""",
(id_utilisateur, nom, nom_banque, numero_compte,
iban or None, bic or None)
)
journaliseur.info("Beneficiaire '%s' ajoute pour l'utilisateur %s", nom, id_utilisateur)
return serialiser_ligne(curseur.fetchone())
# =========================================================
# SUPPRESSION
# =========================================================
def supprimer(self, connexion, id_beneficiaire, id_utilisateur):
"""
Supprime un beneficiaire en verifiant la propriete.
Args:
connexion : Connexion PostgreSQL active.
id_beneficiaire : UUID du beneficiaire.
id_utilisateur : UUID de l'utilisateur proprietaire.
Returns:
dict : Donnees du beneficiaire supprime (nom inclus).
None : Si introuvable ou acces refuse.
"""
curseur = connexion.cursor()
curseur.execute(
'DELETE FROM beneficiaries WHERE id = %s AND user_id = %s'
' RETURNING id, beneficiary_name',
(id_beneficiaire, id_utilisateur)
)
return curseur.fetchone()
# =========================================================
# UTILITAIRES PRIVES
# =========================================================
def _est_propre_compte(self, connexion, id_utilisateur, numero_compte):
"""
Verifie si le numero correspond a un compte de l'utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
numero_compte : Numero a verifier.
Returns:
bool: True si c'est l'un de ses propres comptes.
"""
curseur = connexion.cursor()
curseur.execute(
'SELECT id FROM accounts WHERE user_id = %s AND account_number = %s',
(id_utilisateur, numero_compte)
)
return curseur.fetchone() is not None
def _existe_deja(self, connexion, id_utilisateur, numero_compte):
"""
Verifie si un beneficiaire avec ce numero est deja enregistre.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
numero_compte : Numero a verifier.
Returns:
bool: True si le beneficiaire existe deja.
"""
curseur = connexion.cursor()
curseur.execute(
'SELECT id FROM beneficiaries WHERE user_id = %s AND account_number = %s',
(id_utilisateur, numero_compte)
)
return curseur.fetchone() is not None
+264
View File
@@ -0,0 +1,264 @@
"""
DragonBank - Modele Compte Bancaire
=====================================
Classe DAO gerant toutes les operations en base de donnees
relatives aux comptes bancaires (courant, livret A, assurance vie).
Version : 3.0
"""
import uuid
import decimal
import logging
from database import serialiser_ligne, serialiser_lignes, enregistrer_audit
from config import TAUX_INTERETS, TYPES_COMPTE_UNIQUES
journaliseur = logging.getLogger('dragonbank.compte')
class CompteDAO:
"""
Objet d'acces aux donnees pour la table accounts.
Encapsule toutes les requetes SQL liees aux comptes bancaires
et applique les regles metier (unicite du Livret A, validation
du solde avant virement...).
"""
# =========================================================
# LECTURE
# =========================================================
def lister_par_utilisateur(self, connexion, id_utilisateur):
"""
Retourne tous les comptes actifs d'un utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
Returns:
list[dict]: Liste des comptes tries par date de creation.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, account_number, account_type, balance, currency,
status, interest_rate, created_at
FROM accounts
WHERE user_id = %s AND status = 'active'
ORDER BY created_at ASC
""",
(id_utilisateur,)
)
return serialiser_lignes(curseur.fetchall())
def trouver_par_id(self, connexion, id_compte, id_utilisateur):
"""
Recupere un compte par son UUID en verifiant la propriete.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte.
id_utilisateur : UUID du proprietaire attendu.
Returns:
dict : Donnees du compte.
None : Si introuvable ou n'appartient pas a l'utilisateur.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, account_number, account_type, balance, currency,
status, interest_rate, created_at, updated_at
FROM accounts
WHERE id = %s AND user_id = %s
""",
(id_compte, id_utilisateur)
)
return serialiser_ligne(curseur.fetchone())
def trouver_actif(self, connexion, id_compte, id_utilisateur):
"""
Recupere un compte actif en verifiant la propriete.
Utilise lors des virements pour s'assurer que le compte
est actif (ni ferme ni gele) et appartient a l'utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte.
id_utilisateur : UUID du proprietaire.
Returns:
dict : Donnees du compte actif.
None : Si introuvable, inactif ou acces refuse.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, balance, account_number, account_type
FROM accounts
WHERE id = %s AND user_id = %s AND status = 'active'
""",
(id_compte, id_utilisateur)
)
return curseur.fetchone()
def trouver_par_numero(self, connexion, numero_compte):
"""
Recherche un compte actif par son numero de compte.
Utilise lors des virements vers un beneficiaire pour trouver
le compte destination a partir du numero enregistre.
Args:
connexion : Connexion PostgreSQL active.
numero_compte (str): Numero de compte (format DRGxxxxx).
Returns:
dict : Donnees du compte.
None : Si introuvable ou inactif.
"""
curseur = connexion.cursor()
curseur.execute(
"SELECT id FROM accounts WHERE account_number = %s AND status = 'active'",
(numero_compte,)
)
return curseur.fetchone()
# =========================================================
# CREATION
# =========================================================
def ouvrir(self, connexion, id_utilisateur, type_compte, depot_initial=None):
"""
Ouvre un nouveau compte bancaire.
Applique les regles metier :
- Unicite du Livret A et de l'Assurance Vie.
- Enregistrement du depot initial comme transaction si > 0.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
type_compte (str): 'courant', 'livret_a' ou 'assurance_vie'.
depot_initial : Montant en euros (Decimal ou None).
Returns:
dict: Les donnees du compte cree.
Raises:
ValueError: Si le type est invalide ou si un compte unique existe deja.
"""
if type_compte not in TAUX_INTERETS:
raise ValueError(
"Type de compte invalide. Valeurs acceptees : "
+ ", ".join(TAUX_INTERETS.keys())
)
if type_compte in TYPES_COMPTE_UNIQUES:
if self._existe_compte_unique(connexion, id_utilisateur, type_compte):
raise ValueError("Vous possedez deja un compte " + type_compte)
if depot_initial is None:
depot_initial = decimal.Decimal('0.00')
# Bonus d'ouverture de 100e uniquement pour le compte courant
if type_compte == 'courant':
depot_initial += decimal.Decimal('100.00')
taux = float(TAUX_INTERETS[type_compte])
numero = 'DRG' + str(uuid.uuid4().int)[:13].zfill(13)
curseur = connexion.cursor()
curseur.execute(
"""
INSERT INTO accounts
(user_id, account_number, account_type, balance, interest_rate)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, account_number, account_type, balance, interest_rate, created_at
""",
(id_utilisateur, numero, type_compte, float(depot_initial), taux)
)
compte = curseur.fetchone()
# Enregistrement du depot initial et bonus comme transaction tracable
if depot_initial > 0:
description_depot = "Depot initial a l'ouverture du compte"
if type_compte == 'courant':
description_depot += " (incluant 100e de bonus)"
curseur.execute(
"""
INSERT INTO transactions
(to_account_id, transaction_type, amount, description, status, executed_at)
VALUES (%s, 'depot', %s, %s, 'completed', NOW())
""",
(str(compte['id']), float(depot_initial), description_depot)
)
journaliseur.info("Compte %s ouvert pour l'utilisateur %s", type_compte, id_utilisateur)
return serialiser_ligne(compte)
# =========================================================
# MISE A JOUR DU SOLDE
# =========================================================
def debiter(self, connexion, id_compte, montant):
"""
Deduit un montant du solde d'un compte.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte a debiter.
montant : Montant a deduire (Decimal ou float).
"""
connexion.cursor().execute(
'UPDATE accounts SET balance = balance - %s WHERE id = %s',
(float(montant), id_compte)
)
def crediter(self, connexion, id_compte, montant):
"""
Ajoute un montant au solde d'un compte.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte a crediter.
montant : Montant a ajouter (Decimal ou float).
"""
connexion.cursor().execute(
'UPDATE accounts SET balance = balance + %s WHERE id = %s',
(float(montant), id_compte)
)
# =========================================================
# UTILITAIRES PRIVES
# =========================================================
def _existe_compte_unique(self, connexion, id_utilisateur, type_compte):
"""
Verifie si l'utilisateur possede deja un compte du type donne.
Methode privee (prefixe _) appelee avant l'ouverture
d'un Livret A ou d'une Assurance Vie.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
type_compte (str): Type de compte a verifier.
Returns:
bool: True si un compte actif de ce type existe deja.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id FROM accounts
WHERE user_id = %s AND account_type = %s AND status = 'active'
""",
(id_utilisateur, type_compte)
)
return curseur.fetchone() is not None
+113
View File
@@ -0,0 +1,113 @@
"""
DragonBank - Simulateur d'Epargne
===================================
Classe de logique pure pour la simulation de croissance d'epargne.
Cette classe ne fait aucun acces a la base de donnees.
Elle contient uniquement les formules mathematiques de calcul
des interets composes avec versements mensuels reguliers.
Version : 3.0
"""
class Simulateur:
"""
Calcule la croissance d'un capital epargne par interets composes.
Formule appliquee pour chaque mois :
solde = solde * (1 + taux_mensuel) + versement_mensuel
Ou taux_mensuel = taux_annuel / 100 / 12.
Cette approche modelise les interets composes mensuels,
qui correspondent au fonctionnement reel du Livret A et
de l'assurance vie en fonds euros.
"""
def simuler(self, capital_initial, taux_annuel, duree_annees,
versement_mensuel=0.0):
"""
Simule la croissance d'un capital sur une duree donnee.
Calcule le capital annee par annee en appliquant les
interets composes chaque mois et en ajoutant les versements.
Args:
capital_initial (float) : Montant de depart en euros.
taux_annuel (float) : Taux d'interet annuel en %.
duree_annees (int) : Duree de la simulation en annees.
versement_mensuel (float): Versement mensuel regulier (defaut: 0).
Returns:
dict: Resultats complets de la simulation :
- capital_initial (float) : Mise de depart.
- taux_annuel (float) : Taux utilise.
- duree_annees (int) : Duree de la simulation.
- versement_mensuel (float) : Versement mensuel.
- capital_final (float) : Capital total a la fin.
- total_verse (float) : Somme totale versee (capital + versements).
- total_interets (float) : Interets generes.
- gain_pourcentage (float) : Gain en % par rapport au total verse.
- courbe (list[dict]) : Evolution annee par annee.
"""
taux_mensuel = taux_annuel / 100.0 / 12.0
solde_courant = float(capital_initial)
total_verse = float(capital_initial)
courbe = []
for annee in range(1, duree_annees + 1):
solde_courant, total_verse = self._calculer_annee(
solde_courant, total_verse, taux_mensuel, versement_mensuel
)
courbe.append({
'annee': annee,
'solde': round(solde_courant, 2),
'total_verse': round(total_verse, 2),
'interets': round(solde_courant - total_verse, 2)
})
capital_final = round(solde_courant, 2)
total_interets = round(solde_courant - total_verse, 2)
gain_pct = round((total_interets / max(total_verse, 1)) * 100, 2)
return {
'capital_initial': capital_initial,
'taux_annuel': taux_annuel,
'duree_annees': duree_annees,
'versement_mensuel': versement_mensuel,
'capital_final': capital_final,
'total_verse': round(total_verse, 2),
'total_interets': total_interets,
'gain_pourcentage': gain_pct,
'courbe': courbe
}
# =========================================================
# UTILITAIRE PRIVE
# =========================================================
def _calculer_annee(self, solde, total_verse, taux_mensuel, versement_mensuel):
"""
Calcule le solde et le total verse apres 12 mois.
Applique la formule des interets composes mois par mois,
puis ajoute le versement mensuel.
Args:
solde (float) : Solde en debut d'annee.
total_verse (float) : Total deja verse en debut d'annee.
taux_mensuel (float) : Taux mensuel (taux_annuel / 12 / 100).
versement_mensuel (float): Versement ajoute chaque mois.
Returns:
tuple: (nouveau_solde, nouveau_total_verse) apres 12 mois.
"""
for mois in range(12):
if taux_mensuel > 0:
solde = solde * (1.0 + taux_mensuel)
solde = solde + versement_mensuel
total_verse = total_verse + versement_mensuel
return solde, total_verse
+447
View File
@@ -0,0 +1,447 @@
"""
DragonBank - Modele Transaction
=================================
Classe DAO gerant les virements et l'historique des transactions.
Chaque virement est atomique : debit et credit s'effectuent dans
la meme transaction SQL. Si l'une echoue, tout est annule.
Version : 3.0
"""
import decimal
import logging
import io
import csv
from flask import Response
from database import serialiser_ligne, serialiser_lignes
from config import LIMITE_TRANSACTIONS_DEFAUT, LIMITE_TRANSACTIONS_MAX
journaliseur = logging.getLogger('dragonbank.transaction')
# Noms lisibles pour les types de transactions (utilises dans l'export CSV).
NOMS_TYPES = {
'virement_interne': 'Virement interne',
'virement_entre_personnes': 'Virement entre personnes',
'virement_externe': 'Virement externe',
'depot': 'Depot',
'retrait': 'Retrait',
'interets': 'Interets'
}
class TransactionDAO:
"""
Objet d'acces aux donnees pour la table transactions.
Contient la logique des trois types de virements :
- Interne : entre les propres comptes de l'utilisateur.
- Entre personnes : vers un beneficiaire DragonBank.
- Externe : depuis/vers une banque tierce (simule).
"""
# =========================================================
# LECTURE
# =========================================================
def lister(self, connexion, id_utilisateur, id_compte=None,
type_transaction=None, limite=None):
"""
Retourne l'historique des transactions d'un utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
id_compte (str) : Filtrer par compte (optionnel).
type_transaction : Filtrer par type (optionnel).
limite (int) : Nombre max de resultats (defaut: 50).
Returns:
list[dict]: Transactions triees par date decroissante.
"""
if limite is None:
limite = LIMITE_TRANSACTIONS_DEFAUT
if limite > LIMITE_TRANSACTIONS_MAX:
limite = LIMITE_TRANSACTIONS_MAX
requete_base = """
SELECT t.id, t.from_account_id, t.to_account_id, t.transaction_type,
t.amount, t.currency, t.description, t.status, t.reference,
t.external_bank_name, t.external_account_number,
t.created_at, t.executed_at,
fa.account_number AS from_account_number,
ta.account_number AS to_account_number
FROM transactions t
LEFT JOIN accounts fa ON t.from_account_id = fa.id
LEFT JOIN accounts ta ON t.to_account_id = ta.id
"""
if id_compte:
clause_where = "WHERE (t.from_account_id = %s OR t.to_account_id = %s)"
parametres = [id_compte, id_compte]
else:
clause_where = "WHERE (fa.user_id = %s OR ta.user_id = %s)"
parametres = [id_utilisateur, id_utilisateur]
if type_transaction:
clause_where = clause_where + " AND t.transaction_type = %s"
parametres.append(type_transaction)
parametres.append(limite)
curseur = connexion.cursor()
curseur.execute(
requete_base + ' ' + clause_where + ' ORDER BY t.created_at DESC LIMIT %s',
parametres
)
return serialiser_lignes(curseur.fetchall())
def historique_solde(self, connexion, id_compte, solde_actuel):
"""
Reconstitue l'evolution du solde d'un compte dans le temps.
Utilise une back-calculation : on part du solde actuel
et on remonte les transactions pour retrouver les soldes passes.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte.
solde_actuel : Solde actuel du compte (float).
Returns:
list[dict]: Points {date, solde} en ordre chronologique.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT created_at, amount, from_account_id, to_account_id
FROM transactions
WHERE (from_account_id = %s OR to_account_id = %s)
AND status = 'completed'
ORDER BY created_at DESC
""",
(id_compte, id_compte)
)
transactions = curseur.fetchall()
solde_courant = float(solde_actuel)
points = []
for transaction in transactions:
montant = float(transaction['amount'])
est_debit = str(transaction['from_account_id']) == id_compte
points.append({
'date': str(transaction['created_at'])[:10],
'solde': round(solde_courant, 2)
})
if est_debit:
solde_courant = solde_courant + montant
else:
solde_courant = solde_courant - montant
points.append({'date': 'Ouverture', 'solde': round(solde_courant, 2)})
points.reverse()
return points
# =========================================================
# VIREMENTS
# =========================================================
def virement_interne(self, connexion, id_source, id_destination,
montant, libelle, compte_dao):
"""
Effectue un virement entre deux comptes du meme utilisateur.
Verifie le solde puis debite la source et credite la destination
dans la meme transaction SQL (atomicite).
Args:
connexion : Connexion PostgreSQL active.
id_source (str) : UUID du compte source.
id_destination : UUID du compte destination.
montant : Montant (Decimal).
libelle (str) : Description du virement.
compte_dao : Instance de CompteDAO pour les mises a jour.
Returns:
dict: La transaction creee.
Raises:
ValueError: Si le solde est insuffisant.
"""
self._verifier_solde_suffisant(connexion, id_source, montant)
compte_dao.debiter(connexion, id_source, montant)
compte_dao.crediter(connexion, id_destination, montant)
return self._enregistrer(
connexion,
from_id=id_source,
to_id=id_destination,
type_t='virement_interne',
montant=montant,
libelle=libelle
)
def virement_entre_personnes(self, connexion, id_source, id_destination,
montant, libelle, compte_dao):
"""
Effectue un virement vers un beneficiaire DragonBank.
Args:
connexion : Connexion PostgreSQL active.
id_source (str) : UUID du compte source.
id_destination : UUID du compte destination (beneficiaire).
montant : Montant (Decimal).
libelle (str) : Description du virement.
compte_dao : Instance de CompteDAO.
Returns:
dict: La transaction creee.
Raises:
ValueError: Si le solde est insuffisant.
"""
self._verifier_solde_suffisant(connexion, id_source, montant)
compte_dao.debiter(connexion, id_source, montant)
compte_dao.crediter(connexion, id_destination, montant)
return self._enregistrer(
connexion,
from_id=id_source,
to_id=id_destination,
type_t='virement_entre_personnes',
montant=montant,
libelle=libelle
)
def virement_externe(self, connexion, id_compte, montant, direction,
nom_banque, numero_externe, libelle, compte_dao):
"""
Simule un virement depuis ou vers une banque externe.
Args:
connexion : Connexion PostgreSQL active.
id_compte (str) : UUID du compte DragonBank.
montant : Montant (Decimal).
direction (str) : 'outgoing' ou 'incoming'.
nom_banque (str): Nom de la banque externe.
numero_externe : Numero de compte externe.
libelle (str) : Description.
compte_dao : Instance de CompteDAO.
Returns:
dict: La transaction creee.
Raises:
ValueError: Si le solde est insuffisant (direction outgoing).
"""
if direction == 'outgoing':
self._verifier_solde_suffisant(connexion, id_compte, montant)
compte_dao.debiter(connexion, id_compte, montant)
from_id = id_compte
to_id = None
else:
compte_dao.crediter(connexion, id_compte, montant)
from_id = None
to_id = id_compte
return self._enregistrer(
connexion,
from_id=from_id,
to_id=to_id,
type_t='virement_externe',
montant=montant,
libelle=libelle,
banque_externe=nom_banque,
numero_externe=numero_externe
)
# =========================================================
# EXPORT CSV
# =========================================================
def exporter_csv(self, connexion, id_utilisateur, id_compte=None):
"""
Genere un fichier CSV de l'historique des transactions.
Le fichier est encode en UTF-8 avec BOM (utf-8-sig) pour
une compatibilite optimale avec Microsoft Excel sous Windows.
Le separateur est le point-virgule (standard europeen).
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
id_compte (str) : Restreindre a un compte (optionnel).
Returns:
flask.Response: Reponse HTTP avec le fichier CSV en piece jointe.
"""
if id_compte:
curseur = connexion.cursor()
curseur.execute(
"""
SELECT t.created_at, t.transaction_type, t.description,
t.amount, t.status,
fa.account_number AS from_account,
ta.account_number AS to_account,
t.external_bank_name
FROM transactions t
LEFT JOIN accounts fa ON t.from_account_id = fa.id
LEFT JOIN accounts ta ON t.to_account_id = ta.id
WHERE (t.from_account_id = %s OR t.to_account_id = %s)
ORDER BY t.created_at DESC
""",
(id_compte, id_compte)
)
else:
curseur = connexion.cursor()
curseur.execute(
"""
SELECT t.created_at, t.transaction_type, t.description,
t.amount, t.status,
fa.account_number AS from_account,
ta.account_number AS to_account,
t.external_bank_name
FROM transactions t
LEFT JOIN accounts fa ON t.from_account_id = fa.id
LEFT JOIN accounts ta ON t.to_account_id = ta.id
WHERE (fa.user_id = %s OR ta.user_id = %s)
ORDER BY t.created_at DESC
""",
(id_utilisateur, id_utilisateur)
)
lignes = curseur.fetchall()
sortie = io.StringIO()
writer = csv.writer(sortie, delimiter=';', quoting=csv.QUOTE_ALL)
writer.writerow([
'Date', 'Type', 'Libelle', 'Montant (EUR)',
'Statut', 'Compte source', 'Compte destination', 'Banque externe'
])
for ligne in lignes:
date = str(ligne['created_at'])[:16].replace('T', ' ') if ligne['created_at'] else ''
writer.writerow([
date,
NOMS_TYPES.get(ligne['transaction_type'], ligne['transaction_type']),
ligne['description'] or '',
'{:.2f}'.format(float(ligne['amount'])),
ligne['status'] or '',
ligne['from_account'] or '',
ligne['to_account'] or '',
ligne['external_bank_name'] or ''
])
contenu = sortie.getvalue()
sortie.close()
return Response(
contenu.encode('utf-8-sig'),
mimetype='text/csv',
headers={
'Content-Disposition': 'attachment; filename="dragonbank_transactions.csv"',
'Content-Type': 'text/csv; charset=utf-8'
}
)
# =========================================================
# STATISTIQUES
# =========================================================
def statistiques(self, connexion, id_utilisateur):
"""
Calcule les statistiques de transactions du mois courant.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
Returns:
dict: {transactions_mois, total_envoye, total_recu}
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT COUNT(*) AS transactions_mois,
COALESCE(SUM(CASE WHEN fa.user_id = %s THEN t.amount ELSE 0 END), 0) AS total_envoye,
COALESCE(SUM(CASE WHEN ta.user_id = %s THEN t.amount ELSE 0 END), 0) AS total_recu
FROM transactions t
LEFT JOIN accounts fa ON t.from_account_id = fa.id
LEFT JOIN accounts ta ON t.to_account_id = ta.id
WHERE (fa.user_id = %s OR ta.user_id = %s)
AND t.created_at >= date_trunc('month', CURRENT_DATE)
AND t.status = 'completed'
""",
(id_utilisateur, id_utilisateur, id_utilisateur, id_utilisateur)
)
return curseur.fetchone()
# =========================================================
# UTILITAIRES PRIVES
# =========================================================
def _verifier_solde_suffisant(self, connexion, id_compte, montant):
"""
Verifie que le solde du compte est suffisant pour le debit.
Args:
connexion : Connexion PostgreSQL active.
id_compte : UUID du compte a verifier.
montant : Montant a debiter (Decimal).
Raises:
ValueError: Si le solde est inferieur au montant.
"""
curseur = connexion.cursor()
curseur.execute('SELECT balance FROM accounts WHERE id = %s', (id_compte,))
resultat = curseur.fetchone()
if resultat is None:
raise ValueError("Compte introuvable")
solde = decimal.Decimal(str(resultat['balance']))
if solde < montant:
raise ValueError(
"Solde insuffisant (disponible : "
+ '{:.2f}'.format(float(solde)) + " euros)"
)
def _enregistrer(self, connexion, from_id, to_id, type_t,
montant, libelle, banque_externe=None, numero_externe=None):
"""
Insere une nouvelle transaction en base de donnees.
Args:
connexion : Connexion PostgreSQL active.
from_id : UUID du compte source (ou None).
to_id : UUID du compte destination (ou None).
type_t (str) : Type de transaction.
montant : Montant (Decimal ou float).
libelle (str) : Description.
banque_externe : Nom de la banque externe (optionnel).
numero_externe : Numero de compte externe (optionnel).
Returns:
dict: La transaction inseree.
"""
curseur = connexion.cursor()
curseur.execute(
"""
INSERT INTO transactions
(from_account_id, to_account_id, transaction_type, amount,
description, status, external_bank_name,
external_account_number, executed_at)
VALUES (%s, %s, %s, %s, %s, 'completed', %s, %s, NOW())
RETURNING id, amount, description, status, created_at
""",
(from_id, to_id, type_t, float(montant),
libelle, banque_externe, numero_externe)
)
return serialiser_ligne(curseur.fetchone())
+243
View File
@@ -0,0 +1,243 @@
"""
DragonBank - Modele Utilisateur
================================
Classe DAO (Data Access Object) gerant toutes les operations
en base de donnees relatives aux utilisateurs.
Pattern DAO : separe la logique d'acces aux donnees
de la logique metier et des routes Flask.
Version : 3.0
"""
import uuid
import logging
from database import serialiser_ligne, enregistrer_audit
from auth import hacher_mot_de_passe, verifier_mot_de_passe, simuler_verification_bcrypt
journaliseur = logging.getLogger('dragonbank.utilisateur')
class UtilisateurDAO:
"""
Objet d'acces aux donnees pour la table users.
Chaque methode recoit une connexion active en parametre.
La gestion des transactions (commit/rollback) reste
a la charge de la route appelante.
"""
# =========================================================
# CREATION
# =========================================================
def creer(self, connexion, email, mot_de_passe, prenom, nom,
telephone=None, adresse=None, date_naissance=None):
"""
Cree un nouvel utilisateur en base de donnees.
Args:
connexion : Connexion PostgreSQL active.
email (str) : Adresse email (deja validee et normalisee).
mot_de_passe (str): Mot de passe en clair (sera hache ici).
prenom (str) : Prenom de l'utilisateur.
nom (str) : Nom de famille.
telephone (str): Numero de telephone (optionnel).
adresse (str) : Adresse postale (optionnel).
date_naissance : Date de naissance au format YYYY-MM-DD (optionnel).
Returns:
dict: Les donnees de l'utilisateur cree (sans le hash du mot de passe).
Raises:
Exception: Si l'insertion echoue (email duplique, etc.).
"""
id_utilisateur = str(uuid.uuid4())
hash_mdp = hacher_mot_de_passe(mot_de_passe)
curseur = connexion.cursor()
curseur.execute(
"""
INSERT INTO users
(id, email, password_hash, first_name, last_name,
phone, address, date_of_birth)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, email, first_name, last_name, created_at
""",
(
id_utilisateur, email, hash_mdp,
prenom, nom,
telephone or None,
adresse or None,
date_naissance or None
)
)
utilisateur = curseur.fetchone()
journaliseur.info("Nouvel utilisateur cree : %s", email)
return serialiser_ligne(utilisateur)
# =========================================================
# LECTURE
# =========================================================
def trouver_par_email(self, connexion, email):
"""
Recherche un utilisateur par son adresse email.
Args:
connexion : Connexion PostgreSQL active.
email (str): Email a rechercher.
Returns:
dict : Donnees de l'utilisateur (avec hash) si trouve.
None : Si aucun utilisateur ne correspond.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, email, password_hash, first_name, last_name, is_active
FROM users
WHERE email = %s
""",
(email,)
)
return curseur.fetchone()
def trouver_par_id(self, connexion, id_utilisateur):
"""
Recupere le profil complet d'un utilisateur actif par son UUID.
N'inclut pas le hash du mot de passe dans le resultat.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
Returns:
dict : Profil de l'utilisateur.
None : Si introuvable ou desactive.
"""
curseur = connexion.cursor()
curseur.execute(
"""
SELECT id, email, first_name, last_name, phone, address,
date_of_birth, created_at, last_login
FROM users
WHERE id = %s AND is_active = TRUE
""",
(id_utilisateur,)
)
return serialiser_ligne(curseur.fetchone())
def email_existe(self, connexion, email):
"""
Verifie si une adresse email est deja utilisee.
Args:
connexion : Connexion PostgreSQL active.
email (str): Email a verifier.
Returns:
bool: True si l'email existe deja, False sinon.
"""
curseur = connexion.cursor()
curseur.execute('SELECT id FROM users WHERE email = %s', (email,))
return curseur.fetchone() is not None
# =========================================================
# MISE A JOUR
# =========================================================
def mettre_a_jour_derniere_connexion(self, connexion, id_utilisateur):
"""
Met a jour la date de derniere connexion de l'utilisateur.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
"""
curseur = connexion.cursor()
curseur.execute(
'UPDATE users SET last_login = NOW() WHERE id = %s',
(str(id_utilisateur),)
)
def mettre_a_jour_profil(self, connexion, id_utilisateur, champs):
"""
Met a jour les champs modifiables du profil utilisateur.
Seuls les champs fournis en parametre sont modifies.
Les champs sensibles (email, mot de passe) sont ignores.
Args:
connexion : Connexion PostgreSQL active.
id_utilisateur : UUID de l'utilisateur.
champs (dict) : Dictionnaire {nom_champ: nouvelle_valeur}.
Returns:
dict: Profil mis a jour.
"""
clause_set = ', '.join(champ + ' = %s' for champ in champs)
valeurs = list(champs.values())
valeurs.append(id_utilisateur)
curseur = connexion.cursor()
curseur.execute(
'UPDATE users SET ' + clause_set
+ ' WHERE id = %s'
' RETURNING id, email, first_name, last_name, phone, address',
valeurs
)
return serialiser_ligne(curseur.fetchone())
def mettre_a_jour_mot_de_passe(self, connexion, id_utilisateur, nouveau_hash):
"""Met a jour le l'empreinte bcrypt de l'utilisateur."""
curseur = connexion.cursor()
curseur.execute('UPDATE users SET password_hash = %s WHERE id = %s', (nouveau_hash, id_utilisateur))
def mettre_a_jour_email(self, connexion, id_utilisateur, nouvel_email):
"""Met a jour l'adresse e-mail."""
curseur = connexion.cursor()
curseur.execute('UPDATE users SET email = %s WHERE id = %s', (nouvel_email, id_utilisateur))
# =========================================================
# AUTHENTIFICATION
# =========================================================
def authentifier(self, connexion, email, mot_de_passe):
"""
Verifie les identifiants d'un utilisateur.
Gere la protection contre les attaques temporelles :
si l'email est inconnu, une verification bcrypt fictive est
effectuee pour uniformiser le temps de reponse.
Args:
connexion : Connexion PostgreSQL active.
email (str) : Email soumis.
mot_de_passe (str) : Mot de passe en clair soumis.
Returns:
dict : Donnees de l'utilisateur si authentification reussie.
None : Si les identifiants sont incorrects.
Raises:
PermissionError: Si le compte est desactive.
"""
utilisateur = self.trouver_par_email(connexion, email)
if not utilisateur:
# On simule le temps bcrypt pour eviter la timing attack
simuler_verification_bcrypt()
return None
if not utilisateur['is_active']:
raise PermissionError("Ce compte a ete desactive. Contactez le support.")
if not verifier_mot_de_passe(mot_de_passe, utilisateur['password_hash']):
journaliseur.warning("Echec de connexion pour : %s", email)
return None
return utilisateur