maj
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user