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
+4 -2
View File
@@ -6,6 +6,8 @@ LABEL description="DragonBank Backend API"
# Variables d'environnement
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Permet a Python de trouver les modules locaux (config, database, auth...)
ENV PYTHONPATH=/app
# Répertoire de travail
WORKDIR /app
@@ -24,8 +26,8 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copie du code source
COPY app.py .
# Copie de tout le code source (app.py + modules + dossier models/)
COPY . .
# Création d'un utilisateur non-root (sécurité)
RUN adduser --disabled-password --gecos '' appuser && \
+357 -856
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
"""
DragonBank - Authentification
==============================
Gere la generation et la verification des tokens JWT,
ainsi que le decorateur de protection des routes Flask.
Version : 3.0
"""
import uuid
import logging
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from functools import wraps
import jwt
import bcrypt
from flask import request
from flask import jsonify
from flask import current_app
from config import DUREE_TOKEN_HEURES, COUT_HACHAGE_BCRYPT
journaliseur = logging.getLogger('dragonbank.auth')
# ============================================================
# UTILITAIRES IP
# ============================================================
def obtenir_ip_client():
"""
Retourne l'adresse IP reelle du client.
Lorsque l'application est derriere un reverse proxy (Nginx, Traefik),
l'en-tete X-Forwarded-For contient la liste des IPs traversees.
La premiere est celle du client d'origine.
Returns:
str: L'adresse IP du client.
"""
entete = request.headers.get('X-Forwarded-For')
if entete:
return entete.split(',')[0].strip()
return request.remote_addr
# ============================================================
# GESTION DES MOTS DE PASSE
# ============================================================
def hacher_mot_de_passe(mot_de_passe_clair):
"""
Hache un mot de passe avec bcrypt.
Args:
mot_de_passe_clair (str): Le mot de passe en clair.
Returns:
str: Le hash bcrypt encode en UTF-8.
"""
sel = bcrypt.gensalt(rounds=COUT_HACHAGE_BCRYPT)
return bcrypt.hashpw(
mot_de_passe_clair.encode('utf-8'),
sel
).decode('utf-8')
def verifier_mot_de_passe(mot_de_passe_clair, hash_stocke):
"""
Verifie un mot de passe contre son hash bcrypt stocke.
Args:
mot_de_passe_clair (str): Le mot de passe en clair soumis.
hash_stocke (str) : Le hash bcrypt stocke en base.
Returns:
bool: True si le mot de passe correspond, False sinon.
"""
return bcrypt.checkpw(
mot_de_passe_clair.encode('utf-8'),
hash_stocke.encode('utf-8')
)
def simuler_verification_bcrypt():
"""
Effectue un hachage bcrypt fictif pour consommer du temps CPU.
Utilise pour uniformiser le temps de reponse lors d'un echec
de connexion, qu'il s'agisse d'un email inconnu ou d'un mauvais
mot de passe (contre-mesure aux attaques temporelles).
"""
hacher_mot_de_passe('dummy')
# ============================================================
# TOKENS JWT
# ============================================================
def generer_token(id_utilisateur, email):
"""
Genere un token JWT signe pour authentifier un utilisateur.
Charge utile du token :
- user_id : UUID de l'utilisateur.
- email : Adresse email.
- iat : Date de creation (issued at).
- exp : Date d'expiration.
- jti : Identifiant unique du token (JWT ID).
Args:
id_utilisateur (str): UUID de l'utilisateur.
email (str) : Adresse email.
Returns:
str: Le token JWT encode et signe.
"""
maintenant = datetime.now(timezone.utc)
charge_utile = {
'user_id': str(id_utilisateur),
'email': email,
'iat': maintenant,
'exp': maintenant + timedelta(hours=DUREE_TOKEN_HEURES),
'jti': str(uuid.uuid4())
}
return jwt.encode(
charge_utile,
current_app.config['SECRET_KEY'],
algorithm='HS256'
)
# ============================================================
# DECORATEUR DE PROTECTION DES ROUTES
# ============================================================
def token_requis(fonction):
"""
Decorateur protegeant une route Flask par verification du token JWT.
Extrait le token depuis l'en-tete Authorization (format Bearer),
le valide, puis injecte l'identifiant de l'utilisateur comme
premier argument de la fonction decoree.
Usage :
@app.route('/api/exemple')
@token_requis
def ma_route(id_utilisateur_courant):
...
Returns:
401: Token manquant, expire ou invalide.
"""
@wraps(fonction)
def enveloppe(*args, **kwargs):
# --- Extraction du token ---
token = None
entete = request.headers.get('Authorization', '')
if entete.startswith('Bearer '):
token = entete.split(' ', 1)[1].strip()
if not token:
journaliseur.warning("Acces sans token depuis %s", obtenir_ip_client())
return jsonify({'error': "Token d'authentification manquant"}), 401
# --- Verification et decodage ---
try:
charge = jwt.decode(
token,
current_app.config['SECRET_KEY'],
algorithms=['HS256']
)
id_utilisateur_courant = charge['user_id']
except jwt.ExpiredSignatureError:
journaliseur.info("Token expire depuis %s", obtenir_ip_client())
return jsonify({'error': 'Session expiree, veuillez vous reconnecter'}), 401
except jwt.InvalidTokenError as erreur:
journaliseur.warning("Token invalide depuis %s : %s", obtenir_ip_client(), str(erreur))
return jsonify({'error': 'Token invalide'}), 401
return fonction(id_utilisateur_courant, *args, **kwargs)
return enveloppe
+113
View File
@@ -0,0 +1,113 @@
"""
DragonBank - Configuration
===========================
Centralise toutes les constantes et parametres de l'application.
Toutes les valeurs configurables sont recuperees depuis les variables
d'environnement afin de ne jamais stocker de donnees sensibles
dans le code source (bonne pratique Docker / 12-factor app).
Version : 3.0
"""
import os
import decimal
# ============================================================
# SECURITE
# ============================================================
# Cle secrete pour la signature des tokens JWT.
# OBLIGATOIRE en production via variable d'environnement.
CLE_SECRETE = os.environ.get('SECRET_KEY', 'dev-secret-key-changez-moi-en-production')
# Duree de validite d'un token JWT en heures.
DUREE_TOKEN_HEURES = int(os.environ.get('JWT_EXPIRATION_HOURS', 24))
# Nombre de rounds bcrypt pour le hachage des mots de passe.
# 12 = bon equilibre securite / performance.
COUT_HACHAGE_BCRYPT = 12
# ============================================================
# BASE DE DONNEES
# ============================================================
# URL de connexion PostgreSQL.
# Format : postgresql://utilisateur:motdepasse@hote:port/base
# URL de connexion PostgreSQL.
# Format : postgresql://utilisateur:motdepasse@hote:port/base
def _get_secure_db_url():
ext_url = os.environ.get('DATABASE_URL')
if ext_url: return ext_url
user = os.environ.get('POSTGRES_USER', 'dragonadmin')
host = os.environ.get('POSTGRES_HOST', 'db')
port = os.environ.get('POSTGRES_PORT', '5432')
dbname = os.environ.get('POSTGRES_DB', 'dragonbank')
password = 'dragonpass'
secret_path = os.environ.get('POSTGRES_PASSWORD_FILE', '/run/secrets/db_password')
if os.path.exists(secret_path):
with open(secret_path, 'r') as f:
password = f.read().strip()
return f"postgresql://{user}:{password}@{host}:{port}/{dbname}"
URL_BASE_DE_DONNEES = _get_secure_db_url()
# ============================================================
# REGLES METIER - VIREMENTS
# ============================================================
# Montant minimum autorise pour un virement (en euros).
MONTANT_MINIMUM_VIREMENT = decimal.Decimal('0.01')
# Montant maximum autorise pour un virement (en euros).
MONTANT_MAXIMUM_VIREMENT = decimal.Decimal('50000.00')
# Solde de bienvenue credite a l'ouverture du premier compte courant.
SOLDE_BIENVENUE = decimal.Decimal('100.00')
# ============================================================
# REGLES METIER - COMPTES
# ============================================================
# Taux d'interet par cycle selon le type de compte.
TAUX_INTERETS = {
'courant': decimal.Decimal('0.0000'), # 0 % - pas d'interet
'livret_a': decimal.Decimal('0.0300'), # 3 % - taux reglemente
'assurance_vie': decimal.Decimal('0.0200'), # 2 % - fonds euros
}
# Types de comptes dont l'unicite est imposee par la reglementation.
# Un client ne peut detenir qu'un seul compte de ces types.
TYPES_COMPTE_UNIQUES = ('livret_a', 'assurance_vie')
# Champs du profil que l'utilisateur est autorise a modifier.
# Tous les autres champs (email, mot de passe, is_active) sont proteges.
CHAMPS_PROFIL_MODIFIABLES = ['first_name', 'last_name', 'phone', 'address']
# ============================================================
# REGLES METIER - TRANSACTIONS
# ============================================================
# Nombre de transactions retournees par defaut par l'historique.
LIMITE_TRANSACTIONS_DEFAUT = 50
# Nombre maximum de transactions retournables en une seule requete.
LIMITE_TRANSACTIONS_MAX = 200
# ============================================================
# SIMULATEUR D'EPARGNE
# ============================================================
# Duree minimale de simulation en annees.
SIMULATION_DUREE_MIN = 1
# Duree maximale de simulation en annees.
SIMULATION_DUREE_MAX = 40
+142
View File
@@ -0,0 +1,142 @@
"""
DragonBank - Utilitaires Base de Donnees
=========================================
Gere la connexion a PostgreSQL et la serialisation
des resultats de requetes en types JSON-compatibles.
Version : 3.0
"""
import uuid
import decimal
import logging
from datetime import datetime
import psycopg2
import psycopg2.extras
from config import URL_BASE_DE_DONNEES
journaliseur = logging.getLogger('dragonbank.database')
# ============================================================
# CONNEXION
# ============================================================
def creer_connexion():
"""
Cree et retourne une connexion active a la base de donnees PostgreSQL.
Utilise RealDictCursor pour que les resultats soient des
dictionnaires (acces par nom de colonne) plutot que des tuples.
L'autocommit est desactive : chaque route doit appeler
commit() ou rollback() explicitement.
Returns:
psycopg2.connection: Connexion avec autocommit desactive.
Raises:
psycopg2.OperationalError: Si la connexion echoue.
"""
connexion = psycopg2.connect(
URL_BASE_DE_DONNEES,
cursor_factory=psycopg2.extras.RealDictCursor
)
connexion.autocommit = False
return connexion
# ============================================================
# SERIALISATION JSON
# ============================================================
def serialiser_valeur(valeur):
"""
Convertit une valeur PostgreSQL en type serialisable JSON.
Certains types retournes par psycopg2 (Decimal, datetime, UUID)
ne sont pas nativement serialisables par json.dumps().
Args:
valeur: La valeur a convertir.
Returns:
float : si la valeur est un Decimal.
str : si la valeur est un datetime ou un UUID.
valeur : inchangee pour tous les autres types.
"""
if isinstance(valeur, decimal.Decimal):
return float(valeur)
if isinstance(valeur, datetime):
return valeur.isoformat()
if isinstance(valeur, uuid.UUID):
return str(valeur)
return valeur
def serialiser_ligne(ligne):
"""
Serialise une ligne de resultat PostgreSQL en dictionnaire Python.
Args:
ligne: Une RealDictRow psycopg2, ou None.
Returns:
dict : Toutes les colonnes avec leurs valeurs converties.
None : Si la ligne est None.
"""
if ligne is None:
return None
resultat = {}
for cle, valeur in dict(ligne).items():
resultat[cle] = serialiser_valeur(valeur)
return resultat
def serialiser_lignes(lignes):
"""
Serialise une liste de lignes de resultat PostgreSQL.
Args:
lignes: Liste de RealDictRow psycopg2.
Returns:
list[dict]: Liste de dictionnaires serialises.
"""
resultat = []
for ligne in lignes:
resultat.append(serialiser_ligne(ligne))
return resultat
# ============================================================
# AUDIT
# ============================================================
def enregistrer_audit(curseur, id_utilisateur, action, details, adresse_ip):
"""
Insere une entree dans la table des journaux d'audit.
Trace toutes les actions sensibles (connexion, virement, ouverture
de compte) pour la conformite reglementaire (RGPD, DSP2).
Args:
curseur : Curseur de base de donnees actif.
id_utilisateur : UUID de l'utilisateur.
action (str) : Code de l'action (ex: 'LOGIN', 'TRANSFER_INTERNAL').
details (dict) : Informations complementaires en JSONB.
adresse_ip (str): IP du client.
Note:
Ne commit pas. La validation est a la charge de l'appelant.
"""
curseur.execute(
"""
INSERT INTO audit_logs (user_id, action, details, ip_address)
VALUES (%s, %s, %s::jsonb, %s)
""",
(id_utilisateur, action, psycopg2.extras.Json(details), adresse_ip)
)
+105
View File
@@ -0,0 +1,105 @@
services:
db:
build:
context: ./db
dockerfile: Dockerfile
container_name: dragonbank-db
environment:
POSTGRES_DB: dragonbank
POSTGRES_USER: dragonadmin
POSTGRES_PASSWORD: dragonpass
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- dragonbank-backend-net
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dragonadmin -d dragonbank"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: dragonbank-backend
environment:
DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank
SECRET_KEY: dragonbank-super-secret-key-2024
FLASK_ENV: production
depends_on:
db:
condition: service_healthy
networks:
- dragonbank-backend-net
- dragonbank-frontend-net
ports:
- "5000:5000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: dragonbank-frontend
environment:
BACKEND_URL: http://backend:5000
SECRET_KEY: frontend-secret-key-2024
depends_on:
backend:
condition: service_healthy
networks:
- dragonbank-frontend-net
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 15s
timeout: 5s
retries: 3
start_period: 15s
restart: unless-stopped
interests:
build:
context: ./interests
dockerfile: Dockerfile
container_name: dragonbank-interests
environment:
DATABASE_URL: postgresql://dragonadmin:dragonpass@db:5432/dragonbank
INTEREST_RATE_LIVRET_A: 0.03
INTEREST_RATE_ASSURANCE_VIE: 0.02
INTERVAL_SECONDS: 86400
depends_on:
db:
condition: service_healthy
networks:
- dragonbank-backend-net
healthcheck:
test: ["CMD", "python", "-c", "import psycopg2; psycopg2.connect('postgresql://dragonadmin:dragonpass@db:5432/dragonbank')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
restart: unless-stopped
volumes:
postgres_data:
driver: local
networks:
dragonbank-backend-net:
driver: bridge
dragonbank-frontend-net:
driver: bridge
+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
+1 -1
View File
@@ -3,4 +3,4 @@ Flask-CORS==4.0.0
psycopg2-binary==2.9.9
bcrypt==4.1.2
PyJWT==2.8.0
gunicorn==21.2.0
gunicorn==21.2.0
@@ -0,0 +1,83 @@
from database import creer_connexion, enregistrer_audit
from auth import generer_token, obtenir_ip_client
from validators import valider_champs_obligatoires, valider_email, valider_mot_de_passe
from models.utilisateur import UtilisateurDAO
from models.compte import CompteDAO
class AuthService:
def __init__(self):
self.utilisateur_dao = UtilisateurDAO()
self.compte_dao = CompteDAO()
def inscrire(self, donnees):
conn = creer_connexion()
try:
valider_champs_obligatoires(donnees, ['email', 'password', 'first_name', 'last_name'])
email = valider_email(donnees['email'])
valider_mot_de_passe(donnees['password'])
if self.utilisateur_dao.email_existe(conn, email):
raise ValueError('Cette adresse email est deja associee a un compte')
utilisateur = self.utilisateur_dao.creer(
conn, email, donnees['password'],
donnees['first_name'].strip(),
donnees['last_name'].strip(),
donnees.get('phone', '').strip() or None,
donnees.get('address', '').strip() or None,
donnees.get('date_of_birth') or None
)
compte = self.compte_dao.ouvrir(conn, utilisateur['id'], 'courant')
enregistrer_audit(conn.cursor(), utilisateur['id'], 'REGISTER', {
'email': email, 'account_number': compte['account_number']
}, obtenir_ip_client())
conn.commit()
token = generer_token(utilisateur['id'], email)
return {
'message': 'Inscription reussie ! Bienvenue chez DragonBank.',
'user': utilisateur,
'token': token,
'account_number': compte['account_number']
}
except Exception:
conn.rollback()
raise
finally:
conn.close()
def connecter(self, donnees):
if not donnees or not donnees.get('email') or not donnees.get('password'):
raise ValueError('Email et mot de passe requis')
conn = creer_connexion()
try:
email = donnees['email'].strip().lower()
utilisateur = self.utilisateur_dao.authentifier(conn, email, donnees['password'])
if not utilisateur:
raise PermissionError('Email ou mot de passe incorrect')
self.utilisateur_dao.mettre_a_jour_derniere_connexion(conn, str(utilisateur['id']))
enregistrer_audit(conn.cursor(), str(utilisateur['id']), 'LOGIN',
{'ip': obtenir_ip_client()}, obtenir_ip_client())
conn.commit()
return {
'message': 'Connexion reussie',
'token': generer_token(utilisateur['id'], utilisateur['email']),
'user': {
'id': str(utilisateur['id']),
'email': utilisateur['email'],
'first_name': utilisateur['first_name'],
'last_name': utilisateur['last_name']
}
}
except Exception:
conn.rollback()
raise
finally:
conn.close()
@@ -0,0 +1,54 @@
from database import creer_connexion, enregistrer_audit
from auth import obtenir_ip_client
from models.beneficiaire import BeneficiaireDAO
from validators import valider_champs_obligatoires
class BeneficiaireService:
def __init__(self):
self.beneficiaire_dao = BeneficiaireDAO()
def obtenir_beneficiaires(self, id_utilisateur_courant):
conn = creer_connexion()
try:
return self.beneficiaire_dao.lister_par_utilisateur(conn, id_utilisateur_courant)
finally:
conn.close()
def ajouter_beneficiaire(self, id_utilisateur_courant, donnees):
conn = creer_connexion()
try:
valider_champs_obligatoires(donnees, ['beneficiary_name', 'account_number'])
beneficiaire = self.beneficiaire_dao.ajouter(
conn,
id_utilisateur_courant,
donnees['beneficiary_name'].strip(),
donnees['account_number'].strip().upper(),
donnees.get('bank_name', 'DragonBank').strip(),
donnees.get('iban', '').strip().upper() or None,
donnees.get('bic', '').strip().upper() or None
)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'ADD_BENEFICIARY',
{'nom': donnees['beneficiary_name']}, obtenir_ip_client())
conn.commit()
return beneficiaire
except Exception:
conn.rollback()
raise
finally:
conn.close()
def supprimer_beneficiaire(self, id_utilisateur_courant, id_beneficiaire):
conn = creer_connexion()
try:
supprime = self.beneficiaire_dao.supprimer(conn, id_beneficiaire, id_utilisateur_courant)
if not supprime:
raise ValueError('Beneficiaire introuvable')
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'DELETE_BENEFICIARY',
{'id': id_beneficiaire}, obtenir_ip_client())
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
@@ -0,0 +1,68 @@
from database import creer_connexion, enregistrer_audit
from auth import obtenir_ip_client
from models.compte import CompteDAO
from models.transaction import TransactionDAO
class CompteService:
def __init__(self):
self.compte_dao = CompteDAO()
self.transaction_dao = TransactionDAO()
def obtenir_comptes(self, id_utilisateur_courant):
conn = creer_connexion()
try:
return self.compte_dao.lister_par_utilisateur(conn, id_utilisateur_courant)
finally:
conn.close()
def ouvrir_compte(self, id_utilisateur_courant, donnees):
donnees = donnees or {}
conn = creer_connexion()
try:
import decimal as _d
depot_brut = donnees.get('initial_deposit')
depot = _d.Decimal(str(depot_brut)) if depot_brut else _d.Decimal('0')
if depot < 0:
raise ValueError('Le depot initial ne peut pas etre negatif')
compte = self.compte_dao.ouvrir(
conn, id_utilisateur_courant,
donnees.get('account_type', 'courant'),
depot
)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'OPEN_ACCOUNT',
{'type': donnees.get('account_type'), 'depot': float(depot)},
obtenir_ip_client())
conn.commit()
return compte
except Exception:
conn.rollback()
raise
finally:
conn.close()
def obtenir_detail_compte(self, id_utilisateur_courant, id_compte):
conn = creer_connexion()
try:
compte = self.compte_dao.trouver_par_id(conn, id_compte, id_utilisateur_courant)
if not compte:
raise ValueError('Compte introuvable')
return compte
finally:
conn.close()
def historique_solde(self, id_utilisateur_courant, id_compte):
conn = creer_connexion()
try:
compte = self.compte_dao.trouver_par_id(conn, id_compte, id_utilisateur_courant)
if not compte:
raise ValueError('Compte introuvable')
points = self.transaction_dao.historique_solde(conn, id_compte, compte['balance'])
return {
'account_type': compte['account_type'],
'solde_actuel': compte['balance'],
'points': points
}
finally:
conn.close()
@@ -0,0 +1,41 @@
from database import creer_connexion, serialiser_valeur
from models.transaction import TransactionDAO
class StatsService:
def __init__(self):
self.transaction_dao = TransactionDAO()
def obtenir_statistiques(self, id_utilisateur_courant):
conn = creer_connexion()
try:
curseur = conn.cursor()
curseur.execute(
"""
SELECT COALESCE(SUM(balance), 0) AS total_balance,
COUNT(*) AS total_accounts
FROM accounts
WHERE user_id = %s AND status = 'active'
""",
(id_utilisateur_courant,)
)
stats_comptes = curseur.fetchone()
stats_tx = self.transaction_dao.statistiques(conn, id_utilisateur_courant)
curseur.execute(
'SELECT COUNT(*) AS total_beneficiaries FROM beneficiaries WHERE user_id = %s',
(id_utilisateur_courant,)
)
stats_ben = curseur.fetchone()
return {
'total_balance': serialiser_valeur(stats_comptes['total_balance']),
'total_accounts': stats_comptes['total_accounts'],
'monthly_transactions': stats_tx['transactions_mois'],
'total_sent': serialiser_valeur(stats_tx['total_envoye']),
'total_received': serialiser_valeur(stats_tx['total_recu']),
'total_beneficiaries': stats_ben['total_beneficiaries']
}
finally:
conn.close()
@@ -0,0 +1,147 @@
from database import creer_connexion, enregistrer_audit
from auth import obtenir_ip_client
from validators import valider_champs_obligatoires, valider_montant
from models.transaction import TransactionDAO
from models.compte import CompteDAO
from models.beneficiaire import BeneficiaireDAO
class TransactionService:
def __init__(self):
self.transaction_dao = TransactionDAO()
self.compte_dao = CompteDAO()
self.beneficiaire_dao = BeneficiaireDAO()
def virement_interne(self, id_utilisateur_courant, donnees):
conn = creer_connexion()
try:
valider_champs_obligatoires(donnees, ['from_account_id', 'to_account_id', 'amount'])
montant = valider_montant(donnees['amount'])
if donnees['from_account_id'] == donnees['to_account_id']:
raise ValueError('Les comptes source et destination doivent etre differents')
source = self.compte_dao.trouver_actif(conn, donnees['from_account_id'], id_utilisateur_courant)
dest = self.compte_dao.trouver_actif(conn, donnees['to_account_id'], id_utilisateur_courant)
if not source:
raise ValueError('Compte source introuvable')
if not dest:
raise ValueError('Compte destination introuvable')
libelle = donnees.get('description') or (
'Virement ' + source['account_type'] + ' vers ' + dest['account_type']
)
transaction = self.transaction_dao.virement_interne(
conn, donnees['from_account_id'], donnees['to_account_id'],
montant, libelle, self.compte_dao
)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_INTERNAL',
{'montant': float(montant), 'source': source['account_number'],
'dest': dest['account_number']}, obtenir_ip_client())
conn.commit()
return transaction
except Exception:
conn.rollback()
raise
finally:
conn.close()
def virement_beneficiaire(self, id_utilisateur_courant, donnees):
conn = creer_connexion()
try:
valider_champs_obligatoires(donnees, ['from_account_id', 'beneficiary_id', 'amount'])
montant = valider_montant(donnees['amount'])
source = self.compte_dao.trouver_actif(conn, donnees['from_account_id'], id_utilisateur_courant)
if not source:
raise ValueError('Compte source introuvable')
beneficiaire = self.beneficiaire_dao.trouver_approuve(
conn, donnees['beneficiary_id'], id_utilisateur_courant
)
if not beneficiaire:
raise ValueError('Beneficiaire introuvable ou non approuve')
dest = self.compte_dao.trouver_par_numero(conn, beneficiaire['account_number'])
if not dest:
raise ValueError('Le compte du beneficiaire est introuvable dans DragonBank')
libelle = donnees.get('description') or 'Virement a ' + beneficiaire['beneficiary_name']
transaction = self.transaction_dao.virement_entre_personnes(
conn, donnees['from_account_id'], str(dest['id']),
montant, libelle, self.compte_dao
)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_PERSON',
{'montant': float(montant), 'beneficiaire': beneficiaire['beneficiary_name']},
obtenir_ip_client())
conn.commit()
return transaction
except Exception:
conn.rollback()
raise
finally:
conn.close()
def virement_externe(self, id_utilisateur_courant, donnees):
conn = creer_connexion()
try:
valider_champs_obligatoires(
donnees,
['account_id', 'amount', 'external_bank_name', 'external_account_number', 'direction']
)
direction = donnees['direction']
if direction not in ('incoming', 'outgoing'):
raise ValueError('La direction doit etre "incoming" ou "outgoing"')
montant = valider_montant(donnees['amount'])
compte = self.compte_dao.trouver_actif(conn, donnees['account_id'], id_utilisateur_courant)
if not compte:
raise ValueError('Compte introuvable')
nom_banque = donnees['external_bank_name'].strip()
num_ext = donnees['external_account_number'].strip().upper()
libelle = donnees.get('description', '').strip() or (
'Virement ' + ('sortant vers ' if direction == 'outgoing' else 'entrant depuis ')
+ nom_banque + ' (' + num_ext + ')'
)
transaction = self.transaction_dao.virement_externe(
conn, donnees['account_id'], montant, direction,
nom_banque, num_ext, libelle, self.compte_dao
)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'TRANSFER_EXTERNAL',
{'montant': float(montant), 'direction': direction, 'banque': nom_banque},
obtenir_ip_client())
conn.commit()
return transaction, direction
except Exception:
conn.rollback()
raise
finally:
conn.close()
def obtenir_transactions(self, id_utilisateur_courant, id_compte_filtre, type_tx_filtre, limite):
conn = creer_connexion()
try:
if id_compte_filtre:
if not self.compte_dao.trouver_par_id(conn, id_compte_filtre, id_utilisateur_courant):
raise ValueError('Compte introuvable ou acces refuse')
transactions = self.transaction_dao.lister(
conn, id_utilisateur_courant, id_compte_filtre, type_tx_filtre, limite
)
return transactions
finally:
conn.close()
def exporter_csv(self, id_utilisateur_courant, id_compte_filtre):
conn = creer_connexion()
try:
if id_compte_filtre:
if not self.compte_dao.trouver_par_id(conn, id_compte_filtre, id_utilisateur_courant):
raise ValueError('Compte introuvable ou acces refuse')
return self.transaction_dao.exporter_csv(conn, id_utilisateur_courant, id_compte_filtre)
finally:
conn.close()
@@ -0,0 +1,133 @@
"""
DragonBank - Couche Service (Utilisateur)
=========================================
Ce module implémente le pattern 'Service Layer' (Couche Métier).
Choix architecturaux :
1. Séparation des responsabilités (Separation of Concerns) :
Les routes Flask (Controllers) ne font que recevoir les requêtes HTTP.
C'est cette classe Service qui concentre TOUTES les règles métier complexes
(validation des mots de passe bcrypt, règles de profil, audits de sécurité).
2. Gestion Transactionnelle (ACID) :
Chaque méthode du service encapsule l'ouverture et la fermeture de la connexion DB.
Si une exception survient pendant une mise à jour, le bloc `except` appelle un `conn.rollback()`
pour garantir que la base de données ne reste jamais dans un état instable.
3. Abstraction des Données (DAO) :
Le service instancie la classe `UtilisateurDAO` pour lire et écrire les données.
Il ignore totalement la complexité des requêtes SQL (Pattern Repository/DAO).
"""
from database import creer_connexion, enregistrer_audit
from auth import obtenir_ip_client, verifier_mot_de_passe, hacher_mot_de_passe
from config import CHAMPS_PROFIL_MODIFIABLES
from validators import valider_email, valider_mot_de_passe
from models.utilisateur import UtilisateurDAO
class UtilisateurService:
def __init__(self):
self.utilisateur_dao = UtilisateurDAO()
def obtenir_profil(self, id_utilisateur_courant):
conn = creer_connexion()
try:
utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant)
if not utilisateur:
raise ValueError('Utilisateur introuvable')
return utilisateur
finally:
conn.close()
def modifier_profil(self, id_utilisateur_courant, donnees):
donnees = donnees or {}
champs = {
k: v.strip()
for k, v in donnees.items()
if k in CHAMPS_PROFIL_MODIFIABLES and isinstance(v, str)
}
if not champs:
raise ValueError('Aucun champ valide a mettre a jour')
conn = creer_connexion()
try:
mis_a_jour = self.utilisateur_dao.mettre_a_jour_profil(conn, id_utilisateur_courant, champs)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_PROFILE',
{'champs': list(champs.keys())}, obtenir_ip_client())
conn.commit()
return mis_a_jour
except Exception:
conn.rollback()
raise
finally:
conn.close()
def changer_mot_de_passe(self, id_utilisateur_courant, donnees):
donnees = donnees or {}
ancien_mot_de_passe = donnees.get('current_password')
nouveau_mot_de_passe = donnees.get('new_password')
if not ancien_mot_de_passe or not nouveau_mot_de_passe:
raise ValueError('Le mot de passe actuel et le nouveau mot de passe sont requis')
valider_mot_de_passe(nouveau_mot_de_passe)
conn = creer_connexion()
try:
utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant)
if not utilisateur:
raise ValueError('Utilisateur introuvable')
utilisateur_complet = self.utilisateur_dao.trouver_par_email(conn, utilisateur['email'])
if not verifier_mot_de_passe(ancien_mot_de_passe, utilisateur_complet['password_hash']):
raise PermissionError('Le mot de passe actuel est incorrect')
nouveau_hash = hacher_mot_de_passe(nouveau_mot_de_passe)
self.utilisateur_dao.mettre_a_jour_mot_de_passe(conn, id_utilisateur_courant, nouveau_hash)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_PASSWORD', {}, obtenir_ip_client())
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
def changer_email(self, id_utilisateur_courant, donnees):
donnees = donnees or {}
mot_de_passe = donnees.get('password')
nouvel_email = donnees.get('new_email')
if not mot_de_passe or not nouvel_email:
raise ValueError('Le mot de passe et le nouvel email sont requis')
email_valide = valider_email(nouvel_email)
conn = creer_connexion()
try:
utilisateur = self.utilisateur_dao.trouver_par_id(conn, id_utilisateur_courant)
if not utilisateur:
raise ValueError('Utilisateur introuvable')
if utilisateur['email'] == email_valide:
raise ValueError("Le nouvel email est identique a l'actuel")
if self.utilisateur_dao.email_existe(conn, email_valide):
raise ValueError('Cette adresse email est deja utilisee')
utilisateur_complet = self.utilisateur_dao.trouver_par_email(conn, utilisateur['email'])
if not verifier_mot_de_passe(mot_de_passe, utilisateur_complet['password_hash']):
raise PermissionError('Mot de passe incorrect')
self.utilisateur_dao.mettre_a_jour_email(conn, id_utilisateur_courant, email_valide)
enregistrer_audit(conn.cursor(), id_utilisateur_courant, 'UPDATE_EMAIL',
{'ancien': utilisateur['email'], 'nouveau': email_valide},
obtenir_ip_client())
conn.commit()
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
+181
View File
@@ -0,0 +1,181 @@
"""
DragonBank - Validateurs
=========================
Centralise toutes les fonctions de validation des donnees
recues depuis les requetes HTTP.
Principe : chaque validateur retourne soit la valeur nettoyee,
soit leve une ValueError avec un message explicite.
Les routes n'ont ainsi pas a contenir de logique de validation.
Version : 3.0
"""
import re
import decimal
from config import (
MONTANT_MINIMUM_VIREMENT,
MONTANT_MAXIMUM_VIREMENT,
SIMULATION_DUREE_MIN,
SIMULATION_DUREE_MAX
)
# ============================================================
# EXPRESSIONS REGULIERES
# ============================================================
# Valide le format d'une adresse email (RFC 5322 simplifie).
REGEX_EMAIL = re.compile(
r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$'
)
# ============================================================
# VALIDATEURS
# ============================================================
def valider_email(email):
"""
Valide le format d'une adresse email.
Args:
email (str): L'adresse email a valider.
Returns:
str: L'email normalise (strip + lowercase).
Raises:
ValueError: Si le format est invalide ou si l'email est vide.
"""
if not email or not email.strip():
raise ValueError("L'adresse email est obligatoire")
email_normalise = email.strip().lower()
if not REGEX_EMAIL.match(email_normalise):
raise ValueError("Le format de l'adresse email est invalide")
return email_normalise
def valider_mot_de_passe(mot_de_passe):
"""
Valide la complexite d'un mot de passe.
Regles appliquees :
- Au moins 8 caracteres.
- Au moins une lettre majuscule (A-Z).
- Au moins une lettre minuscule (a-z).
- Au moins un chiffre (0-9).
Args:
mot_de_passe (str): Le mot de passe en clair a verifier.
Returns:
str: Le mot de passe inchange si valide.
Raises:
ValueError: Si une regle n'est pas respectee.
"""
if not mot_de_passe or len(mot_de_passe) < 8:
raise ValueError("Le mot de passe doit contenir au moins 8 caracteres")
if not re.search(r'[A-Z]', mot_de_passe):
raise ValueError("Le mot de passe doit contenir au moins une majuscule")
if not re.search(r'[a-z]', mot_de_passe):
raise ValueError("Le mot de passe doit contenir au moins une minuscule")
if not re.search(r'\d', mot_de_passe):
raise ValueError("Le mot de passe doit contenir au moins un chiffre")
return mot_de_passe
def valider_montant(montant_brut):
"""
Valide et convertit un montant en Decimal de maniere securisee.
Utilise Decimal (et non float) pour eviter les erreurs d'arrondi
inherentes aux nombres a virgule flottante (ex: 0.1 + 0.2 != 0.3).
Args:
montant_brut: Le montant brut a valider (str, int ou float).
Returns:
Decimal: Le montant valide et converti.
Raises:
ValueError: Si le montant est non numerique ou hors limites.
"""
try:
montant = decimal.Decimal(str(montant_brut))
except (decimal.InvalidOperation, TypeError):
raise ValueError("Le montant doit etre un nombre valide")
if montant < MONTANT_MINIMUM_VIREMENT:
raise ValueError(
"Le montant minimum est de " + str(MONTANT_MINIMUM_VIREMENT) + " euros"
)
if montant > MONTANT_MAXIMUM_VIREMENT:
raise ValueError(
"Le montant maximum par virement est de " + str(MONTANT_MAXIMUM_VIREMENT) + " euros"
)
return montant
def valider_champs_obligatoires(donnees, champs):
"""
Verifie que tous les champs obligatoires sont presents et non vides.
Args:
donnees (dict): Dictionnaire des donnees recues.
champs (list) : Liste des noms de champs obligatoires.
Returns:
None
Raises:
ValueError: Liste des champs manquants si au moins un est absent.
"""
if not donnees:
raise ValueError("Le corps de la requete JSON est manquant")
manquants = []
for champ in champs:
valeur = donnees.get(champ, '')
if not valeur or (isinstance(valeur, str) and not valeur.strip()):
manquants.append(champ)
if manquants:
raise ValueError("Champs obligatoires manquants : " + ", ".join(manquants))
def valider_parametres_simulateur(capital, taux, duree, versement):
"""
Valide les parametres d'entree du simulateur d'epargne.
Args:
capital (float): Capital initial en euros.
taux (float): Taux annuel en pourcentage.
duree (int) : Duree en annees.
versement (float): Versement mensuel en euros.
Returns:
None
Raises:
ValueError: Si un parametre est invalide.
"""
if capital < 0:
raise ValueError("Le capital initial ne peut pas etre negatif")
if taux < 0 or taux > 100:
raise ValueError("Le taux annuel doit etre compris entre 0 et 100 %")
if duree < SIMULATION_DUREE_MIN or duree > SIMULATION_DUREE_MAX:
raise ValueError(
"La duree doit etre comprise entre "
+ str(SIMULATION_DUREE_MIN) + " et "
+ str(SIMULATION_DUREE_MAX) + " ans"
)
if versement < 0:
raise ValueError("Le versement mensuel ne peut pas etre negatif")