Compare commits

..

147 commits

Author SHA1 Message Date
Fanilo-Nantenaina
3b5c183b47 refactor(security): remove emojis from logs and improve script debugging 2026-01-21 16:53:38 +03:00
Fanilo-Nantenaina
d25c2cffa9 refactor(security): clean up authentication middleware and api docs 2026-01-21 16:23:34 +03:00
Fanilo-Nantenaina
1c6c45465f fix(security): improve api key and jwt validation handling 2026-01-21 14:00:57 +03:00
Fanilo-Nantenaina
574d82f3c4 fix(security): improve auth handling and openapi schema generation 2026-01-21 13:50:44 +03:00
Fanilo-Nantenaina
a6a623d1ab refactor(api): simplify auth scheme handling and improve schema filtering 2026-01-21 13:41:37 +03:00
Fanilo-Nantenaina
437ecd0ed3 Merge branch 'feat/controlled_swagger_access' into main_2 2026-01-21 13:26:49 +03:00
Fanilo-Nantenaina
c057a085ed refactor(security): improve swagger user management and logging 2026-01-21 13:21:16 +03:00
Fanilo-Nantenaina
23a94f5558 Merge branch 'feat/controlled_swagger_access' into main_2 2026-01-21 13:01:38 +03:00
Fanilo-Nantenaina
797aed0240 feat(security): enhance swagger auth with user context and filtered docs 2026-01-21 12:56:02 +03:00
Fanilo-Nantenaina
5f40c677a8 refactor(manage_security): simplify delete_swagger_user function 2026-01-21 12:21:10 +03:00
Fanilo-Nantenaina
8a22e285df refactor(security): improve logging format and argument handling 2026-01-21 12:12:35 +03:00
Fanilo-Nantenaina
92597a1143 feat(api): add tag-based OpenAPI schema filtering for Swagger users 2026-01-21 12:05:06 +03:00
Fanilo-Nantenaina
c1f4c66e8c refactor(api): remove user dependency from all endpoints 2026-01-20 19:28:27 +03:00
Fanilo-Nantenaina
43da1b09ed Merge branch 'develop' into develop_like 2026-01-20 19:26:42 +03:00
Fanilo-Nantenaina
6d5f8594d0 chore: ignore python clean scripts in gitignore 2026-01-20 19:26:28 +03:00
Fanilo-Nantenaina
a7457c3979 fix(security): improve auth handling and logging in middleware 2026-01-20 19:14:00 +03:00
Fanilo-Nantenaina
5eec115d1d Merge branch 'main' into develop 2026-01-20 16:29:35 +03:00
Fanilo-Nantenaina
d89c9fd35b chore: ignore clean scripts in gitignore 2026-01-20 16:29:10 +03:00
Fanilo-Nantenaina
211dd4fd23 fix(security): improve api key authentication and error handling 2026-01-20 16:18:04 +03:00
Fanilo-Nantenaina
67ef83c4e3 refactor(security): improve authentication logging and endpoint checks 2026-01-20 16:01:54 +03:00
Fanilo-Nantenaina
82d1d92e58 refactor(scripts): improve import handling and path management 2026-01-20 15:35:06 +03:00
Fanilo-Nantenaina
28c8fb3008 refactor(security): improve user management and session handling 2026-01-20 15:27:26 +03:00
Fanilo-Nantenaina
f8cec7ebc5 refactor(security): improve security scripts and api documentation 2026-01-20 15:13:19 +03:00
Fanilo-Nantenaina
1a08894b47 refactor(scripts): improve logging format and endpoint handling in security management 2026-01-20 15:01:57 +03:00
Fanilo-Nantenaina
3cdb490ee5 refactor(security): improve middleware structure and configuration handling 2026-01-20 14:47:07 +03:00
Fanilo-Nantenaina
c84e4ddc20 refactor(auth): simplify authentication logic and improve error handling 2026-01-20 14:25:06 +03:00
Fanilo-Nantenaina
41ca202d4b refactor(security): move security config to environment variables and improve error handling 2026-01-20 14:19:48 +03:00
Fanilo-Nantenaina
918f5d3f19 docs(api): fix incorrect comment syntax in openapi configuration 2026-01-20 14:06:10 +03:00
Fanilo-Nantenaina
fa95d0d117 refactor(api): replace get_current_user with get_sage_client_for_user in dependencies 2026-01-20 13:56:16 +03:00
Fanilo-Nantenaina
a1150390f4 Merge branch 'fix/security' into main_2 2026-01-20 13:54:42 +03:00
Fanilo-Nantenaina
0001dbe634 docs(api): add comments for security schemas and openapi setup 2026-01-20 13:54:36 +03:00
Fanilo-Nantenaina
5b584bf969 refactor(security): improve auth middleware and logging 2026-01-20 13:51:09 +03:00
Fanilo-Nantenaina
022149c237 refactor(api): replace get_sage_client_for_user with get_current_user for dependency injection 2026-01-20 13:46:27 +03:00
Fanilo-Nantenaina
72d1ac58d1 refactor(security): reorganize imports and improve logging message 2026-01-20 12:40:56 +03:00
Fanilo-Nantenaina
cce1cdf76a refactor(scripts): improve manage_security.py organization and error handling 2026-01-20 12:32:49 +03:00
Fanilo-Nantenaina
e51a5e0a0b refactor(database): update import to use direct get_session import 2026-01-20 12:19:51 +03:00
Fanilo-Nantenaina
dd65ae4d96 style: remove emoji from log messages 2026-01-20 12:17:52 +03:00
Fanilo-Nantenaina
cc0062b3bc refactor(security): improve security management script with better logging and structure 2026-01-20 12:11:20 +03:00
Fanilo-Nantenaina
9bd0f62459 feat(api): add authentication to all endpoints and update OpenAPI schema 2026-01-20 11:49:57 +03:00
Fanilo-Nantenaina
e0f08fd83a refactor(dependencies): rename require_role_hybrid to require_role for consistency 2026-01-20 11:29:12 +03:00
Fanilo-Nantenaina
f59e56490c feat(api): add api keys router to middleware stack 2026-01-20 11:24:42 +03:00
Fanilo-Nantenaina
2aafd525cd refactor(api): update middleware and cors configuration 2026-01-20 11:23:10 +03:00
Fanilo-Nantenaina
17a4251eea Merge branch 'feat/secureing_API' 2026-01-20 11:12:59 +03:00
Fanilo-Nantenaina
abc9ff820a feat(security): implement api key management and authentication system 2026-01-20 11:11:32 +03:00
Fanilo-Nantenaina
b85bd26dbe Merge branches 'develop' and 'main' of https://git.dataven.fr/fanilo/Sage100-vps 2026-01-20 11:02:28 +03:00
Fanilo-Nantenaina
1164c7975a refactor: remove emojis and clean up code comments 2026-01-20 06:31:17 +03:00
Fanilo-Nantenaina
a10fda072c feat(security): implement API key authentication system 2026-01-19 20:38:01 +03:00
Fanilo-Nantenaina
9f6c1de8ef feat(api-keys): implement api key management system 2026-01-19 20:35:03 +03:00
Fanilo-Nantenaina
09eae50952 refactor: clean up code by removing unnecessary comments 2026-01-19 20:32:40 +03:00
Fanilo-Nantenaina
4b686c4544 Merge branch 'feat/get_all_reglements' into develop_like 2026-01-17 12:53:01 +03:00
Fanilo-Nantenaina
89510537b3 fix(api): cast montant_total to float in regler_factures_multiple 2026-01-17 11:41:44 +03:00
Fanilo-Nantenaina
0e18129325 feat(reglements): add endpoint to get payment details by invoice number 2026-01-16 17:58:02 +03:00
Fanilo-Nantenaina
aa89ebdf9e feat(reglements): add endpoints to list and get payment details 2026-01-16 15:46:58 +03:00
Fanilo-Nantenaina
9f12727bd3 refactor(routes): remove authentication dependency from universign router 2026-01-16 13:26:24 +03:00
Fanilo-Nantenaina
18603ded6e refactor(auth): reorganize imports and remove unused dependencies - Added some missing auth 2026-01-16 13:22:13 +03:00
Fanilo-Nantenaina
18d72b3bf9 Secured all routes 2026-01-16 12:47:56 +03:00
Fanilo-Nantenaina
fdf359738b Merge branch 'develop' 2026-01-16 12:35:17 +03:00
Fanilo-Nantenaina
ba9e474109 Merge branch 'main' of https://git.dataven.fr/fanilo/backend_vps 2026-01-16 12:34:47 +03:00
Fanilo-Nantenaina
b291cbf65f feat(reglements): add compte_general field to ReglementFactureCreate 2026-01-15 17:13:31 +03:00
Fanilo-Nantenaina
6f2136c3ca feat(sage_client): add new payment parameters and make fields optional 2026-01-15 16:56:25 +03:00
Fanilo-Nantenaina
beabefa3f9 feat(payments): enhance payment processing with new endpoints and schema 2026-01-15 15:49:15 +03:00
Fanilo-Nantenaina
457c746706 Merge branch 'feat/validation_facture' into develop 2026-01-15 14:46:51 +03:00
Fanilo-Nantenaina
d719966339 Merge branch 'feat/settle_invoice' of https://git.dataven.fr/fanilo/backend_vps into feat/settle_invoice 2026-01-15 14:44:17 +03:00
Fanilo-Nantenaina
cc1609549f feat(api): add endpoint to fetch bank journals 2026-01-15 14:44:04 +03:00
Fanilo-Nantenaina
25be0bd569 feat(payments): add payment functionality for invoices 2026-01-15 14:44:04 +03:00
Fanilo-Nantenaina
19faec9b24 Merge branch 'feat/validation_facture' of https://git.dataven.fr/fanilo/backend_vps into feat/validation_facture 2026-01-15 14:39:56 +03:00
Fanilo-Nantenaina
2f06a083dc refactor: simplify invoice validation methods and error handling 2026-01-15 14:38:42 +03:00
Fanilo-Nantenaina
149d8fb2de feat(factures): add invoice validation endpoints and client methods 2026-01-15 14:38:42 +03:00
Fanilo-Nantenaina
eedc628a5f refactor(models): clean up model comments and whitespace 2026-01-15 14:38:26 +03:00
Fanilo-Nantenaina
f505dad8a7 refactor: simplify invoice validation methods and error handling 2026-01-15 11:08:52 +03:00
Fanilo-Nantenaina
a824592398 feat(factures): add invoice validation endpoints and client methods 2026-01-15 10:34:40 +03:00
Fanilo-Nantenaina
23575fa231 feat(api): add endpoint to fetch bank journals 2026-01-14 19:58:56 +03:00
Fanilo-Nantenaina
a9df408399 feat(payments): add payment functionality for invoices 2026-01-14 18:40:21 +03:00
Fanilo-Nantenaina
671d5bac15 feat(society): add logo support and preview endpoint 2026-01-14 15:20:16 +03:00
Fanilo-Nantenaina
d5273a0786 refactor(enterprise): change siren parameter from Query to Path and corrected deprecated "regex" 2026-01-13 18:24:10 +03:00
Fanilo-Nantenaina
e7003d4059 feat(enterprise): add enterprise search functionality with API integration 2026-01-13 18:20:30 +03:00
Fanilo-Nantenaina
30ffc7a493 refactor: improve societe info handling and add sqlite lock retry docs 2026-01-13 18:03:33 +03:00
Fanilo-Nantenaina
3f1dce918d feat(pdf): add dynamic company info to PDF generator 2026-01-13 17:37:23 +03:00
Fanilo-Nantenaina
08665f15dd Merge branch 'feat/get_society' into fix/update_pdf_structure 2026-01-13 17:19:19 +03:00
Fanilo-Nantenaina
3233630401 feat(database): add SQLite optimization and retry mechanisms 2026-01-13 17:14:55 +03:00
Fanilo-Nantenaina
d2f02e1555 refactor(api/schemas): simplify company info response and schema 2026-01-13 17:09:12 +03:00
Fanilo-Nantenaina
9ae447e2c7 feat(society): add company info schema and endpoint 2026-01-13 17:03:18 +03:00
Fanilo-Nantenaina
358b2e3639 refactor(email_queue): update font and logo fallback paths to use pdfs directory 2026-01-13 16:58:18 +03:00
Fanilo-Nantenaina
a2c85a211a feat(pdf): refactor PDF generation with new SagePDFGenerator class 2026-01-13 16:30:34 +03:00
Fanilo-Nantenaina
b17e4abf12 Merge branch 'fix/update_pdf_structure' of https://git.dataven.fr/fanilo/backend_vps into fix/update_pdf_structure 2026-01-13 11:57:07 +03:00
Fanilo-Nantenaina
23f9bba174 chore: add .trunk to gitignore 2026-01-13 11:52:24 +03:00
Fanilo-Nantenaina
983e960b9b feat(universign): add signed document download and storage functionality 2026-01-13 11:50:32 +03:00
Fanilo-Nantenaina
74c0d73294 chore: add .trunk to gitignore 2026-01-13 11:49:57 +03:00
Fanilo-Nantenaina
18f9a45ef6 refactor(universign): remove emojis from logs and clean up admin routes 2026-01-13 11:46:18 +03:00
Fanilo-Nantenaina
d6ed8792cc fix(universign): add missing api_url parameter to document service 2026-01-13 11:13:58 +03:00
Fanilo-Nantenaina
24d7a49a73 feat(universign): add transaction diagnosis endpoint and improve document handling 2026-01-13 11:05:34 +03:00
Fanilo-Nantenaina
c5c17fdd9b fix(auth): increase failed login attempt threshold from 5 to 15 2026-01-13 10:42:58 +03:00
Fanilo-Nantenaina
c389129ae7 Updated using the correct redirection structure 2026-01-12 19:04:09 +03:00
Fanilo-Nantenaina
6b6246b6e5 Testing multi-sage users 2026-01-12 18:56:37 +03:00
b3419eafaa Deleted cached dev database 2026-01-08 17:29:16 +00:00
Fanilo-Nantenaina
a9aff7b386 chore: add staging and production env files to gitignore 2026-01-08 20:07:07 +03:00
Fanilo-Nantenaina
4f0fe17ee9 chore: remove staging environment configuration file 2026-01-08 20:06:51 +03:00
d4d6cbc44f Added enum status caused by case sensibility on Linux 2026-01-08 17:04:09 +00:00
Fanilo-Nantenaina
795b848dff chore: update gitignore and add status enums 2026-01-08 16:59:08 +03:00
Fanilo-Nantenaina
e990cbdc08 MERGING branch develop INTO MAIN 2026-01-08 16:58:43 +03:00
Fanilo-Nantenaina
1972f22b80 chore: update container name in dev docker-compose file 2026-01-08 16:37:14 +03:00
Fanilo-Nantenaina
ce3b234fee chore: update container name in docker-compose.dev.yml 2026-01-08 15:24:11 +03:00
Fanilo-Nantenaina
d78d189606 chore: update database name in docker-compose.dev.yml 2026-01-08 15:22:27 +03:00
Fanilo-Nantenaina
8a012fc162 refactor(docker): restructure docker setup for multiple environments 2026-01-08 15:15:21 +03:00
Fanilo-Nantenaina
f3957dddcf Merge branch 'fix/update_pdf_structure' into feat/update_pdf_structure 2026-01-08 10:48:26 +03:00
Fanilo-Nantenaina
268dfb3618 refactor(api): replace sage dependency with direct sage_client usage 2026-01-08 10:29:21 +03:00
Fanilo-Nantenaina
bcee1f277f refactor: replace is_() method with direct boolean comparison 2026-01-07 20:29:35 +03:00
Fanilo-Nantenaina
d8ec61802d feat(universign): add signed document storage and download functionality 2026-01-07 20:01:55 +03:00
Fanilo-Nantenaina
4a1960745a feat(pdf-generation): redesign document layout with modern styling 2026-01-07 19:42:29 +03:00
0be28f6744 Merge pull request 'debug/universign_3' (#3) from debug/universign_3 into develop
Reviewed-on: fanilo/backend_vps#3
2026-01-07 03:13:26 +00:00
Fanilo-Nantenaina
e4024168b2 fix: update total label in PDF generation to "Total HT NET" 2026-01-07 06:12:25 +03:00
Fanilo-Nantenaina
cd9dd9348d fix: update invoice pdf to use total_ht_net instead of total_ht 2026-01-07 04:26:56 +03:00
Fanilo-Nantenaina
bcaa621432 feat(signer-status): add new signer statuses and improve status handling 2026-01-06 20:24:29 +03:00
Fanilo-Nantenaina
fbaa43e3fd fix(universign): improve webhook payload handling and transaction sync 2026-01-06 19:56:39 +03:00
Fanilo-Nantenaina
1ce85517be feat(universign): improve transaction sync and webhook handling 2026-01-06 19:43:42 +03:00
Fanilo-Nantenaina
a3f02cbd91 feat(universign): add transaction management and status synchronization 2026-01-06 19:15:35 +03:00
Fanilo-Nantenaina
92a2b95cbb fix(universign_status_mapping): add "closed" status mapping to "SIGNE" 2026-01-06 15:43:47 +03:00
Fanilo-Nantenaina
9a1e1d6726 Uniformed function 2026-01-06 15:41:03 +03:00
Fanilo-Nantenaina
c24f276ce4 feat(models): add CLOSED status to UniversignTransactionStatus enum 2026-01-06 15:38:24 +03:00
Fanilo-Nantenaina
5ad1fccc5c refactor(schemas): change date fields to datetime in document models 2026-01-06 13:25:09 +03:00
Fanilo-Nantenaina
0deb178bc6 refactor(utils): reorganize status mapping functions and add docstrings 2026-01-06 12:53:33 +03:00
Fanilo-Nantenaina
bac8cc6017 refactor(universign): simplify status mapping and remove redundant comments 2026-01-06 12:52:52 +03:00
Fanilo-Nantenaina
f3fc32c89f refactor(api): remove unused signature endpoint and related imports 2026-01-06 12:48:56 +03:00
Fanilo-Nantenaina
677cd826d7 refactor(email_queue): remove debug logging and simplify email processing 2026-01-06 12:06:55 +03:00
Fanilo-Nantenaina
410d4553d5 refactor(universign): clean up code and update status constants 2026-01-06 11:29:36 +03:00
Fanilo-Nantenaina
b40c998062 feat(universign): add email templating for signature requests 2026-01-06 11:02:52 +03:00
Fanilo-Nantenaina
ab25443f99 refactor(models): remove verbose comments and docstrings from enums 2026-01-06 09:13:57 +03:00
Fanilo-Nantenaina
e3f7090935 fix(routes): add duplicate webhook endpoint with trailing slash 2026-01-06 00:11:47 +03:00
Fanilo-Nantenaina
bbaec0f0b8 refactor: rename local status from EN_COURS to IN_PROGRESS for clarity 2026-01-06 00:09:45 +03:00
Fanilo-Nantenaina
a08fb12b56 refactor(utils): move normaliser_type_doc to generic_functions module 2026-01-06 00:07:58 +03:00
Fanilo-Nantenaina
19811a2290 refactor(universign): replace sage_client with email_queue for PDF generation 2026-01-06 00:04:16 +03:00
Fanilo-Nantenaina
9f5ccb8e7b fix(models): correct relationship and import paths for universign models 2026-01-05 23:48:25 +03:00
Fanilo-Nantenaina
a68f5af72e feat(universign): implement comprehensive e-signature integration 2026-01-05 23:37:17 +03:00
Fanilo-Nantenaina
50c654a74a refactor(sage_client): Corrected collaborator listing logic and return type 2026-01-05 19:06:53 +03:00
Fanilo-Nantenaina
4d6bb8f0f9 feat(sage_client): add debug logging for collaborator list response 2026-01-05 19:01:06 +03:00
Fanilo-Nantenaina
59105dae88 refactor(api): update response model for collaborators endpoint 2026-01-05 18:38:42 +03:00
Fanilo-Nantenaina
bdb8e4f799 fix(sage_client): handle None filter in lister_collaborateurs 2026-01-05 18:35:33 +03:00
Fanilo-Nantenaina
c97db9b058 Added collaborator 2026-01-05 18:19:11 +03:00
Fanilo-Nantenaina
8850c7c266 Unification for document's schemas 2026-01-05 17:22:27 +03:00
Fanilo-Nantenaina
e7bdf2d6a2 refactor(tiers): restructure client and supplier models to inherit from base and added Collaborator (commercial) fields on retrieving data 2026-01-05 10:14:49 +03:00
Fanilo-Nantenaina
448227c80f feat(articles): extend article schemas with additional fields 2026-01-03 16:56:25 +03:00
Fanilo-Nantenaina
17b17379ac Renamed Pydantic models' name that caused conflict 2026-01-03 15:25:26 +03:00
Fanilo-Nantenaina
e6236558fb fix(articles): add validation for fournisseur_principal field 2026-01-03 12:23:28 +03:00
Fanilo-Nantenaina
45dd517ecf refactor(models): rename request models to be more concise 2026-01-03 11:27:36 +03:00
Fanilo-Nantenaina
306c71b43d refactor(schemas): rename response and request models to simpler names 2026-01-03 11:11:28 +03:00
Fanilo-Nantenaina
e3b0f7e44a refactor(config): move config module to config/config.py and update imports 2026-01-02 19:14:40 +03:00
Fanilo-Nantenaina
512bb366dc refactor(database): fix enum imports and update sqlalchemy queries 2026-01-02 18:00:01 +03:00
86 changed files with 9530 additions and 5765 deletions

View file

@ -1,97 +1,32 @@
# === Environment ===
ENVIRONMENT=development
# Options: development, staging, production
# ============================================
# Configuration Linux VPS - API Principale
# ============================================
# === JWT & Authentication ===
# IMPORTANT: Generer des secrets uniques et forts en production
# python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET=CHANGE_ME_IN_PRODUCTION_USE_STRONG_SECRET_64_CHARS_MIN
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
CSRF_TOKEN_EXPIRE_MINUTES=60
# === Sage Gateway Windows ===
SAGE_GATEWAY_URL=http://192.168.1.50:8100
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
# === Cookie Settings ===
COOKIE_DOMAIN=
# Laisser vide pour localhost, sinon ".example.com" pour sous-domaines
COOKIE_SECURE=false
# Mettre true en production avec HTTPS
COOKIE_SAMESITE=strict
# Options: strict, lax, none
COOKIE_HTTPONLY=true
COOKIE_ACCESS_TOKEN_NAME=access_token
COOKIE_REFRESH_TOKEN_NAME=refresh_token
COOKIE_CSRF_TOKEN_NAME=csrf_token
# === Redis (Token Blacklist & Rate Limiting) ===
REDIS_URL=redis://localhost:6379/0
REDIS_PASSWORD=
REDIS_SSL=false
TOKEN_BLACKLIST_PREFIX=blacklist:
RATE_LIMIT_PREFIX=ratelimit:
# === Rate Limiting ===
RATE_LIMIT_LOGIN_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW_MINUTES=15
RATE_LIMIT_API_REQUESTS=100
RATE_LIMIT_API_WINDOW_SECONDS=60
# === Password Security ===
PASSWORD_MIN_LENGTH=8
PASSWORD_REQUIRE_UPPERCASE=true
PASSWORD_REQUIRE_LOWERCASE=true
PASSWORD_REQUIRE_DIGIT=true
PASSWORD_REQUIRE_SPECIAL=true
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_DURATION_MINUTES=30
# === Device Fingerprint ===
FINGERPRINT_SECRET=
# Si vide, utilise JWT_SECRET
FINGERPRINT_COMPONENTS=user_agent,accept_language,accept_encoding
# === Refresh Token Rotation ===
REFRESH_TOKEN_ROTATION_ENABLED=true
REFRESH_TOKEN_REUSE_WINDOW_SECONDS=10
# === Database ===
# === Base de données ===
DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
# PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/dbname
# === Sage Gateway (Windows) ===
SAGE_GATEWAY_URL=http://windows-server:5000
SAGE_GATEWAY_TOKEN=your_gateway_token
# === Frontend ===
FRONTEND_URL=http://localhost:3000
# === SMTP (Email) ===
SMTP_HOST=smtp.example.com
# === SMTP ===
SMTP_HOST=smtp.office365.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=noreply@example.com
SMTP_USE_TLS=true
SMTP_USER=commercial@monentreprise.fr
SMTP_PASSWORD=MonMotDePasseEmail123!
SMTP_FROM=commercial@monentreprise.fr
# === Universign (Signature electronique) ===
UNIVERSIGN_API_KEY=your_universign_api_key
# === Universign ===
UNIVERSIGN_API_KEY=your_real_universign_key_here
UNIVERSIGN_API_URL=https://api.universign.com/v1
# === API Server ===
# === API ===
API_HOST=0.0.0.0
API_PORT=8000
API_RELOAD=true
# Mettre false en production
API_PORT=8002
API_RELOAD=False
# === CORS ===
# Liste separee par virgules des origines autorisees
CORS_ORIGINS=["*"]
# === Email Queue ===
MAX_EMAIL_WORKERS=3
# === Sage Document Types ===
SAGE_TYPE_DEVIS=0
SAGE_TYPE_BON_COMMANDE=10
SAGE_TYPE_PREPARATION=20
SAGE_TYPE_BON_LIVRAISON=30
SAGE_TYPE_BON_RETOUR=40
SAGE_TYPE_BON_AVOIR=50
SAGE_TYPE_FACTURE=60
# === Logs ===
LOG_LEVEL=INFO

9
.gitignore vendored
View file

@ -39,3 +39,12 @@ data/*.db.bak
*.db
tools/
.trunk
.env.staging
.env.production
.trunk
*clean*.py

9
.trunk/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
*out
*logs
*actions
*notifications
*tools
plugins
user_trunk.yaml
user.yaml
tmp

32
.trunk/trunk.yaml Normal file
View file

@ -0,0 +1,32 @@
# This file controls the behavior of Trunk: https://docs.trunk.io/cli
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.25.0
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.7.4
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
enabled:
- node@22.16.0
- python@3.10.8
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
enabled:
- git-diff-check
- hadolint@2.14.0
- markdownlint@0.47.0
- osv-scanner@2.3.1
- prettier@3.7.4
- trufflehog@3.92.4
actions:
disabled:
- trunk-announce
- trunk-check-pre-push
- trunk-fmt-pre-commit
enabled:
- trunk-upgrade-available

View file

@ -1,23 +1,78 @@
# Backend Dockerfile
FROM python:3.12-slim
# ================================
# Base
# ================================
FROM python:3.12-slim AS base
WORKDIR /app
# Copier et installer les dépendances
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Installer dépendances système si nécessaire
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
# ================================
# DEV
# ================================
FROM base AS dev
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=development
# Installer dépendances dev (si vous avez un requirements.dev.txt)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Créer dossiers
RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs
# Copier le reste du projet
COPY . .
# Créer dossier persistant pour SQLite avec bonnes permissions
RUN mkdir -p /app/data && chmod 777 /app/data
# Exposer le port
EXPOSE 8000
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Lancer l'API et initialiser la DB au démarrage
# CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port 8000"]
# ================================
# STAGING
# ================================
FROM base AS staging
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=staging
CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000"]
RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/data /app/logs && chmod -R 755 /app/data /app/logs
COPY . .
# Initialiser la DB au build
RUN python init_db.py || true
EXPOSE 8002
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", "--log-level", "info"]
# ================================
# PROD
# ================================
FROM base AS prod
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=production
RUN pip install --no-cache-dir -r requirements.txt
# Créer utilisateur non-root pour la sécurité
RUN useradd -m -u 1000 appuser && \
mkdir -p /app/data /app/logs && \
chown -R appuser:appuser /app
COPY --chown=appuser:appuser . .
# Initialiser la DB au build
RUN python init_db.py || true
USER appuser
EXPOSE 8004
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8004", "--workers", "4"]

2370
api.py

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,5 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List, Optional
from enum import Enum
class Environment(str, Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
from typing import List
class Settings(BaseSettings):
@ -14,60 +7,11 @@ class Settings(BaseSettings):
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
# === Environment ===
environment: Environment = Environment.DEVELOPMENT
# === JWT & Auth ===
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
csrf_token_expire_minutes: int = 60
jwt_algorithm: str
access_token_expire_minutes: int
refresh_token_expire_days: int
# === Cookie Settings ===
cookie_domain: Optional[str] = None
cookie_secure: bool = True
cookie_samesite: str = "strict"
cookie_httponly: bool = True
cookie_access_token_name: str = "access_token"
cookie_refresh_token_name: str = "refresh_token"
cookie_csrf_token_name: str = "csrf_token"
# === Redis (Token Blacklist & Rate Limiting) ===
redis_url: str = "redis://localhost:6379/0"
redis_password: Optional[str] = None
redis_ssl: bool = False
token_blacklist_prefix: str = "blacklist:"
rate_limit_prefix: str = "ratelimit:"
# === Rate Limiting ===
rate_limit_login_attempts: int = 5
rate_limit_login_window_minutes: int = 15
rate_limit_api_requests: int = 100
rate_limit_api_window_seconds: int = 60
# === Security ===
password_min_length: int = 8
password_require_uppercase: bool = True
password_require_lowercase: bool = True
password_require_digit: bool = True
password_require_special: bool = True
account_lockout_threshold: int = 5
account_lockout_duration_minutes: int = 30
# === Fingerprint ===
fingerprint_secret: str = ""
fingerprint_components: List[str] = [
"user_agent",
"accept_language",
"accept_encoding",
]
# === Refresh Token Rotation ===
refresh_token_rotation_enabled: bool = True
refresh_token_reuse_window_seconds: int = 10
# === Sage Types ===
SAGE_TYPE_DEVIS: int = 0
SAGE_TYPE_BON_COMMANDE: int = 10
SAGE_TYPE_PREPARATION: int = 20
@ -76,15 +20,12 @@ class Settings(BaseSettings):
SAGE_TYPE_BON_AVOIR: int = 50
SAGE_TYPE_FACTURE: int = 60
# === Sage Gateway ===
sage_gateway_url: str
sage_gateway_token: str
frontend_url: str
# === Database ===
database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
# === SMTP ===
smtp_host: str
smtp_port: int = 587
smtp_user: str
@ -92,30 +33,18 @@ class Settings(BaseSettings):
smtp_from: str
smtp_use_tls: bool = True
# === Universign ===
universign_api_key: str
universign_api_url: str
# === API ===
api_host: str = "0.0.0.0"
api_port: int = 8000
api_reload: bool = True
api_host: str
api_port: int
api_reload: bool = False
# === Email Queue ===
max_email_workers: int = 3
max_retry_attempts: int = 3
retry_delay_seconds: int = 3
# === CORS ===
cors_origins: List[str] = ["*"]
@property
def is_production(self) -> bool:
return self.environment == Environment.PRODUCTION
@property
def is_development(self) -> bool:
return self.environment == Environment.DEVELOPMENT
settings = Settings()

125
config/cors_config.py Normal file
View file

@ -0,0 +1,125 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from typing import List
import os
import logging
logger = logging.getLogger(__name__)
def configure_cors_open(app: FastAPI):
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"],
max_age=3600,
)
logger.info(" CORS configuré: Mode OUVERT (sécurisé par API Keys)")
logger.info(" - Origins: * (toutes)")
logger.info(" - Headers: * (dont X-API-Key)")
logger.info(" - Credentials: False")
def configure_cors_whitelist(app: FastAPI):
allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "")
if allowed_origins_str:
allowed_origins = [
origin.strip()
for origin in allowed_origins_str.split(",")
if origin.strip()
]
else:
allowed_origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"],
max_age=3600,
)
logger.info(" CORS configuré: Mode WHITELIST")
logger.info(f" - Origins autorisées: {len(allowed_origins)}")
for origin in allowed_origins:
logger.info(f"{origin}")
def configure_cors_regex(app: FastAPI):
origin_regex = r"*"
app.add_middleware(
CORSMiddleware,
allow_origin_regex=origin_regex,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"],
max_age=3600,
)
logger.info(" CORS configuré: Mode REGEX")
logger.info(f" - Pattern: {origin_regex}")
def configure_cors_hybrid(app: FastAPI):
from starlette.middleware.base import BaseHTTPMiddleware
class HybridCORSMiddleware(BaseHTTPMiddleware):
def __init__(self, app, known_origins: List[str]):
super().__init__(app)
self.known_origins = set(known_origins)
async def dispatch(self, request, call_next):
origin = request.headers.get("origin")
if origin in self.known_origins:
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, Authorization, X-API-Key"
)
return response
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = "*"
return response
known_origins = ["*"]
app.add_middleware(HybridCORSMiddleware, known_origins=known_origins)
logger.info(" CORS configuré: Mode HYBRIDE")
logger.info(f" - Whitelist: {len(known_origins)} domaines")
logger.info(" - Fallback: * (ouvert)")
def setup_cors(app: FastAPI, mode: str = "open"):
if mode == "open":
configure_cors_open(app)
elif mode == "whitelist":
configure_cors_whitelist(app)
elif mode == "regex":
configure_cors_regex(app)
elif mode == "hybrid":
configure_cors_hybrid(app)
else:
logger.warning(
f" Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut."
)
configure_cors_open(app)

View file

@ -1,56 +1,75 @@
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, Tuple
from datetime import datetime
import logging
from typing import Optional
from jwt.exceptions import InvalidTokenError
from database import get_session
from database import User, AuditEventType
from services.token_service import TokenService
from services.audit_service import AuditService
from security.cookies import CookieManager
from security.fingerprint import DeviceFingerprint, get_client_ip
from security.csrf import CSRFProtection
from database import get_session, User
from security.auth import decode_token
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request, session: AsyncSession = Depends(get_session)
async def get_current_user_hybrid(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User:
token = CookieManager.get_access_token(request)
api_key_obj = getattr(request.state, "api_key", None)
if not token:
if api_key_obj:
if api_key_obj.user_id:
result = await session.execute(
select(User).where(User.id == api_key_obj.user_id)
)
user = result.scalar_one_or_none()
if user:
user._is_api_key_user = True
user._api_key_obj = api_key_obj
return user
virtual_user = User(
id=f"api_key_{api_key_obj.id}",
email=f"api_key_{api_key_obj.id}@virtual.local",
nom=api_key_obj.name,
prenom="API",
hashed_password="",
role="api_client",
is_active=True,
is_verified=True,
)
virtual_user._is_api_key_user = True
virtual_user._api_key_obj = api_key_obj
return virtual_user
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise",
detail="Authentification requise (JWT ou API Key)",
headers={"WWW-Authenticate": "Bearer"},
)
fingerprint_hash = DeviceFingerprint.generate_hash(request)
token = credentials.credentials
payload = await TokenService.validate_access_token(token, fingerprint_hash)
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
if not payload:
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalide ou expire",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token malformed",
detail="Token invalide: user_id manquant",
headers={"WWW-Authenticate": "Bearer"},
)
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable",
@ -58,134 +77,42 @@ async def get_current_user(
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte desactive"
)
if not user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Email non verifie"
)
if user.locked_until and user.locked_until > datetime.now():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouille",
detail="Utilisateur inactif",
)
request.state.user = user
request.state.session_id = payload.get("sid")
return user
async def get_current_user_optional(
request: Request, session: AsyncSession = Depends(get_session)
) -> Optional[User]:
try:
return await get_current_user(request, session)
except HTTPException:
return None
except InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token invalide: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
def require_role(*allowed_roles: str):
async def role_checker(user: User = Depends(get_current_user)) -> User:
def require_role_hybrid(*allowed_roles: str):
async def role_checker(user: User = Depends(get_current_user_hybrid)) -> User:
if user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Acces refuse. Roles requis: {', '.join(allowed_roles)}",
detail=f"Accès interdit. Rôles autorisés: {', '.join(allowed_roles)}",
)
return user
return role_checker
def require_verified_email():
async def email_checker(user: User = Depends(get_current_user)) -> User:
if not user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verification email requise",
)
return user
return email_checker
def is_api_key_user(user: User) -> bool:
"""Vérifie si l'utilisateur est authentifié via API Key"""
return getattr(user, "_is_api_key_user", False)
async def verify_csrf_token(
request: Request, user: User = Depends(get_current_user)
) -> None:
if CSRFProtection.is_exempt(request):
return
session_id = getattr(request.state, "session_id", None)
if not CSRFProtection.validate_request(request, session_id):
logger.warning(
f"CSRF validation echouee pour user {user.id} "
f"sur {request.method} {request.url.path}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Verification CSRF echouee"
)
def get_api_key_from_user(user: User):
"""Récupère l'objet ApiKey depuis un utilisateur (si applicable)"""
return getattr(user, "_api_key_obj", None)
async def get_auth_context(
request: Request, session: AsyncSession = Depends(get_session)
) -> Tuple[Optional[User], str, str]:
ip_address = get_client_ip(request)
fingerprint_hash = DeviceFingerprint.generate_hash(request)
try:
user = await get_current_user(request, session)
except HTTPException:
user = None
return user, ip_address, fingerprint_hash
class AuthenticatedRoute:
def __init__(
self,
require_csrf: bool = True,
allowed_roles: Optional[Tuple[str, ...]] = None,
audit_event: Optional[AuditEventType] = None,
):
self.require_csrf = require_csrf
self.allowed_roles = allowed_roles
self.audit_event = audit_event
async def __call__(
self, request: Request, session: AsyncSession = Depends(get_session)
) -> User:
user = await get_current_user(request, session)
if self.allowed_roles and user.role not in self.allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Acces refuse pour ce role",
)
if self.require_csrf and not CSRFProtection.is_exempt(request):
session_id = getattr(request.state, "session_id", None)
if not CSRFProtection.validate_request(request, session_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verification CSRF echouee",
)
if self.audit_event:
await AuditService.log_event(
session=session,
event_type=self.audit_event,
request=request,
user_id=user.id,
success=True,
)
return user
require_admin = require_role("admin")
require_manager = require_role("admin", "manager")
require_user = require_role("admin", "manager", "user")
get_current_user = get_current_user_hybrid
require_role = require_role_hybrid

View file

@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session, User
from core.dependencies import get_current_user
from sage.sage_client import SageGatewayClient
from sage_client import SageGatewayClient
from config.config import settings
import logging

View file

@ -1,12 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de création du premier utilisateur administrateur
Usage:
python create_admin.py
"""
import asyncio
import sys
from pathlib import Path
@ -28,7 +19,6 @@ async def create_admin():
print(" Création d'un compte administrateur")
print("=" * 60 + "\n")
# Saisie des informations
email = input("Email de l'admin: ").strip().lower()
if not email or "@" not in email:
print(" Email invalide")
@ -41,7 +31,6 @@ async def create_admin():
print(" Prénom et nom requis")
return False
# Mot de passe avec validation
while True:
password = input(
"Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): "
@ -67,7 +56,6 @@ async def create_admin():
print(f"\n Un utilisateur avec l'email {email} existe déjà")
return False
# Créer l'admin
admin = User(
id=str(uuid.uuid4()),
email=email,

View file

@ -152,7 +152,7 @@ templates_signature_email = {
</table>
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
<strong>🔒 Signature électronique sécurisée</strong><br>
<strong> Signature électronique sécurisée</strong><br>
Votre signature est protégée par notre partenaire de confiance <strong>Universign</strong>,
certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera
horodaté de manière infalsifiable.

View file

@ -5,26 +5,30 @@ from database.db_config import (
get_session,
close_db,
)
from database.models.generic_model import Base
from database.models.auth_models import (
User,
RefreshToken,
from database.models.generic_model import (
CacheMetadata,
AuditLog,
AuditEventType,
RefreshToken,
LoginAttempt,
UserSession,
)
from database.models.user import User
from database.models.email import EmailLog
from database.models.signature import SignatureLog
from database.models.sage_config import SageGatewayConfig
from database.Enum.status import (
from database.enum.status import (
StatutEmail,
StatutSignature,
)
from database.models.workflow import WorkflowLog
from database.models.universign import (
UniversignTransaction,
UniversignSigner,
UniversignSyncLog,
UniversignTransactionStatus,
LocalDocumentStatus,
UniversignSignerStatus,
SageDocumentType
)
__all__ = [
"engine",
@ -33,16 +37,22 @@ __all__ = [
"get_session",
"close_db",
"Base",
"User",
"RefreshToken",
"AuditLog",
"AuditEventType",
"LoginAttempt",
"UserSession",
"EmailLog",
"SignatureLog",
"WorkflowLog",
"CacheMetadata",
"AuditLog",
"StatutEmail",
"StatutSignature",
"User",
"RefreshToken",
"LoginAttempt",
"SageGatewayConfig",
"UniversignTransaction",
"UniversignSigner",
"UniversignSyncLog",
"UniversignTransactionStatus",
"LocalDocumentStatus",
"UniversignSignerStatus",
"SageDocumentType"
]

View file

@ -1,20 +1,49 @@
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import NullPool
from sqlalchemy import event, text
import logging
from config.config import settings
from database.models.generic_model import Base
logger = logging.getLogger(__name__)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db")
DATABASE_URL = settings.database_url
def _configure_sqlite_connection(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA cache_size=-64000") # 64MB
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA locking_mode=NORMAL")
cursor.close()
logger.debug("SQLite configuré avec WAL mode et busy_timeout=30s")
engine_kwargs = {
"echo": False,
"future": True,
"poolclass": NullPool,
}
if DATABASE_URL and "sqlite" in DATABASE_URL:
engine_kwargs["connect_args"] = {
"check_same_thread": False,
"timeout": 30,
}
engine = create_async_engine(DATABASE_URL, **engine_kwargs)
if DATABASE_URL and "sqlite" in DATABASE_URL:
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
_configure_sqlite_connection(dbapi_connection, connection_record)
engine = create_async_engine(
DATABASE_URL,
echo=False,
future=True,
poolclass=NullPool,
)
async_session_factory = async_sessionmaker(
engine,
@ -30,6 +59,12 @@ async def init_db():
logger.info("Tentative de connexion")
async with engine.begin() as conn:
logger.info("Connexion etablie")
if DATABASE_URL and "sqlite" in DATABASE_URL:
result = await conn.execute(text("PRAGMA journal_mode"))
journal_mode = result.scalar()
logger.info(f"SQLite journal_mode: {journal_mode}")
await conn.run_sync(Base.metadata.create_all)
logger.info("create_all execute")
@ -49,3 +84,57 @@ async def get_session() -> AsyncSession:
async def close_db():
await engine.dispose()
logger.info("Connexions DB fermées")
async def execute_with_sqlite_retry(
session: AsyncSession, statement, max_retries: int = 5, base_delay: float = 0.1
):
import asyncio
from sqlalchemy.exc import OperationalError
last_error = None
for attempt in range(max_retries):
try:
result = await session.execute(statement)
return result
except OperationalError as e:
last_error = e
if "database is locked" in str(e).lower():
delay = base_delay * (2**attempt)
logger.warning(
f"SQLite locked, tentative {attempt + 1}/{max_retries}, "
f"retry dans {delay:.2f}s"
)
await asyncio.sleep(delay)
else:
raise
raise last_error
async def commit_with_retry(
session: AsyncSession, max_retries: int = 5, base_delay: float = 0.1
):
import asyncio
from sqlalchemy.exc import OperationalError
last_error = None
for attempt in range(max_retries):
try:
await session.commit()
return
except OperationalError as e:
last_error = e
if "database is locked" in str(e).lower():
delay = base_delay * (2**attempt)
logger.warning(
f"SQLite locked lors du commit, tentative {attempt + 1}/{max_retries}, "
f"retry dans {delay:.2f}s"
)
await asyncio.sleep(delay)
else:
raise
raise last_error

18
database/enum/status.py Normal file
View file

@ -0,0 +1,18 @@
import enum
class StatutEmail(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS"
ENVOYE = "ENVOYE"
OUVERT = "OUVERT"
ERREUR = "ERREUR"
BOUNCE = "BOUNCE"
class StatutSignature(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE"
SIGNE = "SIGNE"
REFUSE = "REFUSE"
EXPIRE = "EXPIRE"

View file

@ -1,54 +0,0 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from database import init_db
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
print("\n" + "=" * 60)
print("Initialisation de la base de données délocalisée")
print("=" * 60 + "\n")
try:
logger.info("Debut de l'initialisation")
await init_db()
logger.info("Initialisation terminee")
print("\nBase de données créée avec succès !")
print("Fichier: sage_dataven.db")
print("\nTables créées:")
print(" |- email_logs (Journalisation emails)")
print(" |- signature_logs (Suivi signatures Universign)")
print(" |- workflow_logs (Transformations documents)")
print(" |- cache_metadata (Métadonnées cache)")
print(" |- audit_logs (Journal d'audit)")
print("\nProchaines étapes:")
print(" 1. Configurer le fichier .env avec les credentials")
print(" 2. Lancer la gateway Windows sur la machine Sage")
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
print(" 4. Ou avec Docker : docker-compose up -d")
print(" 5. Tester: http://IP_DU_VPS:8000/docs")
print("\n" + "=" * 60 + "\n")
return True
except Exception as e:
print(f"\nErreur lors de l'initialisation: {e}")
logger.exception("Détails de l'erreur:")
return False
if __name__ == "__main__":
result = asyncio.run(main())
sys.exit(0 if result else 1)

View file

@ -0,0 +1,73 @@
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text
from typing import Optional, List
import json
from datetime import datetime
import uuid
from database.models.generic_model import Base
class ApiKey(Base):
"""Modèle pour les clés API publiques"""
__tablename__ = "api_keys"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
key_hash = Column(String(64), unique=True, nullable=False, index=True)
key_prefix = Column(String(10), nullable=False)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
user_id = Column(String(36), nullable=True)
created_by = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
rate_limit_per_minute = Column(Integer, default=60, nullable=False)
allowed_endpoints = Column(Text, nullable=True)
total_requests = Column(Integer, default=0, nullable=False)
last_used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
expires_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<ApiKey(name='{self.name}', prefix='{self.key_prefix}', active={self.is_active})>"
class SwaggerUser(Base):
"""Modèle pour les utilisateurs autorisés à accéder au Swagger"""
__tablename__ = "swagger_users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(100), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=True)
email = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
allowed_tags = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_login = Column(DateTime, nullable=True)
@property
def allowed_tags_list(self) -> Optional[List[str]]:
if self.allowed_tags:
try:
return json.loads(self.allowed_tags)
except json.JSONDecodeError:
return None
return None
@allowed_tags_list.setter
def allowed_tags_list(self, tags: Optional[List[str]]):
self.allowed_tags = json.dumps(tags) if tags is not None else None
def __repr__(self):
return f"<SwaggerUser(username='{self.username}', active={self.is_active})>"

View file

@ -1,214 +0,0 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
Text,
ForeignKey,
Index,
Enum as SQLEnum,
)
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum
from database.models.generic_model import Base
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
nom = Column(String(100), nullable=False)
prenom = Column(String(100), nullable=False)
role = Column(String(50), default="user")
is_verified = Column(Boolean, default=False, index=True)
verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, index=True)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
reset_token = Column(String(255), nullable=True, unique=True, index=True)
reset_token_expires = Column(DateTime, nullable=True)
password_changed_at = Column(DateTime, nullable=True)
must_change_password = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_login = Column(DateTime, nullable=True)
last_login_ip = Column(String(45), nullable=True)
refresh_tokens = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
audit_logs = relationship(
"AuditLog", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<User {self.email} verified={self.is_verified}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
token_hash = Column(String(64), unique=True, nullable=False, index=True)
token_id = Column(String(32), unique=True, nullable=False, index=True)
fingerprint_hash = Column(String(64), nullable=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
is_revoked = Column(Boolean, default=False, index=True)
revoked_at = Column(DateTime, nullable=True)
revoked_reason = Column(String(100), nullable=True)
is_used = Column(Boolean, default=False)
used_at = Column(DateTime, nullable=True)
replaced_by = Column(String(36), nullable=True)
expires_at = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_used_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("ix_refresh_tokens_user_valid", "user_id", "is_revoked", "expires_at"),
)
def __repr__(self):
return f"<RefreshToken {self.token_id[:8]}... user={self.user_id}>"
class AuditEventType(str, Enum):
LOGIN_SUCCESS = "login_success"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
PASSWORD_CHANGE = "password_change"
PASSWORD_RESET_REQUEST = "password_reset_request"
PASSWORD_RESET_COMPLETE = "password_reset_complete"
EMAIL_VERIFICATION = "email_verification"
ACCOUNT_LOCKED = "account_locked"
ACCOUNT_UNLOCKED = "account_unlocked"
TOKEN_REFRESH = "token_refresh"
TOKEN_REVOKED = "token_revoked"
SUSPICIOUS_ACTIVITY = "suspicious_activity"
SESSION_CREATED = "session_created"
SESSION_TERMINATED = "session_terminated"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
event_type = Column(SQLEnum(AuditEventType), nullable=False, index=True)
event_description = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True, index=True)
user_agent = Column(String(500), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
resource_type = Column(String(50), nullable=True)
resource_id = Column(String(100), nullable=True)
request_method = Column(String(10), nullable=True)
request_path = Column(String(500), nullable=True)
meta = Column("metadata", Text, nullable=True)
success = Column(Boolean, default=True)
failure_reason = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
user = relationship("User", back_populates="audit_logs")
__table_args__ = (
Index("ix_audit_logs_user_event", "user_id", "event_type", "created_at"),
Index("ix_audit_logs_ip_event", "ip_address", "event_type", "created_at"),
)
def __repr__(self):
return f"<AuditLog {self.event_type.value} user={self.user_id}>"
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=True, index=True)
user_agent = Column(String(500), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
success = Column(Boolean, default=False, index=True)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
__table_args__ = (
Index("ix_login_attempts_email_time", "email", "timestamp"),
Index("ix_login_attempts_ip_time", "ip_address", "timestamp"),
)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"
class UserSession(Base):
__tablename__ = "user_sessions"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_token_hash = Column(String(64), unique=True, nullable=False, index=True)
refresh_token_id = Column(String(36), nullable=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
location = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, index=True)
terminated_at = Column(DateTime, nullable=True)
termination_reason = Column(String(100), nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_activity = Column(DateTime, default=datetime.now, nullable=False)
expires_at = Column(DateTime, nullable=False)
__table_args__ = (Index("ix_user_sessions_user_active", "user_id", "is_active"),)
def __repr__(self):
return f"<UserSession {self.id[:8]}... user={self.user_id}>"

View file

@ -8,7 +8,7 @@ from sqlalchemy import (
)
from datetime import datetime
from database.models.generic_model import Base
from database.Enum.status import StatutEmail
from database.enum.status import StatutEmail
class EmailLog(Base):

View file

@ -5,6 +5,7 @@ from sqlalchemy import (
DateTime,
Float,
Text,
Boolean,
)
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
@ -28,3 +29,63 @@ class CacheMetadata(Base):
def __repr__(self):
return f"<CacheMetadata type={self.cache_type} items={self.item_count}>"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
action = Column(String(100), nullable=False, index=True)
ressource_type = Column(String(50), nullable=True)
ressource_id = Column(String(100), nullable=True, index=True)
utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True)
succes = Column(Boolean, default=True)
details = Column(Text, nullable=True)
erreur = Column(Text, nullable=True)
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"

View file

@ -22,6 +22,9 @@ class SageGatewayConfig(Base):
gateway_url = Column(String(500), nullable=False)
gateway_token = Column(String(255), nullable=False)
sage_database = Column(String(255), nullable=True)
sage_company = Column(String(255), nullable=True)
is_active = Column(Boolean, default=False, index=True)
is_default = Column(Boolean, default=False)
priority = Column(Integer, default=0)

View file

@ -9,7 +9,7 @@ from sqlalchemy import (
)
from datetime import datetime
from database.models.generic_model import Base
from database.Enum.status import StatutSignature
from database.enum.status import StatutSignature
class SignatureLog(Base):

View file

@ -0,0 +1,272 @@
from sqlalchemy import (
Column,
String,
DateTime,
Boolean,
Integer,
Text,
Enum as SQLEnum,
ForeignKey,
Index,
)
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum
from database.models.generic_model import Base
class UniversignTransactionStatus(str, Enum):
DRAFT = "draft"
READY = "ready"
STARTED = "started"
COMPLETED = "completed"
CLOSED = "closed"
REFUSED = "refused"
EXPIRED = "expired"
CANCELED = "canceled"
FAILED = "failed"
class UniversignSignerStatus(str, Enum):
WAITING = "waiting"
OPEN = "open"
VIEWED = "viewed"
SIGNED = "signed"
COMPLETED = "completed"
REFUSED = "refused"
EXPIRED = "expired"
STALLED = "stalled"
UNKNOWN = "unknown"
class LocalDocumentStatus(str, Enum):
PENDING = "EN_ATTENTE"
IN_PROGRESS = "EN_COURS"
SIGNED = "SIGNE"
REJECTED = "REFUSE"
EXPIRED = "EXPIRE"
ERROR = "ERREUR"
class SageDocumentType(int, Enum):
DEVIS = 0
BON_COMMANDE = 10
PREPARATION = 20
BON_LIVRAISON = 30
BON_RETOUR = 40
BON_AVOIR = 50
FACTURE = 60
class UniversignTransaction(Base):
__tablename__ = "universign_transactions"
id = Column(String(36), primary_key=True)
transaction_id = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="ID Universign (ex: tr_abc123)",
)
sage_document_id = Column(
String(50),
nullable=False,
index=True,
comment="Numéro du document Sage (ex: DE00123)",
)
sage_document_type = Column(
SQLEnum(SageDocumentType), nullable=False, comment="Type de document Sage"
)
universign_status = Column(
SQLEnum(UniversignTransactionStatus),
nullable=False,
default=UniversignTransactionStatus.DRAFT,
index=True,
comment="Statut brut Universign",
)
universign_status_updated_at = Column(
DateTime, nullable=True, comment="Dernière MAJ du statut Universign"
)
local_status = Column(
SQLEnum(LocalDocumentStatus),
nullable=False,
default=LocalDocumentStatus.PENDING,
index=True,
comment="Statut métier simplifié pour l'UI",
)
signer_url = Column(Text, nullable=True, comment="URL de signature")
document_url = Column(Text, nullable=True, comment="URL du document signé")
signed_document_path = Column(
Text, nullable=True, comment="Chemin local du PDF signé"
)
signed_document_downloaded_at = Column(
DateTime, nullable=True, comment="Date de téléchargement du document"
)
signed_document_size_bytes = Column(
Integer, nullable=True, comment="Taille du fichier en octets"
)
download_attempts = Column(
Integer, default=0, comment="Nombre de tentatives de téléchargement"
)
download_error = Column(
Text, nullable=True, comment="Dernière erreur de téléchargement"
)
certificate_url = Column(Text, nullable=True, comment="URL du certificat")
signers_data = Column(
Text, nullable=True, comment="JSON des signataires (snapshot)"
)
requester_email = Column(String(255), nullable=True)
requester_name = Column(String(255), nullable=True)
document_name = Column(String(500), nullable=True)
created_at = Column(
DateTime,
default=datetime.now,
nullable=False,
comment="Date de création locale",
)
sent_at = Column(
DateTime, nullable=True, comment="Date d'envoi Universign (started)"
)
signed_at = Column(DateTime, nullable=True, comment="Date de signature complète")
refused_at = Column(DateTime, nullable=True)
expired_at = Column(DateTime, nullable=True)
canceled_at = Column(DateTime, nullable=True)
last_synced_at = Column(
DateTime, nullable=True, comment="Dernière sync réussie avec Universign"
)
sync_attempts = Column(Integer, default=0, comment="Nombre de tentatives de sync")
sync_error = Column(Text, nullable=True)
is_test = Column(
Boolean, default=False, comment="Transaction en environnement .alpha"
)
needs_sync = Column(
Boolean, default=True, index=True, comment="À synchroniser avec Universign"
)
webhook_received = Column(Boolean, default=False, comment="Webhook Universign reçu")
signers = relationship(
"UniversignSigner", back_populates="transaction", cascade="all, delete-orphan"
)
sync_logs = relationship(
"UniversignSyncLog", back_populates="transaction", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_sage_doc", "sage_document_id", "sage_document_type"),
Index("idx_sync_status", "needs_sync", "universign_status"),
Index("idx_dates", "created_at", "signed_at"),
)
def __repr__(self):
return (
f"<UniversignTransaction {self.transaction_id} "
f"sage={self.sage_document_id} "
f"status={self.universign_status.value}>"
)
class UniversignSigner(Base):
__tablename__ = "universign_signers"
id = Column(String(36), primary_key=True)
transaction_id = Column(
String(36),
ForeignKey("universign_transactions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
email = Column(String(255), nullable=False, index=True)
name = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
status = Column(
SQLEnum(UniversignSignerStatus),
default=UniversignSignerStatus.WAITING,
nullable=False,
)
viewed_at = Column(DateTime, nullable=True)
signed_at = Column(DateTime, nullable=True)
refused_at = Column(DateTime, nullable=True)
refusal_reason = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
signature_method = Column(String(50), nullable=True)
order_index = Column(Integer, default=0)
transaction = relationship("UniversignTransaction", back_populates="signers")
def __repr__(self):
return f"<UniversignSigner {self.email} status={self.status.value}>"
class UniversignSyncLog(Base):
__tablename__ = "universign_sync_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
transaction_id = Column(
String(36),
ForeignKey("universign_transactions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sync_type = Column(String(50), nullable=False, comment="webhook, polling, manual")
sync_timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
previous_status = Column(String(50), nullable=True)
new_status = Column(String(50), nullable=True)
changes_detected = Column(Text, nullable=True, comment="JSON des changements")
success = Column(Boolean, default=True)
error_message = Column(Text, nullable=True)
http_status_code = Column(Integer, nullable=True)
response_time_ms = Column(Integer, nullable=True)
transaction = relationship("UniversignTransaction", back_populates="sync_logs")
def __repr__(self):
return f"<SyncLog {self.sync_type} at {self.sync_timestamp}>"
class UniversignConfig(Base):
__tablename__ = "universign_configs"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=True, index=True)
environment = Column(
String(50), nullable=False, default="alpha", comment="alpha, prod"
)
api_url = Column(String(500), nullable=False)
api_key = Column(String(500), nullable=False, comment="À chiffrer")
webhook_url = Column(String(500), nullable=True)
webhook_secret = Column(String(255), nullable=True)
auto_sync_enabled = Column(Boolean, default=True)
sync_interval_minutes = Column(Integer, default=5)
signature_expiry_days = Column(Integer, default=30)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<UniversignConfig {self.environment}>"

24
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,24 @@
services:
backend:
container_name: dev-sage-api
build:
context: .
target: dev
env_file: .env
volumes:
- .:/app
- /app/__pycache__
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8000:8000"
environment:
ENV: development
DEBUG: "true"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3

23
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,23 @@
services:
backend:
container_name: prod_sage_api
build:
context: .
target: prod
env_file: .env.production
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8004:8004"
environment:
ENV: production
DEBUG: "false"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8004/"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s

View file

@ -0,0 +1,22 @@
services:
backend:
container_name: staging_sage_api
build:
context: .
target: staging
env_file: .env.staging
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8002:8002"
environment:
ENV: staging
DEBUG: "false"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -1,11 +1,4 @@
services:
vps-sage-api:
build: .
container_name: vps-sage-api
env_file: .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8000:8000"
restart: unless-stopped
backend:
build:
context: .

1079
email_queue.py Normal file

File diff suppressed because it is too large Load diff

35
init_db.py Normal file
View file

@ -0,0 +1,35 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from database import init_db
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
try:
logger.info("Debut de l'initialisation")
await init_db()
logger.info("Initialisation terminee")
print("\nInitialisation terminee")
print("\nBase de données créée avec succès !")
return True
except Exception as e:
print(f"\nErreur lors de l'initialisation: {e}")
logger.exception("Détails de l'erreur:")
return False
if __name__ == "__main__":
result = asyncio.run(main())
sys.exit(0 if result else 1)

View file

@ -1,181 +1,295 @@
from fastapi import Request, Response, status
from fastapi import Request, status
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from typing import Set
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Receive, Send
from sqlalchemy import select
from typing import Callable, Optional
from datetime import datetime
import logging
import time
from config.config import settings
from security.csrf import CSRFProtection
from security.fingerprint import get_client_ip
from services.redis_service import redis_service
import base64
import json
logger = logging.getLogger(__name__)
security = HTTPBasic()
RATE_LIMIT_EXEMPT_PATHS: Set[str] = {
"/health",
class SwaggerAuthMiddleware:
PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"]
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive=receive)
path = request.url.path
if not any(path.startswith(p) for p in self.PROTECTED_PATHS):
await self.app(scope, receive, send)
return
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
response = JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Authentification requise pour la documentation"},
headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'},
)
await response(scope, receive, send)
return
try:
encoded_credentials = auth_header.split(" ")[1]
decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8")
username, password = decoded_credentials.split(":", 1)
credentials = HTTPBasicCredentials(username=username, password=password)
swagger_user = await self._verify_credentials(credentials)
if not swagger_user:
response = JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Identifiants invalides"},
headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'},
)
await response(scope, receive, send)
return
if "state" not in scope:
scope["state"] = {}
scope["state"]["swagger_user"] = swagger_user
logger.info(
f"✓ Swagger auth: {swagger_user['username']} - tags: {swagger_user.get('allowed_tags', 'ALL')}"
)
except Exception as e:
logger.error(f" Erreur parsing auth header: {e}")
response = JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={"detail": "Format d'authentification invalide"},
headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'},
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
async def _verify_credentials(
self, credentials: HTTPBasicCredentials
) -> Optional[dict]:
from database.db_config import async_session_factory
from database.models.api_key import SwaggerUser
from security.auth import verify_password
try:
async with async_session_factory() as session:
result = await session.execute(
select(SwaggerUser).where(
SwaggerUser.username == credentials.username
)
)
swagger_user = result.scalar_one_or_none()
if swagger_user and swagger_user.is_active:
if verify_password(
credentials.password, swagger_user.hashed_password
):
swagger_user.last_login = datetime.now()
await session.commit()
logger.info(f"✓ Accès Swagger autorisé: {credentials.username}")
return {
"id": swagger_user.id,
"username": swagger_user.username,
"allowed_tags": swagger_user.allowed_tags_list,
"is_active": swagger_user.is_active,
}
logger.warning(f"✗ Accès Swagger refusé: {credentials.username}")
return None
except Exception as e:
logger.error(f" Erreur vérification credentials: {e}", exc_info=True)
return None
class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware):
EXCLUDED_PATHS = [
"/docs",
"/redoc",
"/openapi.json",
}
"/",
"/health",
"/auth",
"/api-keys/verify",
"/universign/webhook",
]
def _is_excluded_path(self, path: str) -> bool:
"""Vérifie si le chemin est exclu de l'authentification API Key"""
if path == "/":
return True
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
for excluded in self.EXCLUDED_PATHS:
if excluded == "/":
continue
if path == excluded or path.startswith(excluded + "/"):
return True
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return False
if settings.is_production:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
async def dispatch(self, request: Request, call_next: Callable):
path = request.url.path
method = request.method
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()"
)
return response
class CSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if CSRFProtection.is_exempt(request):
if self._is_excluded_path(path):
return await call_next(request)
if not CSRFProtection.validate_double_submit(request):
auth_header = request.headers.get("Authorization")
api_key_header = request.headers.get("X-API-Key")
if api_key_header:
api_key_header = api_key_header.strip()
if not api_key_header or api_key_header == "":
api_key_header = None
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token.startswith("sdk_live_"):
logger.warning(
f"CSRF validation echouee: {request.method} {request.url.path} "
f"depuis {get_client_ip(request)}"
" API Key envoyée dans Authorization au lieu de X-API-Key"
)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "Verification CSRF echouee"},
return await self._handle_api_key_auth(
request, token, path, method, call_next
)
logger.debug(f"JWT détecté pour {method} {path} → délégation à FastAPI")
request.state.authenticated_via = "jwt"
return await call_next(request)
if api_key_header:
logger.debug(f" API Key détectée pour {method} {path}")
return await self._handle_api_key_auth(
request, api_key_header, path, method, call_next
)
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
path = request.url.path.rstrip("/")
if path in RATE_LIMIT_EXEMPT_PATHS:
logger.debug(f" Aucune auth pour {method} {path} → délégation à FastAPI")
return await call_next(request)
ip = get_client_ip(request)
key = f"api:{ip}"
window_seconds = settings.rate_limit_api_window_seconds
max_requests = settings.rate_limit_api_requests
async def _handle_api_key_auth(
self,
request: Request,
api_key: str,
path: str,
method: str,
call_next: Callable,
):
try:
count = await redis_service.increment_rate_limit(key, window_seconds)
remaining = max(0, max_requests - count)
from database.db_config import async_session_factory
from services.api_key import ApiKeyService
response = await call_next(request)
async with async_session_factory() as session:
service = ApiKeyService(session)
response.headers["X-RateLimit-Limit"] = str(max_requests)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(window_seconds)
api_key_obj = await service.verify_api_key(api_key)
if count > max_requests:
logger.warning(
f"Rate limit depasse pour IP {ip}: {count}/{max_requests}"
)
if not api_key_obj:
logger.warning(f"🔒 Clé API invalide: {method} {path}")
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Limite de requetes atteinte"},
headers={
"X-RateLimit-Limit": str(max_requests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(window_seconds),
"Retry-After": str(window_seconds),
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"detail": "Clé API invalide ou expirée",
"hint": "Vérifiez votre clé X-API-Key",
},
)
return response
except Exception as e:
logger.error(f"Erreur rate limiting: {e}")
return await call_next(request)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
start_time = time.time()
ip = get_client_ip(request)
method = request.method
path = request.url.path
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
log_level = logging.INFO
if response.status_code >= 500:
log_level = logging.ERROR
elif response.status_code >= 400:
log_level = logging.WARNING
logger.log(
log_level,
f"{method} {path} - {response.status_code} - {duration_ms:.2f}ms - {ip}",
is_allowed, rate_info = await service.check_rate_limit(api_key_obj)
if not is_allowed:
logger.warning(f"⏱️ Rate limit: {api_key_obj.name}")
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Rate limit dépassé"},
headers={
"X-RateLimit-Limit": str(rate_info["limit"]),
"X-RateLimit-Remaining": "0",
},
)
return response
has_access = await service.check_endpoint_access(api_key_obj, path)
if not has_access:
allowed = (
json.loads(api_key_obj.allowed_endpoints)
if api_key_obj.allowed_endpoints
else ["Tous"]
)
class FingerprintValidationMiddleware(BaseHTTPMiddleware):
VALIDATION_PATHS: Set[str] = {
"/auth/refresh",
"/auth/logout",
}
logger.warning(
f"🚫 ACCÈS REFUSÉ: {api_key_obj.name}\n"
f" Endpoint demandé: {path}\n"
f" Endpoints autorisés: {allowed}"
)
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
path = request.url.path.rstrip("/")
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={
"detail": "Accès non autorisé à cet endpoint",
"endpoint_requested": path,
"api_key_name": api_key_obj.name,
"allowed_endpoints": allowed,
"hint": "Cette clé API n'a pas accès à cet endpoint.",
},
)
if path not in self.VALIDATION_PATHS:
return await call_next(request)
request.state.api_key = api_key_obj
request.state.authenticated_via = "api_key"
logger.info(f" ACCÈS AUTORISÉ: {api_key_obj.name}{method} {path}")
return await call_next(request)
def setup_security_middleware(app) -> None:
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(FingerprintValidationMiddleware)
async def init_security_services() -> None:
try:
await redis_service.connect()
logger.info("Services de securite initialises")
except Exception as e:
logger.warning(f"Redis non disponible, fonctionnement en mode degrade: {e}")
logger.error(f"💥 Erreur validation API Key: {e}", exc_info=True)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": f"Erreur interne: {str(e)}"},
)
async def shutdown_security_services() -> None:
try:
await redis_service.disconnect()
logger.info("Services de securite arretes")
except Exception as e:
logger.error(f"Erreur arret services securite: {e}")
ApiKeyMiddleware = ApiKeyMiddlewareHTTP
def get_api_key_from_request(request: Request) -> Optional:
"""Récupère l'objet ApiKey depuis la requête si présent"""
return getattr(request.state, "api_key", None)
def get_auth_method(request: Request) -> str:
"""Retourne la méthode d'authentification utilisée"""
return getattr(request.state, "authenticated_via", "none")
def get_swagger_user_from_request(request: Request) -> Optional[dict]:
"""Récupère l'utilisateur Swagger depuis la requête"""
return getattr(request.state, "swagger_user", None)
__all__ = [
"SwaggerAuthMiddleware",
"ApiKeyMiddlewareHTTP",
"ApiKeyMiddleware",
"get_api_key_from_request",
"get_auth_method",
"get_swagger_user_from_request",
]

View file

@ -1,14 +1,10 @@
fastapi
uvicorn[standard]
starlette
structlog
pydantic
pydantic-settings
reportlab
requests
msal
aiosmtplib
python-multipart
email-validator
@ -17,13 +13,9 @@ python-dotenv
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.2.0
PyJWT
sqlalchemy[asyncio]
sqlalchemy
aiosqlite
tenacity
asyncpg
httpx
redis[hiredis]

154
routes/api_keys.py Normal file
View file

@ -0,0 +1,154 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
import logging
from database import get_session, User
from core.dependencies import get_current_user, require_role
from services.api_key import ApiKeyService, api_key_to_response
from schemas.api_key import (
ApiKeyCreate,
ApiKeyCreatedResponse,
ApiKeyResponse,
ApiKeyList,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-keys", tags=["API Keys Management"])
@router.post(
"",
response_model=ApiKeyCreatedResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_role("admin", "super_admin"))],
)
async def create_api_key(
data: ApiKeyCreate,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
service = ApiKeyService(session)
api_key_obj, api_key_plain = await service.create_api_key(
name=data.name,
description=data.description,
created_by=user.email,
user_id=user.id,
expires_in_days=data.expires_in_days,
rate_limit_per_minute=data.rate_limit_per_minute,
allowed_endpoints=data.allowed_endpoints,
)
logger.info(f" Clé API créée par {user.email}: {data.name}")
response_data = api_key_to_response(api_key_obj)
response_data["api_key"] = api_key_plain
return ApiKeyCreatedResponse(**response_data)
@router.get("", response_model=ApiKeyList)
async def list_api_keys(
include_revoked: bool = Query(False, description="Inclure les clés révoquées"),
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
service = ApiKeyService(session)
user_id = None if user.role in ["admin", "super_admin"] else user.id
keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id)
items = [ApiKeyResponse(**api_key_to_response(k)) for k in keys]
return ApiKeyList(total=len(items), items=items)
@router.get("/{key_id}", response_model=ApiKeyResponse)
async def get_api_key(
key_id: str,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
"""Récupérer une clé API par son ID"""
service = ApiKeyService(session)
api_key_obj = await service.get_by_id(key_id)
if not api_key_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Clé API {key_id} introuvable",
)
if user.role not in ["admin", "super_admin"]:
if api_key_obj.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accès refusé à cette clé",
)
return ApiKeyResponse(**api_key_to_response(api_key_obj))
@router.delete("/{key_id}", status_code=status.HTTP_200_OK)
async def revoke_api_key(
key_id: str,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
service = ApiKeyService(session)
api_key_obj = await service.get_by_id(key_id)
if not api_key_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Clé API {key_id} introuvable",
)
if user.role not in ["admin", "super_admin"]:
if api_key_obj.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Accès refusé à cette clé",
)
success = await service.revoke_api_key(key_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Erreur lors de la révocation",
)
logger.info(f" Clé API révoquée par {user.email}: {api_key_obj.name}")
return {
"success": True,
"message": f"Clé API '{api_key_obj.name}' révoquée avec succès",
}
@router.post("/verify", status_code=status.HTTP_200_OK)
async def verify_api_key_endpoint(
api_key: str = Query(..., description="Clé API à vérifier"),
session: AsyncSession = Depends(get_session),
):
service = ApiKeyService(session)
api_key_obj = await service.verify_api_key(api_key)
if not api_key_obj:
return {
"valid": False,
"message": "Clé API invalide, expirée ou révoquée",
}
return {
"valid": True,
"message": "Clé API valide",
"key_name": api_key_obj.name,
"rate_limit": api_key_obj.rate_limit_per_minute,
"expires_at": api_key_obj.expires_at,
}

View file

@ -1,29 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select
from sqlalchemy import select
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime, timedelta
from typing import Optional, List
from typing import Optional
import uuid
import logging
from config.config import settings
from database import get_session
from database import User, RefreshToken, AuditEventType
from database import get_session, User, RefreshToken, LoginAttempt
from core.dependencies import get_current_user
from security.auth import (
hash_password,
verify_password,
validate_password_strength,
create_access_token,
create_refresh_token,
decode_token,
generate_verification_token,
generate_reset_token,
hash_token,
)
from security.cookies import CookieManager, set_auth_cookies
from security.fingerprint import DeviceFingerprint, get_client_ip
from security.rate_limiter import RateLimiter
from services.token_service import TokenService
from services.audit_service import AuditService
from services.email_service import AuthEmailService
from core.dependencies import get_current_user
from config.config import settings
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
@ -31,50 +29,82 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
password: str = Field(..., min_length=8)
nom: str = Field(..., min_length=2, max_length=100)
prenom: str = Field(..., min_length=2, max_length=100)
class LoginRequest(BaseModel):
class Login(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1, max_length=128)
password: str
class TokenResponse(BaseModel):
message: str
user: dict
expires_in: int
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 86400
class ForgotPasswordRequest(BaseModel):
class RefreshTokenRequest(BaseModel):
refresh_token: str
class ForgotPassword(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
class ResetPassword(BaseModel):
token: str
new_password: str = Field(..., min_length=8, max_length=128)
new_password: str = Field(..., min_length=8)
class VerifyEmailRequest(BaseModel):
class VerifyEmail(BaseModel):
token: str
class ResendVerificationRequest(BaseModel):
class ResendVerification(BaseModel):
email: EmailStr
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8, max_length=128)
async def log_login_attempt(
session: AsyncSession,
email: str,
ip: str,
user_agent: str,
success: bool,
failure_reason: Optional[str] = None,
):
attempt = LoginAttempt(
email=email,
ip_address=ip,
user_agent=user_agent,
success=success,
failure_reason=failure_reason,
timestamp=datetime.now(),
)
session.add(attempt)
await session.commit()
class SessionResponse(BaseModel):
id: str
device_info: Optional[str]
ip_address: Optional[str]
created_at: str
last_used_at: Optional[str]
async def check_rate_limit(
session: AsyncSession, email: str, ip: str
) -> tuple[bool, str]:
time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute(
select(LoginAttempt).where(
LoginAttempt.email == email,
LoginAttempt.success,
LoginAttempt.timestamp >= time_window,
)
)
failed_attempts = result.scalars().all()
if len(failed_attempts) >= 15:
return False, "Trop de tentatives échouées. Réessayez dans 15 minutes."
return True, ""
@router.post("/register", status_code=status.HTTP_201_CREATED)
@ -83,18 +113,12 @@ async def register(
request: Request,
session: AsyncSession = Depends(get_session),
):
ip = get_client_ip(request)
result = await session.execute(select(User).where(User.email == data.email))
existing_user = result.scalar_one_or_none()
allowed, error_msg = await RateLimiter.check_registration_rate_limit(ip)
if not allowed:
if existing_user:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
)
result = await session.execute(select(User).where(User.email == data.email.lower()))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est deja utilise"
status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé"
)
is_valid, error_msg = validate_password_strength(data.password)
@ -119,233 +143,23 @@ async def register(
await session.commit()
base_url = str(request.base_url).rstrip("/")
AuthEmailService.send_verification_email(data.email, verification_token, base_url)
email_sent = AuthEmailService.send_verification_email(
data.email, verification_token, base_url
)
logger.info(f"Nouvel utilisateur inscrit: {data.email}")
if not email_sent:
logger.warning(f"Échec envoi email vérification pour {data.email}")
logger.info(f" Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})")
return {
"success": True,
"message": "Inscription reussie. Consultez votre email pour verifier votre compte.",
"message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.",
"user_id": new_user.id,
"email": data.email,
}
@router.post("/login")
async def login(
data: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
ip = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
fingerprint_hash = DeviceFingerprint.generate_hash(request)
allowed, error_msg, _ = await RateLimiter.check_login_rate_limit(
data.email.lower(), ip
)
if not allowed:
await AuditService.log_login_failed(session, request, data.email, "rate_limit")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
)
result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.hashed_password):
await RateLimiter.record_login_attempt(data.email.lower(), ip, success=False)
await AuditService.record_login_attempt(
session, request, data.email, False, "invalid_credentials"
)
if user:
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
if user.failed_login_attempts >= settings.account_lockout_threshold:
user.locked_until = datetime.now() + timedelta(
minutes=settings.account_lockout_duration_minutes
)
await AuditService.log_account_locked(
session, request, user.id, "too_many_failed_attempts"
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Compte verrouille. Reessayez dans {settings.account_lockout_duration_minutes} minutes.",
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect",
)
if not user.is_active:
await AuditService.log_login_failed(
session, request, data.email, "account_disabled", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte desactive"
)
if not user.is_verified:
await AuditService.log_login_failed(
session, request, data.email, "email_not_verified", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email non verifie. Consultez votre boite de reception.",
)
if user.locked_until and user.locked_until > datetime.now():
await AuditService.log_login_failed(
session, request, data.email, "account_locked", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouille",
)
user.failed_login_attempts = 0
user.locked_until = None
user.last_login = datetime.now()
user.last_login_ip = ip
(
access_token,
refresh_token,
csrf_token,
session_id,
) = await TokenService.create_token_pair(
session=session,
user=user,
fingerprint_hash=fingerprint_hash,
device_info=user_agent,
ip_address=ip,
)
await session.commit()
await RateLimiter.record_login_attempt(data.email.lower(), ip, success=True)
await AuditService.log_login_success(session, request, user.id, user.email)
set_auth_cookies(response, access_token, refresh_token, csrf_token)
logger.info(f"Connexion reussie: {user.email} depuis {ip}")
return TokenResponse(
message="Connexion reussie",
user={
"id": user.id,
"email": user.email,
"nom": user.nom,
"prenom": user.prenom,
"role": user.role,
},
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post("/refresh")
async def refresh_tokens(
request: Request, response: Response, session: AsyncSession = Depends(get_session)
):
refresh_token = CookieManager.get_refresh_token(request)
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token manquant"
)
ip = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
fingerprint_hash = DeviceFingerprint.generate_hash(request)
result = await TokenService.refresh_tokens(
session=session,
refresh_token=refresh_token,
fingerprint_hash=fingerprint_hash,
device_info=user_agent,
ip_address=ip,
)
if not result:
CookieManager.clear_all_auth_cookies(response)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token invalide ou expire",
)
new_access, new_refresh, new_csrf, session_id = result
await session.commit()
set_auth_cookies(response, new_access, new_refresh, new_csrf)
logger.debug("Tokens rafraichis avec succes")
return {
"message": "Tokens rafraichis",
"expires_in": settings.access_token_expire_minutes * 60,
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
refresh_token = CookieManager.get_refresh_token(request)
if refresh_token:
await TokenService.revoke_token(
session=session, refresh_token=refresh_token, reason="user_logout"
)
await AuditService.log_logout(session, request, user.id)
await session.commit()
CookieManager.clear_all_auth_cookies(response)
logger.info(f"Deconnexion: {user.email}")
return {"success": True, "message": "Deconnexion reussie"}
@router.post("/logout-all")
async def logout_all_sessions(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
count = await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="user_logout_all"
)
await AuditService.log_event(
session=session,
event_type=AuditEventType.SESSION_TERMINATED,
request=request,
user_id=user.id,
description=f"Toutes les sessions terminees ({count} tokens revoques)",
)
await session.commit()
CookieManager.clear_all_auth_cookies(response)
logger.info(f"Toutes les sessions terminees pour {user.email}: {count} tokens")
return {"success": True, "message": f"{count} session(s) terminee(s)"}
@router.get("/verify-email")
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(User).where(User.verification_token == token))
@ -354,16 +168,13 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
if not user:
return {
"success": False,
"message": "Token de verification invalide ou deja utilise.",
"message": "Token de vérification invalide ou déjà utilisé.",
}
if (
user.verification_token_expires
and user.verification_token_expires < datetime.now()
):
if user.verification_token_expires < datetime.now():
return {
"success": False,
"message": "Token expire. Demandez un nouveau lien de verification.",
"message": "Token expiré. Veuillez demander un nouvel email de vérification.",
"expired": True,
}
@ -372,19 +183,18 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
user.verification_token_expires = None
await session.commit()
logger.info(f"Email verifie: {user.email}")
logger.info(f" Email vérifié: {user.email}")
return {
"success": True,
"message": "Email verifie avec succes. Vous pouvez maintenant vous connecter.",
"message": " Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
"email": user.email,
}
@router.post("/verify-email")
async def verify_email_post(
data: VerifyEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
data: VerifyEmail, session: AsyncSession = Depends(get_session)
):
result = await session.execute(
select(User).where(User.verification_token == data.token)
@ -394,40 +204,31 @@ async def verify_email_post(
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de verification invalide",
detail="Token de vérification invalide",
)
if (
user.verification_token_expires
and user.verification_token_expires < datetime.now()
):
if user.verification_token_expires < datetime.now():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token expire. Demandez un nouveau lien de verification.",
detail="Token expiré. Demandez un nouvel email de vérification.",
)
user.is_verified = True
user.verification_token = None
user.verification_token_expires = None
await AuditService.log_event(
session=session,
event_type=AuditEventType.EMAIL_VERIFICATION,
request=request,
user_id=user.id,
description="Email verifie avec succes",
)
await session.commit()
logger.info(f"Email verifie: {user.email}")
logger.info(f" Email vérifié: {user.email}")
return {"success": True, "message": "Email verifie avec succes."}
return {
"success": True,
"message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
}
@router.post("/resend-verification")
async def resend_verification(
data: ResendVerificationRequest,
data: ResendVerification,
request: Request,
session: AsyncSession = Depends(get_session),
):
@ -437,12 +238,12 @@ async def resend_verification(
if not user:
return {
"success": True,
"message": "Si cet email existe, un nouveau lien a ete envoye.",
"message": "Si cet email existe, un nouveau lien de vérification a été envoyé.",
}
if user.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est deja verifie"
status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié"
)
verification_token = generate_verification_token()
@ -453,21 +254,18 @@ async def resend_verification(
base_url = str(request.base_url).rstrip("/")
AuthEmailService.send_verification_email(user.email, verification_token, base_url)
return {"success": True, "message": "Un nouveau lien de verification a ete envoye."}
return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."}
@router.post("/forgot-password")
async def forgot_password(
data: ForgotPasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
@router.post("/login", response_model=TokenResponse)
async def login(
data: Login, request: Request, session: AsyncSession = Depends(get_session)
):
ip = get_client_ip(request)
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown")
allowed, error_msg = await RateLimiter.check_password_reset_rate_limit(
data.email.lower(), ip
)
if not allowed:
is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip)
if not is_allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
)
@ -475,14 +273,161 @@ async def forgot_password(
result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
await AuditService.log_password_reset_request(
session, request, data.email, user.id if user else None
if not user or not verify_password(data.password, user.hashed_password):
await log_login_attempt(
session,
data.email.lower(),
ip,
user_agent,
False,
"Identifiants incorrects",
)
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= 15:
user.locked_until = datetime.now() + timedelta(minutes=15)
await session.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.",
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect",
)
if not user.is_active:
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
)
if not user.is_verified:
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Email non vérifié"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email non vérifié. Consultez votre boîte de réception.",
)
if user.locked_until and user.locked_until > datetime.now():
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte verrouillé"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouillé",
)
user.failed_login_attempts = 0
user.locked_until = None
user.last_login = datetime.now()
access_token = create_access_token(
{"sub": user.id, "email": user.email, "role": user.role}
)
refresh_token_jwt = create_refresh_token(user.id)
refresh_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_token(refresh_token_jwt),
device_info=user_agent[:500],
ip_address=ip,
expires_at=datetime.now() + timedelta(days=7),
created_at=datetime.now(),
)
session.add(refresh_token_record)
await session.commit()
await log_login_attempt(session, data.email.lower(), ip, user_agent, True)
logger.info(f" Connexion réussie: {user.email}")
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_jwt,
expires_in=86400,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_access_token(
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
):
payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide"
)
user_id = payload.get("sub")
token_hash = hash_token(data.refresh_token)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.token_hash == token_hash,
not RefreshToken.is_revoked,
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token révoqué ou introuvable",
)
if token_record.expires_at < datetime.now():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré"
)
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable ou désactivé",
)
new_access_token = create_access_token(
{"sub": user.id, "email": user.email, "role": user.role}
)
logger.info(f" Token rafraîchi: {user.email}")
return TokenResponse(
access_token=new_access_token,
refresh_token=data.refresh_token,
expires_in=86400,
)
@router.post("/forgot-password")
async def forgot_password(
data: ForgotPassword,
request: Request,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
if not user:
return {
"success": True,
"message": "Si cet email existe, un lien de reinitialisation a ete envoye.",
"message": "Si cet email existe, un lien de réinitialisation a été envoyé.",
}
reset_token = generate_reset_token()
@ -490,23 +435,24 @@ async def forgot_password(
user.reset_token_expires = datetime.now() + timedelta(hours=1)
await session.commit()
frontend_url = settings.frontend_url or str(request.base_url).rstrip("/")
frontend_url = (
settings.frontend_url
if hasattr(settings, "frontend_url")
else str(request.base_url).rstrip("/")
)
AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url)
logger.info(f"Reset password demande: {user.email}")
logger.info(f" Reset password demandé: {user.email}")
return {
"success": True,
"message": "Si cet email existe, un lien de reinitialisation a ete envoye.",
"message": "Si cet email existe, un lien de réinitialisation a été envoyé.",
}
@router.post("/reset-password")
async def reset_password(
data: ResetPasswordRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
data: ResetPassword, session: AsyncSession = Depends(get_session)
):
result = await session.execute(select(User).where(User.reset_token == data.token))
user = result.scalar_one_or_none()
@ -514,13 +460,13 @@ async def reset_password(
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de reinitialisation invalide",
detail="Token de réinitialisation invalide",
)
if user.reset_token_expires and user.reset_token_expires < datetime.now():
if user.reset_token_expires < datetime.now():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token expire. Demandez un nouveau lien.",
detail="Token expiré. Demandez un nouveau lien de réinitialisation.",
)
is_valid, error_msg = validate_password_strength(data.new_password)
@ -532,67 +478,41 @@ async def reset_password(
user.reset_token_expires = None
user.failed_login_attempts = 0
user.locked_until = None
user.password_changed_at = datetime.now()
await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="password_reset"
)
await AuditService.log_password_change(session, request, user.id, "reset")
await session.commit()
CookieManager.clear_all_auth_cookies(response)
AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"Mot de passe reinitialise: {user.email}")
logger.info(f" Mot de passe réinitialisé: {user.email}")
return {
"success": True,
"message": "Mot de passe reinitialise. Vous pouvez maintenant vous connecter.",
"message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.",
}
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
request: Request,
response: Response,
@router.post("/logout")
async def logout(
data: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mot de passe actuel incorrect",
token_hash = hash_token(data.refresh_token)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash
)
is_valid, error_msg = validate_password_strength(data.new_password)
if not is_valid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
user.hashed_password = hash_password(data.new_password)
user.password_changed_at = datetime.now()
await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="password_change"
)
token_record = result.scalar_one_or_none()
await AuditService.log_password_change(session, request, user.id, "user_initiated")
if token_record:
token_record.is_revoked = True
token_record.revoked_at = datetime.now()
await session.commit()
CookieManager.clear_all_auth_cookies(response)
logger.info(f" Déconnexion: {user.email}")
AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"Mot de passe change: {user.email}")
return {
"success": True,
"message": "Mot de passe modifie. Veuillez vous reconnecter.",
}
return {"success": True, "message": "Déconnexion réussie"}
@router.get("/me")
@ -604,69 +524,6 @@ async def get_current_user_info(user: User = Depends(get_current_user)):
"prenom": user.prenom,
"role": user.role,
"is_verified": user.is_verified,
"created_at": user.created_at.isoformat() if user.created_at else None,
"created_at": user.created_at.isoformat(),
"last_login": user.last_login.isoformat() if user.last_login else None,
}
@router.get("/sessions", response_model=List[SessionResponse])
async def get_active_sessions(
session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user)
):
sessions = await TokenService.get_user_active_sessions(session, user.id)
return [SessionResponse(**s) for s in sessions]
@router.delete("/sessions/{session_id}")
async def revoke_session(
session_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
result = await session.execute(
select(RefreshToken).where(
RefreshToken.id == session_id,
RefreshToken.user_id == user.id,
RefreshToken.is_revoked.is_(false()),
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session introuvable"
)
token_record.is_revoked = True
token_record.revoked_at = datetime.now()
token_record.revoked_reason = "user_revoked"
await AuditService.log_event(
session=session,
event_type=AuditEventType.SESSION_TERMINATED,
request=request,
user_id=user.id,
description=f"Session {session_id[:8]}... revoquee",
)
await session.commit()
return {"success": True, "message": "Session revoquee"}
@router.get("/csrf-token")
async def get_csrf_token(
request: Request, response: Response, user: User = Depends(get_current_user)
):
from security.auth import generate_session_id, create_csrf_token
session_id = getattr(request.state, "session_id", None)
if not session_id:
session_id = generate_session_id()
csrf_token = create_csrf_token(session_id)
CookieManager.set_csrf_token(response, csrf_token)
return {"csrf_token": csrf_token}

158
routes/enterprise.py Normal file
View file

@ -0,0 +1,158 @@
from fastapi import APIRouter, HTTPException, Query, Path
import httpx
import logging
from datetime import datetime
from schemas import EntrepriseSearch, EntrepriseSearchResponse
from utils.enterprise import (
calculer_tva_intracommunautaire,
mapper_resultat_api,
rechercher_entreprise_api,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/entreprises", tags=["Entreprises"])
@router.get("/search", response_model=EntrepriseSearchResponse)
async def rechercher_entreprise(
q: str = Query(..., min_length=2, description="Nom d'entreprise, SIREN ou SIRET"),
per_page: int = Query(5, ge=1, le=25, description="Nombre de résultats (max 25)"),
):
try:
logger.info(f" Recherche entreprise: '{q}'")
api_response = await rechercher_entreprise_api(q, per_page)
resultats_api = api_response.get("results", [])
if not resultats_api:
logger.info(f"Aucun résultat pour: {q}")
return EntrepriseSearchResponse(total_results=0, results=[], query=q)
entreprises = []
for data in resultats_api:
entreprise = mapper_resultat_api(data)
if entreprise:
entreprises.append(entreprise)
logger.info(f" {len(entreprises)} résultat(s) trouvé(s)")
return EntrepriseSearchResponse(
total_results=len(entreprises), results=entreprises, query=q
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur recherche entreprise: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Erreur lors de la recherche: {str(e)}"
)
@router.get("/siren/{siren}", response_model=EntrepriseSearch)
async def lire_entreprise_par_siren(
siren: str = Path(
...,
min_length=9,
max_length=9,
pattern=r"^\d{9}$",
description="Numéro SIREN (9 chiffres)",
),
):
try:
logger.info(f"Lecture entreprise SIREN: {siren}")
api_response = await rechercher_entreprise_api(siren, per_page=1)
resultats = api_response.get("results", [])
if not resultats:
raise HTTPException(
status_code=404,
detail=f"Aucune entreprise trouvée pour le SIREN {siren}",
)
entreprise_data = resultats[0]
if entreprise_data.get("siren") != siren:
raise HTTPException(status_code=404, detail=f"SIREN {siren} introuvable")
entreprise = mapper_resultat_api(entreprise_data)
if not entreprise:
raise HTTPException(
status_code=500,
detail="Erreur lors du traitement des données entreprise",
)
if not entreprise.is_active:
logger.warning(f" Entreprise CESSÉE: {siren}")
return entreprise
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture SIREN {siren}: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Erreur lors de la récupération: {str(e)}"
)
@router.get("/tva/{siren}")
async def calculer_tva(
siren: str = Path(
...,
min_length=9,
max_length=9,
pattern=r"^\d{9}$",
description="Numéro SIREN (9 chiffres)",
),
):
tva_number = calculer_tva_intracommunautaire(siren)
if not tva_number:
raise HTTPException(status_code=400, detail=f"SIREN invalide: {siren}")
return {
"siren": siren,
"vat_number": tva_number,
"format": "FR + Clé (2 chiffres) + SIREN (9 chiffres)",
}
@router.get("/health")
async def health_check_api_sirene():
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
"https://recherche-entreprises.api.gouv.fr/search",
params={"q": "test", "per_page": 1},
)
if response.status_code == 200:
return {
"status": "healthy",
"api_sirene": "disponible",
"response_time_ms": response.elapsed.total_seconds() * 1000,
"timestamp": datetime.now().isoformat(),
}
else:
return {
"status": "degraded",
"api_sirene": f"statut {response.status_code}",
"timestamp": datetime.now().isoformat(),
}
except Exception as e:
logger.error(f"Health check failed: {e}")
return {
"status": "unhealthy",
"api_sirene": "indisponible",
"error": str(e),
"timestamp": datetime.now().isoformat(),
}

View file

@ -12,9 +12,9 @@ from schemas import (
SageGatewayCreate,
SageGatewayUpdate,
SageGatewayResponse,
SageGatewayListResponse,
SageGatewayList,
SageGatewayHealthCheck,
SageGatewayTestRequest,
SageGatewayTest,
SageGatewayStatsResponse,
CurrentGatewayInfo,
)
@ -41,7 +41,7 @@ async def create_gateway(
return SageGatewayResponse(**gateway_response_from_model(gateway))
@router.get("", response_model=SageGatewayListResponse)
@router.get("", response_model=SageGatewayList)
async def list_gateways(
include_deleted: bool = Query(False, description="Inclure les gateways supprimées"),
session: AsyncSession = Depends(get_session),
@ -54,7 +54,7 @@ async def list_gateways(
items = [SageGatewayResponse(**gateway_response_from_model(g)) for g in gateways]
return SageGatewayListResponse(
return SageGatewayList(
items=items,
total=len(items),
active_gateway=SageGatewayResponse(**gateway_response_from_model(active))
@ -268,7 +268,7 @@ async def check_gateway_health(
@router.post("/test", response_model=dict)
async def test_gateway_config(
data: SageGatewayTestRequest,
data: SageGatewayTest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):

1130
routes/universign.py Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

BIN
sage/pdfs/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -1,4 +1,3 @@
# sage_client.py
import requests
from typing import Dict, List, Optional
from config.config import settings
@ -401,6 +400,181 @@ class SageGatewayClient:
result = self._post("/sage/client/remise-max", {"code": code_client})
return result.get("data", {}).get("remise_max", 10.0)
def lister_collaborateurs(
self, filtre: Optional[str] = None, actifs_seulement: bool = True
) -> List[Dict]:
"""Liste tous les collaborateurs"""
return self._post(
"/sage/collaborateurs/list",
{
"filtre": filtre or "",
"actifs_seulement": actifs_seulement,
},
).get("data", [])
def lire_collaborateur(self, numero: int) -> Optional[Dict]:
"""Lit un collaborateur par numéro"""
return self._post("/sage/collaborateurs/get", {"numero": numero}).get("data")
def creer_collaborateur(self, data: Dict) -> Optional[Dict]:
"""Crée un nouveau collaborateur"""
return self._post("/sage/collaborateurs/create", data).get("data")
def modifier_collaborateur(self, numero: int, data: Dict) -> Optional[Dict]:
"""Modifie un collaborateur existant"""
return self._post(
"/sage/collaborateurs/update", {"numero": numero, **data}
).get("data")
def lire_informations_societe(self) -> Optional[Dict]:
"""Lit les informations de la société depuis P_DOSSIER"""
return self._get("/sage/societe/info").get("data")
def valider_facture(self, numero_facture: str) -> dict:
response = self._post(f"/sage/factures/{numero_facture}/valider", {})
return response.get("data", {})
def devalider_facture(self, numero_facture: str) -> dict:
response = self._post(f"/sage/factures/{numero_facture}/devalider", {})
return response.get("data", {})
def get_statut_validation(self, numero_facture: str) -> dict:
response = self._get(f"/sage/factures/{numero_facture}/statut-validation")
return response.get("data", {})
def regler_facture(
self,
numero_facture: str,
montant: float,
mode_reglement: int = 0,
date_reglement: str = None,
reference: str = "",
libelle: str = "",
code_journal: str = None,
devise_code: int = 0,
cours_devise: float = 1.0,
tva_encaissement: bool = False,
compte_general: str = None,
) -> dict:
"""Règle une facture"""
payload = {
"montant": montant,
"mode_reglement": mode_reglement,
"reference": reference,
"libelle": libelle,
"devise_code": devise_code,
"cours_devise": cours_devise,
"tva_encaissement": tva_encaissement,
}
if date_reglement:
payload["date_reglement"] = date_reglement
if code_journal:
payload["code_journal"] = code_journal
if compte_general:
payload["compte_general"] = compte_general
return self._post(f"/sage/factures/{numero_facture}/regler", payload).get(
"data", {}
)
def regler_factures_client(
self,
client_code: str,
montant_total: float,
mode_reglement: int = 0,
date_reglement: str = None,
reference: str = "",
libelle: str = "",
code_journal: str = None,
numeros_factures: list = None,
devise_code: int = 0,
cours_devise: float = 1.0,
tva_encaissement: bool = False,
) -> dict:
"""Règle plusieurs factures d'un client"""
payload = {
"client_code": client_code,
"montant_total": montant_total,
"mode_reglement": mode_reglement,
"reference": reference,
"libelle": libelle,
"devise_code": devise_code,
"cours_devise": cours_devise,
"tva_encaissement": tva_encaissement,
}
if date_reglement:
payload["date_reglement"] = date_reglement
if code_journal:
payload["code_journal"] = code_journal
if numeros_factures:
payload["numeros_factures"] = numeros_factures
return self._post("/sage/reglements/multiple", payload).get("data", {})
def get_reglements_facture(self, numero_facture: str) -> dict:
"""Récupère les règlements d'une facture"""
return self._get(f"/sage/factures/{numero_facture}/reglements").get("data", {})
def get_reglements_client(
self,
client_code: str,
date_debut: str = None,
date_fin: str = None,
inclure_soldees: bool = True,
) -> dict:
"""Récupère les règlements d'un client"""
params = {"inclure_soldees": inclure_soldees}
if date_debut:
params["date_debut"] = date_debut
if date_fin:
params["date_fin"] = date_fin
return self._get(f"/sage/clients/{client_code}/reglements", params=params).get(
"data", {}
)
def get_journaux_banque(self) -> dict:
return self._get("/sage/journaux/banque").get("data", {})
def get_modes_reglement(self) -> List[dict]:
"""Récupère les modes de règlement depuis Sage"""
return self._get("/sage/reglements/modes").get("data", {}).get("modes", [])
def get_devises(self) -> List[dict]:
"""Récupère les devises disponibles"""
return self._get("/sage/devises").get("data", {}).get("devises", [])
def get_journaux_tresorerie(self) -> List[dict]:
"""Récupère les journaux de trésorerie (banque + caisse)"""
return (
self._get("/sage/journaux/tresorerie").get("data", {}).get("journaux", [])
)
def get_comptes_generaux(
self, prefixe: str = None, type_compte: str = None
) -> List[dict]:
params = {}
if prefixe:
params["prefixe"] = prefixe
if type_compte:
params["type_compte"] = type_compte
return (
self._get("/sage/comptes-generaux", params=params)
.get("data", {})
.get("comptes", [])
)
def get_tva_taux(self) -> List[dict]:
"""Récupère les taux de TVA"""
return self._get("/sage/tva/taux").get("data", {}).get("taux", [])
def get_parametres_encaissement(self) -> dict:
"""Récupère les paramètres TVA sur encaissement"""
return self._get("/sage/parametres/encaissement").get("data", {})
def refresh_cache(self) -> Dict:
return self._post("/sage/cache/refresh")
@ -414,5 +588,14 @@ class SageGatewayClient:
except Exception:
return {"status": "down"}
def get_tous_reglements(self, params=None):
return self._get("/sage/reglements", params=params)
def get_reglement_facture_detail(self, facture_no):
return self._get(f"/sage/reglements/facture/{facture_no}")
def get_reglement_detail(self, rg_no):
return self._get(f"/sage/reglements/{rg_no}")
sage_client = SageGatewayClient()

View file

@ -1,108 +1,124 @@
from schemas.tiers.tiers import TiersDetails, TypeTiersInt
from schemas.tiers.type_tiers import TypeTiers
from schemas.schema_mixte import BaremeRemiseResponse
from schemas.user import UserResponse
from schemas.user import Users
from schemas.tiers.clients import (
ClientCreateRequest,
ClientCreate,
ClientDetails,
ClientResponse,
ClientUpdateRequest,
ClientUpdate,
)
from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate
from schemas.tiers.fournisseurs import (
FournisseurCreateAPIRequest,
FournisseurCreate,
FournisseurDetails,
FournisseurUpdateRequest,
FournisseurUpdate,
)
from schemas.documents.avoirs import AvoirCreateRequest, AvoirUpdateRequest
from schemas.documents.commandes import CommandeCreateRequest, CommandeUpdateRequest
from schemas.documents.avoirs import AvoirCreate, AvoirUpdate
from schemas.documents.commandes import CommandeCreate, CommandeUpdate
from schemas.documents.devis import (
DevisRequest,
DevisResponse,
DevisUpdateRequest,
RelanceDevisRequest,
Devis,
DevisUpdate,
RelanceDevis,
)
from schemas.documents.documents import TypeDocument, TypeDocumentSQL
from schemas.documents.email import StatutEmail, EmailEnvoiRequest
from schemas.documents.factures import FactureCreateRequest, FactureUpdateRequest
from schemas.documents.livraisons import LivraisonCreateRequest, LivraisonUpdateRequest
from schemas.documents.universign import SignatureRequest, StatutSignature
from schemas.documents.email import StatutEmail, EmailEnvoi
from schemas.documents.factures import FactureCreate, FactureUpdate
from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate
from schemas.documents.universign import (
Signature,
StatutSignature,
SyncStatsResponse,
CreateSignatureRequest,
TransactionResponse,
)
from schemas.articles.articles import (
ArticleCreateRequest,
ArticleResponse,
ArticleUpdateRequest,
ArticleListResponse,
EntreeStockRequest,
SortieStockRequest,
MouvementStockResponse,
ArticleCreate,
Article,
ArticleUpdate,
ArticleList,
EntreeStock,
SortieStock,
MouvementStock,
)
from schemas.articles.famille_article import (
FamilleResponse,
FamilleCreateRequest,
FamilleListResponse,
Familles,
FamilleCreate,
FamilleList,
)
from schemas.sage.sage_gateway import (
SageGatewayCreate,
SageGatewayUpdate,
SageGatewayResponse,
SageGatewayListResponse,
SageGatewayList,
SageGatewayHealthCheck,
SageGatewayTestRequest,
SageGatewayTest,
SageGatewayStatsResponse,
CurrentGatewayInfo,
)
from schemas.society.societe import SocieteInfo
from schemas.society.enterprise import EntrepriseSearch, EntrepriseSearchResponse
__all__ = [
"TiersDetails",
"TypeTiers",
"BaremeRemiseResponse",
"UserResponse",
"ClientCreateRequest",
"Users",
"ClientCreate",
"ClientDetails",
"ClientResponse",
"ClientUpdateRequest",
"FournisseurCreateAPIRequest",
"ClientUpdate",
"FournisseurCreate",
"FournisseurDetails",
"FournisseurUpdateRequest",
"FournisseurUpdate",
"Contact",
"AvoirCreateRequest",
"AvoirUpdateRequest",
"CommandeCreateRequest",
"CommandeUpdateRequest",
"AvoirCreate",
"AvoirUpdate",
"CommandeCreate",
"CommandeUpdate",
"DevisRequest",
"DevisResponse",
"DevisUpdateRequest",
"Devis",
"DevisUpdate",
"TypeDocument",
"TypeDocumentSQL",
"StatutEmail",
"EmailEnvoiRequest",
"FactureCreateRequest",
"FactureUpdateRequest",
"LivraisonCreateRequest",
"LivraisonUpdateRequest",
"SignatureRequest",
"EmailEnvoi",
"FactureCreate",
"FactureUpdate",
"LivraisonCreate",
"LivraisonUpdate",
"Signature",
"StatutSignature",
"TypeTiersInt",
"ArticleCreateRequest",
"ArticleResponse",
"ArticleUpdateRequest",
"ArticleListResponse",
"EntreeStockRequest",
"SortieStockRequest",
"MouvementStockResponse",
"RelanceDevisRequest",
"FamilleResponse",
"FamilleCreateRequest",
"FamilleListResponse",
"ArticleCreate",
"Article",
"ArticleUpdate",
"ArticleList",
"EntreeStock",
"SortieStock",
"MouvementStock",
"RelanceDevis",
"Familles",
"FamilleCreate",
"FamilleList",
"ContactCreate",
"ContactUpdate",
"SageGatewayCreate",
"SageGatewayUpdate",
"SageGatewayResponse",
"SageGatewayListResponse",
"SageGatewayList",
"SageGatewayHealthCheck",
"SageGatewayTestRequest",
"SageGatewayTest",
"SageGatewayStatsResponse",
"CurrentGatewayInfo",
"SyncStatsResponse",
"CreateSignatureRequest",
"TransactionResponse",
"SocieteInfo",
"EntrepriseSearch",
"EntrepriseSearchResponse",
]

77
schemas/api_key.py Normal file
View file

@ -0,0 +1,77 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class ApiKeyCreate(BaseModel):
"""Schema pour créer une clé API"""
name: str = Field(..., min_length=3, max_length=255, description="Nom de la clé")
description: Optional[str] = Field(None, description="Description de l'usage")
expires_in_days: Optional[int] = Field(
None, ge=1, le=3650, description="Expiration en jours (max 10 ans)"
)
rate_limit_per_minute: int = Field(
60, ge=1, le=1000, description="Limite de requêtes par minute"
)
allowed_endpoints: Optional[List[str]] = Field(
None, description="Endpoints autorisés ([] = tous, ['/clients*'] = wildcard)"
)
class ApiKeyResponse(BaseModel):
"""Schema de réponse pour une clé API"""
id: str
name: str
description: Optional[str]
key_prefix: str
is_active: bool
is_expired: bool
rate_limit_per_minute: int
allowed_endpoints: Optional[List[str]]
total_requests: int
last_used_at: Optional[datetime]
created_at: datetime
expires_at: Optional[datetime]
revoked_at: Optional[datetime]
created_by: str
class ApiKeyCreatedResponse(ApiKeyResponse):
"""Schema de réponse après création (inclut la clé en clair)"""
api_key: str = Field(
..., description=" Clé API en clair - à sauvegarder immédiatement"
)
class ApiKeyList(BaseModel):
"""Liste de clés API"""
total: int
items: List[ApiKeyResponse]
class SwaggerUserCreate(BaseModel):
"""Schema pour créer un utilisateur Swagger"""
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8)
full_name: Optional[str] = None
email: Optional[str] = None
class SwaggerUserResponse(BaseModel):
"""Schema de réponse pour un utilisateur Swagger"""
id: str
username: str
full_name: Optional[str]
email: Optional[str]
is_active: bool
created_at: datetime
last_login: Optional[datetime]
class Config:
from_attributes = True

View file

@ -1,6 +1,6 @@
from pydantic import BaseModel, Field, validator, field_validator
from typing import List, Optional
from datetime import date, datetime
from datetime import date
from utils import (
NomenclatureType,
@ -11,363 +11,7 @@ from utils import (
)
class EmplacementStockModel(BaseModel):
"""Détail du stock dans un emplacement spécifique"""
depot: str = Field(..., description="Numéro du dépôt (DE_No)")
emplacement: str = Field(..., description="Code emplacement (DP_No)")
qte_stockee: float = Field(0.0, description="Quantité stockée (AE_QteSto)")
qte_preparee: float = Field(0.0, description="Quantité préparée (AE_QtePrepa)")
qte_a_controler: float = Field(
0.0, description="Quantité à contrôler (AE_QteAControler)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
depot_num: Optional[str] = Field(None, description="Numéro dépôt")
depot_nom: Optional[str] = Field(None, description="Nom du dépôt (DE_Intitule)")
depot_code: Optional[str] = Field(None, description="Code dépôt (DE_Code)")
depot_adresse: Optional[str] = Field(None, description="Adresse (DE_Adresse)")
depot_complement: Optional[str] = Field(None, description="Complément adresse")
depot_code_postal: Optional[str] = Field(None, description="Code postal")
depot_ville: Optional[str] = Field(None, description="Ville")
depot_contact: Optional[str] = Field(None, description="Contact")
depot_est_principal: Optional[bool] = Field(
None, description="Dépôt principal (DE_Principal)"
)
depot_categorie_compta: Optional[int] = Field(
None, description="Catégorie comptable"
)
depot_region: Optional[str] = Field(None, description="Région")
depot_pays: Optional[str] = Field(None, description="Pays")
depot_email: Optional[str] = Field(None, description="Email")
depot_telephone: Optional[str] = Field(None, description="Téléphone")
depot_fax: Optional[str] = Field(None, description="Fax")
depot_emplacement_defaut: Optional[str] = Field(
None, description="Emplacement par défaut"
)
depot_exclu: Optional[bool] = Field(None, description="Dépôt exclu")
emplacement_code: Optional[str] = Field(
None, description="Code emplacement (DP_Code)"
)
emplacement_libelle: Optional[str] = Field(
None, description="Libellé emplacement (DP_Intitule)"
)
emplacement_zone: Optional[str] = Field(None, description="Zone (DP_Zone)")
emplacement_type: Optional[int] = Field(
None, description="Type emplacement (DP_Type)"
)
class Config:
json_schema_extra = {
"example": {
"depot": "01",
"emplacement": "A1-01",
"qte_stockee": 100.0,
"qte_preparee": 5.0,
"depot_nom": "Dépôt principal",
"depot_ville": "Paris",
"emplacement_libelle": "Allée A, Niveau 1, Case 01",
"emplacement_zone": "Zone A",
}
}
class GammeArticleModel(BaseModel):
"""Gamme d'un article (taille, couleur, etc.)"""
numero_gamme: int = Field(..., description="Numéro de gamme (AG_No)")
enumere: str = Field(..., description="Code énuméré (EG_Enumere)")
type_gamme: int = Field(0, description="Type de gamme (AG_Type)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
ligne: Optional[int] = Field(None, description="Ligne énuméré (EG_Ligne)")
borne_sup: Optional[float] = Field(
None, description="Borne supérieure (EG_BorneSup)"
)
gamme_nom: Optional[str] = Field(
None, description="Nom de la gamme (P_GAMME.G_Intitule)"
)
class Config:
json_schema_extra = {
"example": {
"numero_gamme": 1,
"enumere": "001",
"type_gamme": 0,
"ligne": 1,
"gamme_nom": "Taille",
}
}
class TarifClientModel(BaseModel):
"""Tarif spécifique pour un client ou catégorie tarifaire"""
categorie: int = Field(..., description="Catégorie tarifaire (AC_Categorie)")
client_num: Optional[str] = Field(None, description="Numéro client (CT_Num)")
prix_vente: float = Field(0.0, description="Prix de vente HT (AC_PrixVen)")
coefficient: float = Field(0.0, description="Coefficient (AC_Coef)")
prix_ttc: float = Field(0.0, description="Prix TTC (AC_PrixTTC)")
arrondi: float = Field(0.0, description="Arrondi (AC_Arrondi)")
qte_montant: float = Field(0.0, description="Quantité montant (AC_QteMont)")
enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)")
prix_devise: float = Field(0.0, description="Prix en devise (AC_PrixDev)")
devise: int = Field(0, description="Code devise (AC_Devise)")
remise: float = Field(0.0, description="Remise (AC_Remise)")
mode_calcul: int = Field(0, description="Mode de calcul (AC_Calcul)")
type_remise: int = Field(0, description="Type de remise (AC_TypeRem)")
ref_client: Optional[str] = Field(
None, description="Référence client (AC_RefClient)"
)
coef_nouveau: float = Field(0.0, description="Nouveau coefficient (AC_CoefNouv)")
prix_vente_nouveau: float = Field(
0.0, description="Nouveau prix vente (AC_PrixVenNouv)"
)
prix_devise_nouveau: float = Field(
0.0, description="Nouveau prix devise (AC_PrixDevNouv)"
)
remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AC_RemiseNouv)")
date_application: Optional[datetime] = Field(
None, description="Date application (AC_DateApplication)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"categorie": 1,
"client_num": "CLI001",
"prix_vente": 110.00,
"coefficient": 1.294,
"remise": 12.0,
}
}
class ComposantModel(BaseModel):
"""Composant/Opération de nomenclature"""
operation: str = Field(..., description="Code opération (AT_Operation)")
code_ressource: Optional[str] = Field(None, description="Code ressource (RP_Code)")
temps: float = Field(0.0, description="Temps nécessaire (AT_Temps)")
type: int = Field(0, description="Type composant (AT_Type)")
description: Optional[str] = Field(None, description="Description (AT_Description)")
ordre: int = Field(0, description="Ordre d'exécution (AT_Ordre)")
gamme_1_comp: int = Field(0, description="Gamme 1 composant (AG_No1Comp)")
gamme_2_comp: int = Field(0, description="Gamme 2 composant (AG_No2Comp)")
type_ressource: int = Field(0, description="Type ressource (AT_TypeRessource)")
chevauche: int = Field(0, description="Chevauchement (AT_Chevauche)")
demarre: int = Field(0, description="Démarrage (AT_Demarre)")
operation_chevauche: Optional[str] = Field(
None, description="Opération chevauchée (AT_OperationChevauche)"
)
valeur_chevauche: float = Field(
0.0, description="Valeur chevauchement (AT_ValeurChevauche)"
)
type_chevauche: int = Field(0, description="Type chevauchement (AT_TypeChevauche)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"operation": "OP010",
"code_ressource": "RES01",
"temps": 15.5,
"description": "Montage pièce A",
"ordre": 10,
}
}
class ComptaArticleModel(BaseModel):
"""Comptabilité spécifique d'un article"""
champ: int = Field(..., description="Champ (ACP_Champ)")
compte_general: Optional[str] = Field(
None, description="Compte général (ACP_ComptaCPT_CompteG)"
)
compte_auxiliaire: Optional[str] = Field(
None, description="Compte auxiliaire (ACP_ComptaCPT_CompteA)"
)
taxe_1: Optional[str] = Field(None, description="Taxe 1 (ACP_ComptaCPT_Taxe1)")
taxe_2: Optional[str] = Field(None, description="Taxe 2 (ACP_ComptaCPT_Taxe2)")
taxe_3: Optional[str] = Field(None, description="Taxe 3 (ACP_ComptaCPT_Taxe3)")
taxe_date_1: Optional[datetime] = Field(None, description="Date taxe 1")
taxe_date_2: Optional[datetime] = Field(None, description="Date taxe 2")
taxe_date_3: Optional[datetime] = Field(None, description="Date taxe 3")
taxe_anc_1: Optional[str] = Field(None, description="Ancienne taxe 1")
taxe_anc_2: Optional[str] = Field(None, description="Ancienne taxe 2")
taxe_anc_3: Optional[str] = Field(None, description="Ancienne taxe 3")
type_facture: int = Field(0, description="Type de facture (ACP_TypeFacture)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"champ": 1,
"compte_general": "707100",
"taxe_1": "TVA20",
"type_facture": 0,
}
}
class FournisseurArticleModel(BaseModel):
"""Fournisseur d'un article"""
fournisseur_num: str = Field(..., description="Numéro fournisseur (CT_Num)")
ref_fournisseur: Optional[str] = Field(
None, description="Référence fournisseur (AF_RefFourniss)"
)
prix_achat: float = Field(0.0, description="Prix d'achat (AF_PrixAch)")
unite: Optional[str] = Field(None, description="Unité (AF_Unite)")
conversion: float = Field(0.0, description="Conversion (AF_Conversion)")
delai_appro: int = Field(0, description="Délai approvisionnement (AF_DelaiAppro)")
garantie: int = Field(0, description="Garantie (AF_Garantie)")
colisage: int = Field(0, description="Colisage (AF_Colisage)")
qte_mini: float = Field(0.0, description="Quantité minimum (AF_QteMini)")
qte_montant: float = Field(0.0, description="Quantité montant (AF_QteMont)")
enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)")
est_principal: bool = Field(
False, description="Fournisseur principal (AF_Principal)"
)
prix_devise: float = Field(0.0, description="Prix devise (AF_PrixDev)")
devise: int = Field(0, description="Code devise (AF_Devise)")
remise: float = Field(0.0, description="Remise (AF_Remise)")
conversion_devise: float = Field(0.0, description="Conversion devise (AF_ConvDiv)")
type_remise: int = Field(0, description="Type remise (AF_TypeRem)")
code_barre_fournisseur: Optional[str] = Field(
None, description="Code-barres fournisseur (AF_CodeBarre)"
)
prix_achat_nouveau: float = Field(
0.0, description="Nouveau prix achat (AF_PrixAchNouv)"
)
prix_devise_nouveau: float = Field(
0.0, description="Nouveau prix devise (AF_PrixDevNouv)"
)
remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AF_RemiseNouv)")
date_application: Optional[datetime] = Field(
None, description="Date application (AF_DateApplication)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"fournisseur_num": "F001",
"ref_fournisseur": "REF-FOURN-001",
"prix_achat": 85.00,
"delai_appro": 15,
"est_principal": True,
}
}
class ReferenceEnumereeModel(BaseModel):
"""Référence énumérée (article avec gammes)"""
gamme_1: int = Field(0, description="Gamme 1 (AG_No1)")
gamme_2: int = Field(0, description="Gamme 2 (AG_No2)")
reference_enumeree: str = Field(..., description="Référence énumérée (AE_Ref)")
prix_achat: float = Field(0.0, description="Prix achat (AE_PrixAch)")
code_barre: Optional[str] = Field(None, description="Code-barres (AE_CodeBarre)")
prix_achat_nouveau: float = Field(
0.0, description="Nouveau prix achat (AE_PrixAchNouv)"
)
edi_code: Optional[str] = Field(None, description="Code EDI (AE_EdiCode)")
en_sommeil: bool = Field(False, description="En sommeil (AE_Sommeil)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"gamme_1": 1,
"gamme_2": 3,
"reference_enumeree": "ART001-T1-C3",
"prix_achat": 85.00,
}
}
class MediaArticleModel(BaseModel):
"""Média attaché à un article (photo, document, etc.)"""
commentaire: Optional[str] = Field(None, description="Commentaire (ME_Commentaire)")
fichier: Optional[str] = Field(None, description="Nom fichier (ME_Fichier)")
type_mime: Optional[str] = Field(None, description="Type MIME (ME_TypeMIME)")
origine: int = Field(0, description="Origine (ME_Origine)")
ged_id: Optional[str] = Field(None, description="ID GED (ME_GedId)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"commentaire": "Photo produit principale",
"fichier": "ART001_photo1.jpg",
"type_mime": "image/jpeg",
}
}
class PrixGammeModel(BaseModel):
"""Prix spécifique par combinaison de gammes"""
gamme_1: int = Field(0, description="Gamme 1 (AG_No1)")
gamme_2: int = Field(0, description="Gamme 2 (AG_No2)")
prix_net: float = Field(0.0, description="Prix net (AR_PUNet)")
cout_standard: float = Field(0.0, description="Coût standard (AR_CoutStd)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"gamme_1": 1,
"gamme_2": 3,
"prix_net": 125.50,
"cout_standard": 82.30,
}
}
class ArticleResponse(BaseModel):
class Article(BaseModel):
"""Article complet avec tous les enrichissements disponibles"""
reference: str = Field(..., description="Référence article (AR_Ref)")
@ -432,7 +76,6 @@ class ArticleResponse(BaseModel):
)
nb_emplacements: int = Field(0, description="Nombre d'emplacements")
# Champs énumérés normalisés
suivi_stock: Optional[int] = Field(
None,
description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé",
@ -712,7 +355,20 @@ class ArticleResponse(BaseModel):
)
exclure: Optional[bool] = Field(None, description="Exclure de certains traitements")
# ===== VALIDATEURS =====
@field_validator("fournisseur_principal", mode="before")
@classmethod
def convert_fournisseur_principal(cls, v):
if v in (None, "", " ", " "):
return None
if isinstance(v, str):
v = v.strip()
if not v:
return None
try:
return int(v)
except (ValueError, TypeError):
return None
return v
@field_validator(
"unite_vente",
@ -763,11 +419,11 @@ class ArticleResponse(BaseModel):
}
class ArticleListResponse(BaseModel):
class ArticleList(BaseModel):
"""Réponse pour une liste d'articles"""
total: int = Field(..., description="Nombre total d'articles")
articles: List[ArticleResponse] = Field(..., description="Liste des articles")
articles: List[Article] = Field(..., description="Liste des articles")
filtre_applique: Optional[str] = Field(
None, description="Filtre de recherche appliqué"
)
@ -780,37 +436,85 @@ class ArticleListResponse(BaseModel):
)
class ArticleCreateRequest(BaseModel):
"""Schéma pour création d'article"""
class ArticleCreate(BaseModel):
reference: str = Field(..., max_length=18, description="Référence article")
designation: str = Field(..., max_length=69, description="Désignation")
famille: Optional[str] = Field(None, max_length=18, description="Code famille")
prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT")
prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT")
coef: Optional[float] = Field(None, ge=0, description="Coefficient")
stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial")
stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum")
code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres")
unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité")
tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA")
description: Optional[str] = Field(None, description="Description")
stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum")
code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN")
unite_vente: Optional[str] = Field("UN", max_length=10, description="Unité vente")
tva_code: Optional[str] = Field(None, max_length=10, description="Code TVA")
code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal")
class ArticleUpdateRequest(BaseModel):
"""Schéma pour modification d'article"""
designation: Optional[str] = Field(None, max_length=69)
prix_vente: Optional[float] = Field(None, ge=0)
prix_achat: Optional[float] = Field(None, ge=0)
stock_reel: Optional[float] = Field(
None, ge=0, description="Critique pour erreur 2881"
description: Optional[str] = Field(
None, max_length=255, description="Description/Commentaire"
)
stock_mini: Optional[float] = Field(None, ge=0)
code_ean: Optional[str] = Field(None, max_length=13)
description: Optional[str] = Field(None)
pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine")
garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois")
delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours")
poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg")
poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg")
stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1")
stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2")
stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3")
stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4")
stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5")
soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte")
publie: Optional[bool] = Field(None, description="Publié web/catalogue")
en_sommeil: Optional[bool] = Field(None, description="Article en sommeil")
class MouvementStockLigneRequest(BaseModel):
class ArticleUpdate(BaseModel):
designation: Optional[str] = Field(None, max_length=69, description="Désignation")
famille: Optional[str] = Field(None, max_length=18, description="Code famille")
prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT")
prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT")
coef: Optional[float] = Field(None, ge=0, description="Coefficient")
stock_reel: Optional[float] = Field(None, ge=0, description="Stock réel")
stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum")
stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum")
code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN")
unite_vente: Optional[str] = Field(None, max_length=10, description="Unité vente")
code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal")
description: Optional[str] = Field(None, max_length=255, description="Description")
pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine")
garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois")
delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours")
poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg")
poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg")
stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1")
stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2")
stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3")
stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4")
stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5")
soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte")
publie: Optional[bool] = Field(None, description="Publié web/catalogue")
en_sommeil: Optional[bool] = Field(None, description="Article en sommeil")
class MouvementStockLigne(BaseModel):
article_ref: str = Field(..., description="Référence de l'article")
quantite: float = Field(..., gt=0, description="Quantité (>0)")
depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')")
@ -864,7 +568,7 @@ class MouvementStockLigneRequest(BaseModel):
return v
class EntreeStockRequest(BaseModel):
class EntreeStock(BaseModel):
"""Création d'un bon d'entrée en stock"""
date_entree: Optional[date] = Field(
@ -874,7 +578,7 @@ class EntreeStockRequest(BaseModel):
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigneRequest] = Field(
lignes: List[MouvementStockLigne] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
@ -899,7 +603,7 @@ class EntreeStockRequest(BaseModel):
}
class SortieStockRequest(BaseModel):
class SortieStock(BaseModel):
"""Création d'un bon de sortie de stock"""
date_sortie: Optional[date] = Field(
@ -909,7 +613,7 @@ class SortieStockRequest(BaseModel):
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigneRequest] = Field(
lignes: List[MouvementStockLigne] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
@ -933,7 +637,7 @@ class SortieStockRequest(BaseModel):
}
class MouvementStockResponse(BaseModel):
class MouvementStock(BaseModel):
"""Réponse pour un mouvement de stock"""
article_ref: str = Field(..., description="Numéro d'article")

View file

@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
from typing import Optional
class FamilleCreateRequest(BaseModel):
class FamilleCreate(BaseModel):
"""Schéma pour création de famille d'articles"""
code: str = Field(..., max_length=18, description="Code famille (max 18 car)")
@ -27,7 +27,7 @@ class FamilleCreateRequest(BaseModel):
}
class FamilleResponse(BaseModel):
class Familles(BaseModel):
"""Modèle complet d'une famille avec données comptables et fournisseur"""
code: str = Field(..., description="Code famille")
@ -236,10 +236,10 @@ class FamilleResponse(BaseModel):
}
class FamilleListResponse(BaseModel):
class FamilleList(BaseModel):
"""Réponse pour la liste des familles"""
familles: list[FamilleResponse]
familles: list[Familles]
total: int
filtre: Optional[str] = None
inclure_totaux: bool = True

View file

@ -1,30 +1,22 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
class LigneAvoir(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class AvoirCreateRequest(BaseModel):
class AvoirCreate(BaseModel):
client_id: str
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneAvoir]
date_avoir: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_avoir": "2024-01-15",
"date_avoir": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15T10:00:00",
"reference": "AV-EXT-001",
"lignes": [
{
@ -38,18 +30,18 @@ class AvoirCreateRequest(BaseModel):
}
class AvoirUpdateRequest(BaseModel):
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneAvoir]] = None
class AvoirUpdate(BaseModel):
date_avoir: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"date_avoir": "2024-01-15",
"date_livraison": "2024-01-15",
"date_avoir": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15T10:00:00",
"reference": "AV-EXT-001",
"lignes": [
{

View file

@ -1,30 +1,21 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
class LigneCommande(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class CommandeCreateRequest(BaseModel):
class CommandeCreate(BaseModel):
client_id: str
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneCommande]
date_commande: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_commande": "2024-01-15",
"date_commande": "2024-01-15T10:00:00",
"reference": "CMD-EXT-001",
"lignes": [
{
@ -38,18 +29,18 @@ class CommandeCreateRequest(BaseModel):
}
class CommandeUpdateRequest(BaseModel):
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneCommande]] = None
class CommandeUpdate(BaseModel):
date_commande: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"date_commande": "2024-01-15",
"date_livraison": "2024-01-15",
"date_commande": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15T10:00:00",
"reference": "CMD-EXT-001",
"lignes": [
{

View file

@ -1,27 +1,19 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
from datetime import datetime
class LigneDevis(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
from schemas.documents.ligne_document import LigneDocument
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[date] = None
date_livraison: Optional[date] = None
date_devis: Optional[datetime] = None
date_livraison: Optional[datetime] = None
reference: Optional[str] = None
lignes: List[LigneDevis]
lignes: List[LigneDocument]
class DevisResponse(BaseModel):
class Devis(BaseModel):
id: str
client_id: str
date_devis: str
@ -30,20 +22,20 @@ class DevisResponse(BaseModel):
nb_lignes: int
class DevisUpdateRequest(BaseModel):
class DevisUpdate(BaseModel):
"""Modèle pour modification d'un devis existant"""
date_devis: Optional[date] = None
date_livraison: Optional[date] = None
date_devis: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
reference: Optional[str] = None
lignes: Optional[List[LigneDevis]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
class Config:
json_schema_extra = {
"example": {
"date_devis": "2024-01-15",
"date_livraison": "2024-01-15",
"date_devis": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15T10:00:00",
"reference": "DEV-001",
"lignes": [
{
@ -58,6 +50,6 @@ class DevisUpdateRequest(BaseModel):
}
class RelanceDevisRequest(BaseModel):
class RelanceDevis(BaseModel):
doc_id: str
message_personnalise: Optional[str] = None

View file

@ -13,7 +13,7 @@ class StatutEmail(str, Enum):
BOUNCE = "BOUNCE"
class EmailEnvoiRequest(BaseModel):
class EmailEnvoi(BaseModel):
destinataire: EmailStr
cc: Optional[List[EmailStr]] = []
cci: Optional[List[EmailStr]] = []

View file

@ -1,30 +1,22 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
class LigneFacture(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class FactureCreateRequest(BaseModel):
class FactureCreate(BaseModel):
client_id: str
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneFacture]
date_facture: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_facture": "2024-01-15",
"date_facture": "2024-01-15T10:00:00",
"reference": "FA-EXT-001",
"lignes": [
{
@ -38,18 +30,18 @@ class FactureCreateRequest(BaseModel):
}
class FactureUpdateRequest(BaseModel):
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneFacture]] = None
class FactureUpdate(BaseModel):
date_facture: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"date_facture": "2024-01-15",
"date_livraison": "2024-01-15",
"date_facture": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15T10:00:00",
"lignes": [
{
"article_code": "ART001",

View file

@ -0,0 +1,25 @@
from pydantic import BaseModel, field_validator
from typing import Optional
class LigneDocument(BaseModel):
article_code: str
quantite: float
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
@field_validator("quantite")
def validate_quantite(cls, v):
if v <= 0:
raise ValueError("La quantité doit être positive")
return v
@field_validator("remise_pourcentage")
def validate_remise(cls, v):
if v is not None and (v < 0 or v > 100):
raise ValueError("La remise doit être entre 0 et 100")
return v

View file

@ -1,30 +1,22 @@
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import date
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
class LigneLivraison(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class LivraisonCreateRequest(BaseModel):
class LivraisonCreate(BaseModel):
client_id: str
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: List[LigneLivraison]
date_livraison: Optional[datetime] = None
date_livraison_prevue: Optional[datetime] = None
lignes: List[LigneDocument]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_livraison": "2024-01-15",
"date_livraison": "2024-01-15T10:00:00",
"reference": "BL-EXT-001",
"lignes": [
{
@ -38,18 +30,18 @@ class LivraisonCreateRequest(BaseModel):
}
class LivraisonUpdateRequest(BaseModel):
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: Optional[List[LigneLivraison]] = None
class LivraisonUpdate(BaseModel):
date_livraison: Optional[datetime] = None
date_livraison_prevue: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"date_livraison": "2024-01-15",
"date_livraison_prevue": "2024-01-15",
"date_livraison": "2024-01-15T10:00:00",
"date_livraison_prevue": "2024-01-15T10:00:00",
"reference": "BL-EXT-001",
"lignes": [
{

View file

@ -0,0 +1,109 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
import logging
from decimal import Decimal
from datetime import date
logger = logging.getLogger(__name__)
class ReglementFactureCreate(BaseModel):
"""Requête de règlement d'une facture côté VPS"""
montant: Decimal = Field(..., gt=0, description="Montant à régler")
devise_code: Optional[int] = Field(0, description="Code devise (0=EUR par défaut)")
cours_devise: Optional[Decimal] = Field(1.0, description="Cours de la devise")
mode_reglement: int = Field(
..., ge=0, description="Code mode règlement depuis /reglements/modes"
)
code_journal: str = Field(
..., min_length=1, description="Code journal depuis /journaux/tresorerie"
)
date_reglement: Optional[date] = Field(
None, description="Date du règlement (défaut: aujourd'hui)"
)
date_echeance: Optional[date] = Field(None, description="Date d'échéance")
reference: Optional[str] = Field(
"", max_length=17, description="Référence pièce règlement"
)
libelle: Optional[str] = Field(
"", max_length=35, description="Libellé du règlement"
)
tva_encaissement: Optional[bool] = Field(
False, description="Appliquer TVA sur encaissement"
)
compte_general: Optional[str] = Field(None)
@field_validator("montant")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"montant": 375.12,
"mode_reglement": 2,
"reference": "CHQ-001",
"code_journal": "BEU",
"date_reglement": "2024-01-01",
"libelle": "Règlement multiple",
"tva_encaissement": False,
"devise_code": 0,
"cours_devise": 1.0,
"date_echeance": "2024-01-31",
}
}
class ReglementMultipleCreate(BaseModel):
"""Requête de règlement multiple côté VPS"""
client_id: str = Field(..., description="Code client")
montant_total: Decimal = Field(..., gt=0)
devise_code: Optional[int] = Field(0)
cours_devise: Optional[Decimal] = Field(1.0)
mode_reglement: int = Field(...)
code_journal: str = Field(...)
date_reglement: Optional[date] = None
reference: Optional[str] = Field("")
libelle: Optional[str] = Field("")
tva_encaissement: Optional[bool] = Field(False)
numeros_factures: Optional[List[str]] = Field(
None, description="Si vide, règle les plus anciennes en premier"
)
@field_validator("client_id", mode="before")
def strip_client_id(cls, v):
return v.replace("\xa0", "").strip() if v else v
@field_validator("montant_total")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"montant_total": 1000.00,
"mode_reglement": 2,
"numeros_factures": ["FA00081", "FA00082"],
"reference": "CHQ-001",
"code_journal": "BEU",
"date_reglement": "2024-01-01",
"libelle": "Règlement multiple",
"tva_encaissement": False,
"devise_code": 0,
"cours_devise": 1.0,
"date_echeance": "2024-01-31",
}
}

View file

@ -1,6 +1,12 @@
from pydantic import BaseModel, EmailStr
from enum import Enum
from schemas.documents.documents import TypeDocument
from database import (
SageDocumentType,
)
from typing import List, Optional
from datetime import datetime
class StatutSignature(str, Enum):
@ -11,8 +17,54 @@ class StatutSignature(str, Enum):
EXPIRE = "EXPIRE"
class SignatureRequest(BaseModel):
class Signature(BaseModel):
doc_id: str
type_doc: TypeDocument
email_signataire: EmailStr
nom_signataire: str
class CreateSignatureRequest(BaseModel):
"""Demande de création d'une signature"""
sage_document_id: str
sage_document_type: SageDocumentType
signer_email: EmailStr
signer_name: str
document_name: Optional[str] = None
class TransactionResponse(BaseModel):
"""Réponse détaillée d'une transaction"""
id: str
transaction_id: str
sage_document_id: str
sage_document_type: str
universign_status: str
local_status: str
local_status_label: str
signer_url: Optional[str]
document_url: Optional[str]
created_at: datetime
sent_at: Optional[datetime]
signed_at: Optional[datetime]
last_synced_at: Optional[datetime]
needs_sync: bool
signers: List[dict]
signed_document_available: bool = False
signed_document_downloaded_at: Optional[datetime] = None
signed_document_size_kb: Optional[float] = None
class SyncStatsResponse(BaseModel):
"""Statistiques de synchronisation"""
total_transactions: int
pending_sync: int
signed: int
in_progress: int
refused: int
expired: int
last_sync_at: Optional[datetime]

View file

@ -10,8 +10,8 @@ class GatewayHealthStatus(str, Enum):
UNKNOWN = "unknown"
# === CREATE ===
class SageGatewayCreate(BaseModel):
name: str = Field(
..., min_length=2, max_length=100, description="Nom de la gateway"
)
@ -23,6 +23,8 @@ class SageGatewayCreate(BaseModel):
gateway_token: str = Field(
..., min_length=10, description="Token d'authentification"
)
sage_database: Optional[str] = Field(None, max_length=255)
sage_company: Optional[str] = Field(None, max_length=255)
is_active: bool = Field(False, description="Activer immédiatement cette gateway")
@ -51,6 +53,9 @@ class SageGatewayUpdate(BaseModel):
gateway_url: Optional[str] = None
gateway_token: Optional[str] = Field(None, min_length=10)
sage_database: Optional[str] = None
sage_company: Optional[str] = None
is_default: Optional[bool] = None
priority: Optional[int] = Field(None, ge=0, le=100)
@ -65,8 +70,8 @@ class SageGatewayUpdate(BaseModel):
return v.rstrip("/") if v else v
# === RESPONSE ===
class SageGatewayResponse(BaseModel):
id: str
user_id: str
@ -76,6 +81,9 @@ class SageGatewayResponse(BaseModel):
gateway_url: str
token_preview: str
sage_database: Optional[str] = None
sage_company: Optional[str] = None
is_active: bool
is_default: bool
priority: int
@ -100,7 +108,8 @@ class SageGatewayResponse(BaseModel):
from_attributes = True
class SageGatewayListResponse(BaseModel):
class SageGatewayList(BaseModel):
items: List[SageGatewayResponse]
total: int
active_gateway: Optional[SageGatewayResponse] = None
@ -121,7 +130,7 @@ class SageGatewayActivateRequest(BaseModel):
gateway_id: str
class SageGatewayTestRequest(BaseModel):
class SageGatewayTest(BaseModel):
gateway_url: str
gateway_token: str

View file

@ -0,0 +1,24 @@
from pydantic import BaseModel, Field
from typing import Optional, List
class EntrepriseSearch(BaseModel):
"""Modèle de réponse pour une entreprise trouvée"""
company_name: str = Field(..., description="Raison sociale complète")
siren: str = Field(..., description="Numéro SIREN (9 chiffres)")
vat_number: str = Field(..., description="Numéro de TVA intracommunautaire")
address: str = Field(..., description="Adresse complète du siège")
naf_code: str = Field(..., description="Code NAF/APE")
is_active: bool = Field(..., description="True si entreprise active")
siret_siege: Optional[str] = Field(None, description="SIRET du siège")
code_postal: Optional[str] = None
ville: Optional[str] = None
class EntrepriseSearchResponse(BaseModel):
"""Réponse globale de la recherche"""
total_results: int
results: List[EntrepriseSearch]
query: str

View file

@ -0,0 +1,46 @@
from pydantic import BaseModel
from typing import Optional, List
class ExerciceComptable(BaseModel):
numero: int
debut: str
fin: Optional[str] = None
class SocieteInfo(BaseModel):
raison_sociale: str
numero_dossier: str
siret: Optional[str] = None
code_ape: Optional[str] = None
numero_tva: Optional[str] = None
adresse: Optional[str] = None
complement_adresse: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
code_region: Optional[str] = None
pays: Optional[str] = None
telephone: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
email_societe: Optional[str] = None
site_web: Optional[str] = None
capital: float = 0.0
forme_juridique: Optional[str] = None
exercices: List[ExerciceComptable] = []
devise_compte: int = 0
devise_equivalent: int = 0
longueur_compte_general: int = 0
longueur_compte_analytique: int = 0
regime_fec: int = 0
base_modele: Optional[str] = None
marqueur: int = 0
logo_base64: Optional[str] = None
logo_content_type: Optional[str] = None

View file

View file

@ -1,6 +1,6 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from schemas.tiers.contact import Contact
from typing import Optional
from schemas.tiers.tiers import TiersDetails
class ClientResponse(BaseModel):
@ -13,271 +13,25 @@ class ClientResponse(BaseModel):
telephone: Optional[str] = None
class ClientDetails(BaseModel):
numero: Optional[str] = Field(None, description="Code client (CT_Num)")
intitule: Optional[str] = Field(
None, description="Raison sociale ou Nom complet (CT_Intitule)"
)
type_tiers: Optional[int] = Field(
None, description="Type : 0=Client, 1=Fournisseur (CT_Type)"
)
qualite: Optional[str] = Field(
None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)"
)
classement: Optional[str] = Field(
None, description="Code de classement (CT_Classement)"
)
raccourci: Optional[str] = Field(
None, description="Code raccourci 7 car. (CT_Raccourci)"
)
siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)")
tva_intra: Optional[str] = Field(
None, description="N° TVA intracommunautaire (CT_Identifiant)"
)
code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)")
contact: Optional[str] = Field(
None, description="Nom du contact principal (CT_Contact)"
)
adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)")
complement: Optional[str] = Field(
None, description="Complément d'adresse (CT_Complement)"
)
code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)")
ville: Optional[str] = Field(None, description="Ville (CT_Ville)")
region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)")
pays: Optional[str] = Field(None, description="Pays (CT_Pays)")
telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)")
telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)")
email: Optional[str] = Field(None, description="Email principal (CT_EMail)")
site_web: Optional[str] = Field(None, description="Site web (CT_Site)")
facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)")
linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)")
taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)")
taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)")
taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)")
taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)")
statistique01: Optional[str] = Field(
None, description="Statistique 1 (CT_Statistique01)"
)
statistique02: Optional[str] = Field(
None, description="Statistique 2 (CT_Statistique02)"
)
statistique03: Optional[str] = Field(
None, description="Statistique 3 (CT_Statistique03)"
)
statistique04: Optional[str] = Field(
None, description="Statistique 4 (CT_Statistique04)"
)
statistique05: Optional[str] = Field(
None, description="Statistique 5 (CT_Statistique05)"
)
statistique06: Optional[str] = Field(
None, description="Statistique 6 (CT_Statistique06)"
)
statistique07: Optional[str] = Field(
None, description="Statistique 7 (CT_Statistique07)"
)
statistique08: Optional[str] = Field(
None, description="Statistique 8 (CT_Statistique08)"
)
statistique09: Optional[str] = Field(
None, description="Statistique 9 (CT_Statistique09)"
)
statistique10: Optional[str] = Field(
None, description="Statistique 10 (CT_Statistique10)"
)
encours_autorise: Optional[float] = Field(
None, description="Encours maximum autorisé (CT_Encours)"
)
assurance_credit: Optional[float] = Field(
None, description="Montant assurance crédit (CT_Assurance)"
)
langue: Optional[int] = Field(
None, description="Code langue 0=FR, 1=EN (CT_Langue)"
)
commercial_code: Optional[int] = Field(
None, description="Code du commercial (CO_No)"
)
lettrage_auto: Optional[bool] = Field(
None, description="Lettrage automatique (CT_Lettrage)"
)
est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)")
type_facture: Optional[int] = Field(
None, description="Type facture 0=Facture, 1=BL (CT_Facture)"
)
est_prospect: Optional[bool] = Field(
None, description="True si prospect (CT_Prospect=1)"
)
bl_en_facture: Optional[int] = Field(
None, description="Imprimer BL en facture (CT_BLFact)"
)
saut_page: Optional[int] = Field(
None, description="Saut de page sur documents (CT_Saut)"
)
validation_echeance: Optional[int] = Field(
None, description="Valider les échéances (CT_ValidEch)"
)
controle_encours: Optional[int] = Field(
None, description="Contrôler l'encours (CT_ControlEnc)"
)
exclure_relance: Optional[bool] = Field(
None, description="Exclure des relances (CT_NotRappel)"
)
exclure_penalites: Optional[bool] = Field(
None, description="Exclure des pénalités (CT_NotPenal)"
)
bon_a_payer: Optional[int] = Field(
None, description="Bon à payer obligatoire (CT_BonAPayer)"
)
priorite_livraison: Optional[int] = Field(
None, description="Priorité livraison (CT_PrioriteLivr)"
)
livraison_partielle: Optional[int] = Field(
None, description="Livraison partielle (CT_LivrPartielle)"
)
delai_transport: Optional[int] = Field(
None, description="Délai transport jours (CT_DelaiTransport)"
)
delai_appro: Optional[int] = Field(
None, description="Délai appro jours (CT_DelaiAppro)"
)
commentaire: Optional[str] = Field(
None, description="Commentaire libre (CT_Commentaire)"
)
section_analytique: Optional[str] = Field(
None, description="Section analytique (CA_Num)"
)
mode_reglement_code: Optional[int] = Field(
None, description="Code mode règlement (MR_No)"
)
surveillance_active: Optional[bool] = Field(
None, description="Surveillance financière (CT_Surveillance)"
)
coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)")
forme_juridique: Optional[str] = Field(
None, description="Forme juridique SA, SARL (CT_SvFormeJuri)"
)
effectif: Optional[str] = Field(
None, description="Nombre d'employés (CT_SvEffectif)"
)
sv_regularite: Optional[str] = Field(
None, description="Régularité paiements (CT_SvRegul)"
)
sv_cotation: Optional[str] = Field(
None, description="Cotation crédit (CT_SvCotation)"
)
sv_objet_maj: Optional[str] = Field(
None, description="Objet dernière MAJ (CT_SvObjetMaj)"
)
sv_chiffre_affaires: Optional[float] = Field(
None, description="Chiffre d'affaires (CT_SvCA)"
)
sv_resultat: Optional[float] = Field(
None, description="Résultat financier (CT_SvResultat)"
)
compte_general: Optional[str] = Field(
None, description="Compte général principal (CG_NumPrinc)"
)
categorie_tarif: Optional[int] = Field(
None, description="Catégorie tarifaire (N_CatTarif)"
)
categorie_compta: Optional[int] = Field(
None, description="Catégorie comptable (N_CatCompta)"
)
contacts: Optional[List[Contact]] = Field(
default_factory=list, description="Liste des contacts du client"
)
class ClientDetails(TiersDetails):
class Config:
json_schema_extra = {
"example": {
"numero": "CLI000001",
"intitule": "SARL EXEMPLE",
"type_tiers": 0,
"qualite": "CLI",
"classement": "A",
"raccourci": "EXEMPL",
"siret": "12345678901234",
"tva_intra": "FR12345678901",
"code_naf": "6201Z",
"contact": "Jean Dupont",
"adresse": "123 Rue de la Paix",
"complement": "Bâtiment B",
"code_postal": "75001",
"ville": "Paris",
"region": "Île-de-France",
"pays": "France",
"telephone": "0123456789",
"telecopie": "0123456788",
"email": "contact@exemple.fr",
"site_web": "https://www.exemple.fr",
"facebook": "https://facebook.com/exemple",
"linkedin": "https://linkedin.com/company/exemple",
"taux01": 0.0,
"taux02": 0.0,
"taux03": 0.0,
"taux04": 0.0,
"statistique01": "Informatique",
"statistique02": "",
"statistique03": "",
"statistique04": "",
"statistique05": "",
"statistique06": "",
"statistique07": "",
"statistique08": "",
"statistique09": "",
"statistique10": "",
"encours_autorise": 50000.0,
"assurance_credit": 40000.0,
"langue": 0,
"commercial_code": 1,
"lettrage_auto": True,
"est_actif": True,
"type_facture": 1,
"est_prospect": False,
"bl_en_facture": 0,
"saut_page": 0,
"validation_echeance": 0,
"controle_encours": 1,
"exclure_relance": False,
"exclure_penalites": False,
"bon_a_payer": 0,
"priorite_livraison": 1,
"livraison_partielle": 1,
"delai_transport": 2,
"delai_appro": 0,
"commentaire": "Client important",
"section_analytique": "",
"mode_reglement_code": 1,
"surveillance_active": True,
"coface": "COF12345",
"forme_juridique": "SARL",
"effectif": "50-99",
"sv_regularite": "",
"sv_cotation": "",
"sv_objet_maj": "",
"sv_chiffre_affaires": 2500000.0,
"sv_resultat": 150000.0,
"compte_general": "4110000",
"categorie_tarif": 0,
"categorie_compta": 0,
"commercial": {
"numero": 1,
"nom": "DUPONT",
"prenom": "Jean",
"email": "j.dupont@entreprise.fr",
},
}
}
class ClientCreateRequest(BaseModel):
class ClientCreate(BaseModel):
intitule: str = Field(
..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE"
)
@ -679,7 +433,7 @@ class ClientCreateRequest(BaseModel):
}
class ClientUpdateRequest(BaseModel):
class ClientUpdate(BaseModel):
intitule: Optional[str] = Field(None, max_length=69)
qualite: Optional[str] = Field(None, max_length=17)
classement: Optional[str] = Field(None, max_length=17)

111
schemas/tiers/commercial.py Normal file
View file

@ -0,0 +1,111 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
class CollaborateurBase(BaseModel):
"""Champs communs collaborateur"""
nom: str = Field(..., max_length=50)
prenom: Optional[str] = Field(None, max_length=50)
fonction: Optional[str] = Field(None, max_length=50)
adresse: Optional[str] = Field(None, max_length=100)
complement: Optional[str] = Field(None, max_length=100)
code_postal: Optional[str] = Field(None, max_length=10)
ville: Optional[str] = Field(None, max_length=50)
code_region: Optional[str] = Field(None, max_length=50)
pays: Optional[str] = Field(None, max_length=50)
service: Optional[str] = Field(None, max_length=50)
vendeur: bool = Field(default=False)
caissier: bool = Field(default=False)
acheteur: bool = Field(default=False)
chef_ventes: bool = Field(default=False)
numero_chef_ventes: Optional[int] = None
telephone: Optional[str] = Field(None, max_length=20)
telecopie: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
tel_portable: Optional[str] = Field(None, max_length=20)
facebook: Optional[str] = Field(None, max_length=100)
linkedin: Optional[str] = Field(None, max_length=100)
skype: Optional[str] = Field(None, max_length=100)
matricule: Optional[str] = Field(None, max_length=20)
sommeil: bool = Field(default=False)
class CollaborateurCreate(CollaborateurBase):
"""Création d'un collaborateur"""
pass
class CollaborateurUpdate(BaseModel):
"""Modification d'un collaborateur (tous champs optionnels)"""
nom: Optional[str] = Field(None, max_length=50)
prenom: Optional[str] = Field(None, max_length=50)
fonction: Optional[str] = Field(None, max_length=50)
adresse: Optional[str] = Field(None, max_length=100)
complement: Optional[str] = Field(None, max_length=100)
code_postal: Optional[str] = Field(None, max_length=10)
ville: Optional[str] = Field(None, max_length=50)
code_region: Optional[str] = Field(None, max_length=50)
pays: Optional[str] = Field(None, max_length=50)
service: Optional[str] = Field(None, max_length=50)
vendeur: Optional[bool] = None
caissier: Optional[bool] = None
acheteur: Optional[bool] = None
chef_ventes: Optional[bool] = None
numero_chef_ventes: Optional[int] = None
telephone: Optional[str] = Field(None, max_length=20)
telecopie: Optional[str] = Field(None, max_length=20)
email: Optional[EmailStr] = None
tel_portable: Optional[str] = Field(None, max_length=20)
facebook: Optional[str] = Field(None, max_length=100)
linkedin: Optional[str] = Field(None, max_length=100)
skype: Optional[str] = Field(None, max_length=100)
matricule: Optional[str] = Field(None, max_length=20)
sommeil: Optional[bool] = None
class CollaborateurListe(BaseModel):
"""Vue liste simplifiée"""
numero: int
nom: str
prenom: Optional[str]
fonction: Optional[str]
service: Optional[str]
email: Optional[str]
telephone: Optional[str]
vendeur: bool
sommeil: bool
class CollaborateurDetails(CollaborateurBase):
"""Détails complets d'un collaborateur"""
numero: int
class Config:
json_schema_extra = {
"example": {
"numero": 1,
"nom": "DUPONT",
"prenom": "Jean",
"fonction": "Directeur Commercial",
"service": "Commercial",
"vendeur": True,
"email": "j.dupont@entreprise.fr",
"telephone": "0123456789",
"sommeil": False,
}
}

View file

@ -1,273 +1,27 @@
from pydantic import BaseModel, Field, EmailStr
from typing import List, Optional
from schemas.tiers.contact import Contact
from typing import Optional
from schemas.tiers.tiers import TiersDetails
class FournisseurDetails(BaseModel):
numero: Optional[str] = Field(None, description="Code fournisseur (CT_Num)")
intitule: Optional[str] = Field(
None, description="Raison sociale ou Nom complet (CT_Intitule)"
)
type_tiers: Optional[int] = Field(
None, description="Type : 0=Client, 1=Fournisseur (CT_Type)"
)
qualite: Optional[str] = Field(
None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)"
)
classement: Optional[str] = Field(
None, description="Code de classement (CT_Classement)"
)
raccourci: Optional[str] = Field(
None, description="Code raccourci 7 car. (CT_Raccourci)"
)
siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)")
tva_intra: Optional[str] = Field(
None, description="N° TVA intracommunautaire (CT_Identifiant)"
)
code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)")
contact: Optional[str] = Field(
None, description="Nom du contact principal (CT_Contact)"
)
adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)")
complement: Optional[str] = Field(
None, description="Complément d'adresse (CT_Complement)"
)
code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)")
ville: Optional[str] = Field(None, description="Ville (CT_Ville)")
region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)")
pays: Optional[str] = Field(None, description="Pays (CT_Pays)")
telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)")
telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)")
email: Optional[str] = Field(None, description="Email principal (CT_EMail)")
site_web: Optional[str] = Field(None, description="Site web (CT_Site)")
facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)")
linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)")
taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)")
taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)")
taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)")
taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)")
statistique01: Optional[str] = Field(
None, description="Statistique 1 (CT_Statistique01)"
)
statistique02: Optional[str] = Field(
None, description="Statistique 2 (CT_Statistique02)"
)
statistique03: Optional[str] = Field(
None, description="Statistique 3 (CT_Statistique03)"
)
statistique04: Optional[str] = Field(
None, description="Statistique 4 (CT_Statistique04)"
)
statistique05: Optional[str] = Field(
None, description="Statistique 5 (CT_Statistique05)"
)
statistique06: Optional[str] = Field(
None, description="Statistique 6 (CT_Statistique06)"
)
statistique07: Optional[str] = Field(
None, description="Statistique 7 (CT_Statistique07)"
)
statistique08: Optional[str] = Field(
None, description="Statistique 8 (CT_Statistique08)"
)
statistique09: Optional[str] = Field(
None, description="Statistique 9 (CT_Statistique09)"
)
statistique10: Optional[str] = Field(
None, description="Statistique 10 (CT_Statistique10)"
)
encours_autorise: Optional[float] = Field(
None, description="Encours maximum autorisé (CT_Encours)"
)
assurance_credit: Optional[float] = Field(
None, description="Montant assurance crédit (CT_Assurance)"
)
langue: Optional[int] = Field(
None, description="Code langue 0=FR, 1=EN (CT_Langue)"
)
commercial_code: Optional[int] = Field(
None, description="Code du commercial (CO_No)"
)
lettrage_auto: Optional[bool] = Field(
None, description="Lettrage automatique (CT_Lettrage)"
)
est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)")
type_facture: Optional[int] = Field(
None, description="Type facture 0=Facture, 1=BL (CT_Facture)"
)
est_prospect: Optional[bool] = Field(
None, description="True si prospect (CT_Prospect=1)"
)
bl_en_facture: Optional[int] = Field(
None, description="Imprimer BL en facture (CT_BLFact)"
)
saut_page: Optional[int] = Field(
None, description="Saut de page sur documents (CT_Saut)"
)
validation_echeance: Optional[int] = Field(
None, description="Valider les échéances (CT_ValidEch)"
)
controle_encours: Optional[int] = Field(
None, description="Contrôler l'encours (CT_ControlEnc)"
)
exclure_relance: Optional[bool] = Field(
None, description="Exclure des relances (CT_NotRappel)"
)
exclure_penalites: Optional[bool] = Field(
None, description="Exclure des pénalités (CT_NotPenal)"
)
bon_a_payer: Optional[int] = Field(
None, description="Bon à payer obligatoire (CT_BonAPayer)"
)
priorite_livraison: Optional[int] = Field(
None, description="Priorité livraison (CT_PrioriteLivr)"
)
livraison_partielle: Optional[int] = Field(
None, description="Livraison partielle (CT_LivrPartielle)"
)
delai_transport: Optional[int] = Field(
None, description="Délai transport jours (CT_DelaiTransport)"
)
delai_appro: Optional[int] = Field(
None, description="Délai appro jours (CT_DelaiAppro)"
)
commentaire: Optional[str] = Field(
None, description="Commentaire libre (CT_Commentaire)"
)
section_analytique: Optional[str] = Field(
None, description="Section analytique (CA_Num)"
)
mode_reglement_code: Optional[int] = Field(
None, description="Code mode règlement (MR_No)"
)
surveillance_active: Optional[bool] = Field(
None, description="Surveillance financière (CT_Surveillance)"
)
coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)")
forme_juridique: Optional[str] = Field(
None, description="Forme juridique SA, SARL (CT_SvFormeJuri)"
)
effectif: Optional[str] = Field(
None, description="Nombre d'employés (CT_SvEffectif)"
)
sv_regularite: Optional[str] = Field(
None, description="Régularité paiements (CT_SvRegul)"
)
sv_cotation: Optional[str] = Field(
None, description="Cotation crédit (CT_SvCotation)"
)
sv_objet_maj: Optional[str] = Field(
None, description="Objet dernière MAJ (CT_SvObjetMaj)"
)
sv_chiffre_affaires: Optional[float] = Field(
None, description="Chiffre d'affaires (CT_SvCA)"
)
sv_resultat: Optional[float] = Field(
None, description="Résultat financier (CT_SvResultat)"
)
compte_general: Optional[str] = Field(
None, description="Compte général principal (CG_NumPrinc)"
)
categorie_tarif: Optional[int] = Field(
None, description="Catégorie tarifaire (N_CatTarif)"
)
categorie_compta: Optional[int] = Field(
None, description="Catégorie comptable (N_CatCompta)"
)
contacts: Optional[List[Contact]] = Field(
default_factory=list, description="Liste des contacts du fournisseur"
)
class FournisseurDetails(TiersDetails):
class Config:
json_schema_extra = {
"example": {
"numero": "FOU000001",
"intitule": "SARL FOURNISSEUR EXEMPLE",
"intitule": "SARL FOURNISSEUR",
"type_tiers": 1,
"qualite": "FOU",
"classement": "A",
"raccourci": "EXEMPL",
"siret": "12345678901234",
"tva_intra": "FR12345678901",
"code_naf": "6201Z",
"contact": "Jean Dupont",
"adresse": "123 Rue de la Paix",
"complement": "Bâtiment B",
"code_postal": "75001",
"ville": "Paris",
"region": "Île-de-France",
"pays": "France",
"telephone": "0123456789",
"telecopie": "0123456788",
"email": "contact@exemple.fr",
"site_web": "https://www.exemple.fr",
"facebook": "https://facebook.com/exemple",
"linkedin": "https://linkedin.com/company/exemple",
"taux01": 0.0,
"taux02": 0.0,
"taux03": 0.0,
"taux04": 0.0,
"statistique01": "Informatique",
"statistique02": "",
"statistique03": "",
"statistique04": "",
"statistique05": "",
"statistique06": "",
"statistique07": "",
"statistique08": "",
"statistique09": "",
"statistique10": "",
"encours_autorise": 50000.0,
"assurance_credit": 40000.0,
"langue": 0,
"commercial_code": 1,
"lettrage_auto": True,
"est_actif": True,
"type_facture": 1,
"est_prospect": False,
"bl_en_facture": 0,
"saut_page": 0,
"validation_echeance": 0,
"controle_encours": 1,
"exclure_relance": False,
"exclure_penalites": False,
"bon_a_payer": 0,
"priorite_livraison": 1,
"livraison_partielle": 1,
"delai_transport": 2,
"delai_appro": 0,
"commentaire": "Client important",
"section_analytique": "",
"mode_reglement_code": 1,
"surveillance_active": True,
"coface": "COF12345",
"forme_juridique": "SARL",
"effectif": "50-99",
"sv_regularite": "",
"sv_cotation": "",
"sv_objet_maj": "",
"sv_chiffre_affaires": 2500000.0,
"sv_resultat": 150000.0,
"compte_general": "4110000",
"categorie_tarif": 0,
"categorie_compta": 0,
"commercial": {
"numero": 1,
"nom": "MARTIN",
"prenom": "Sophie",
"email": "s.martin@entreprise.fr",
},
}
}
class FournisseurCreateAPIRequest(BaseModel):
class FournisseurCreate(BaseModel):
intitule: str = Field(
..., min_length=1, max_length=69, description="Raison sociale du fournisseur"
)
@ -304,7 +58,7 @@ class FournisseurCreateAPIRequest(BaseModel):
}
class FournisseurUpdateRequest(BaseModel):
class FournisseurUpdate(BaseModel):
intitule: Optional[str] = Field(None, min_length=1, max_length=69)
adresse: Optional[str] = Field(None, max_length=35)
code_postal: Optional[str] = Field(None, max_length=9)

View file

@ -3,6 +3,8 @@ from pydantic import BaseModel, Field
from schemas.tiers.contact import Contact
from enum import IntEnum
from schemas.tiers.tiers_collab import Collaborateur
class TypeTiersInt(IntEnum):
CLIENT = 0
@ -12,7 +14,6 @@ class TypeTiersInt(IntEnum):
class TiersDetails(BaseModel):
# IDENTIFICATION
numero: Optional[str] = Field(None, description="Code tiers (CT_Num)")
intitule: Optional[str] = Field(
None, description="Raison sociale ou Nom complet (CT_Intitule)"
@ -35,7 +36,6 @@ class TiersDetails(BaseModel):
)
code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)")
# ADRESSE
contact: Optional[str] = Field(
None, description="Nom du contact principal (CT_Contact)"
)
@ -48,7 +48,6 @@ class TiersDetails(BaseModel):
region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)")
pays: Optional[str] = Field(None, description="Pays (CT_Pays)")
# TELECOM
telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)")
telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)")
email: Optional[str] = Field(None, description="Email principal (CT_EMail)")
@ -56,13 +55,11 @@ class TiersDetails(BaseModel):
facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)")
linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)")
# TAUX
taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)")
taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)")
taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)")
taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)")
# STATISTIQUES
statistique01: Optional[str] = Field(
None, description="Statistique 1 (CT_Statistique01)"
)
@ -94,7 +91,6 @@ class TiersDetails(BaseModel):
None, description="Statistique 10 (CT_Statistique10)"
)
# COMMERCIAL
encours_autorise: Optional[float] = Field(
None, description="Encours maximum autorisé (CT_Encours)"
)
@ -107,8 +103,10 @@ class TiersDetails(BaseModel):
commercial_code: Optional[int] = Field(
None, description="Code du commercial (CO_No)"
)
commercial: Optional[Collaborateur] = Field(
None, description="Détails du commercial/collaborateur"
)
# FACTURATION
lettrage_auto: Optional[bool] = Field(
None, description="Lettrage automatique (CT_Lettrage)"
)
@ -141,7 +139,6 @@ class TiersDetails(BaseModel):
None, description="Bon à payer obligatoire (CT_BonAPayer)"
)
# LOGISTIQUE
priorite_livraison: Optional[int] = Field(
None, description="Priorité livraison (CT_PrioriteLivr)"
)
@ -155,17 +152,14 @@ class TiersDetails(BaseModel):
None, description="Délai appro jours (CT_DelaiAppro)"
)
# COMMENTAIRE
commentaire: Optional[str] = Field(
None, description="Commentaire libre (CT_Commentaire)"
)
# ANALYTIQUE
section_analytique: Optional[str] = Field(
None, description="Section analytique (CA_Num)"
)
# ORGANISATION / SURVEILLANCE
mode_reglement_code: Optional[int] = Field(
None, description="Code mode règlement (MR_No)"
)
@ -195,7 +189,6 @@ class TiersDetails(BaseModel):
None, description="Résultat financier (CT_SvResultat)"
)
# COMPTE GENERAL ET CATEGORIES
compte_general: Optional[str] = Field(
None, description="Compte général principal (CG_NumPrinc)"
)
@ -206,7 +199,6 @@ class TiersDetails(BaseModel):
None, description="Catégorie comptable (N_CatCompta)"
)
# CONTACTS
contacts: Optional[List[Contact]] = Field(
default_factory=list, description="Liste des contacts du tiers"
)

View file

@ -0,0 +1,54 @@
from typing import Optional
from pydantic import BaseModel, Field
class Collaborateur(BaseModel):
"""Modèle pour un collaborateur/commercial"""
numero: Optional[int] = Field(None, description="Numéro du collaborateur (CO_No)")
nom: Optional[str] = Field(None, description="Nom (CO_Nom)")
prenom: Optional[str] = Field(None, description="Prénom (CO_Prenom)")
fonction: Optional[str] = Field(None, description="Fonction (CO_Fonction)")
adresse: Optional[str] = Field(None, description="Adresse (CO_Adresse)")
complement: Optional[str] = Field(
None, description="Complément adresse (CO_Complement)"
)
code_postal: Optional[str] = Field(None, description="Code postal (CO_CodePostal)")
ville: Optional[str] = Field(None, description="Ville (CO_Ville)")
region: Optional[str] = Field(None, description="Région (CO_CodeRegion)")
pays: Optional[str] = Field(None, description="Pays (CO_Pays)")
service: Optional[str] = Field(None, description="Service (CO_Service)")
est_vendeur: Optional[bool] = Field(None, description="Est vendeur (CO_Vendeur)")
est_caissier: Optional[bool] = Field(None, description="Est caissier (CO_Caissier)")
est_acheteur: Optional[bool] = Field(None, description="Est acheteur (CO_Acheteur)")
telephone: Optional[str] = Field(None, description="Téléphone (CO_Telephone)")
telecopie: Optional[str] = Field(None, description="Fax (CO_Telecopie)")
email: Optional[str] = Field(None, description="Email (CO_EMail)")
tel_portable: Optional[str] = Field(None, description="Portable (CO_TelPortable)")
matricule: Optional[str] = Field(None, description="Matricule (CO_Matricule)")
facebook: Optional[str] = Field(None, description="Facebook (CO_Facebook)")
linkedin: Optional[str] = Field(None, description="LinkedIn (CO_LinkedIn)")
skype: Optional[str] = Field(None, description="Skype (CO_Skype)")
est_actif: Optional[bool] = Field(None, description="Est actif (CO_Sommeil=0)")
est_chef_ventes: Optional[bool] = Field(
None, description="Est chef des ventes (CO_ChefVentes)"
)
chef_ventes_numero: Optional[int] = Field(
None, description="N° chef des ventes (CO_NoChefVentes)"
)
class Config:
json_schema_extra = {
"example": {
"numero": 1,
"nom": "DUPONT",
"prenom": "Jean",
"fonction": "Commercial",
"service": "Ventes",
"est_vendeur": True,
"telephone": "0123456789",
"email": "j.dupont@entreprise.fr",
"tel_portable": "0612345678",
"est_actif": True,
}
}

View file

@ -2,7 +2,7 @@ from pydantic import BaseModel
from typing import Optional
class UserResponse(BaseModel):
class Users(BaseModel):
id: str
email: str
nom: str

651
scripts/manage_security.py Normal file
View file

@ -0,0 +1,651 @@
import sys
import os
from pathlib import Path
import asyncio
import argparse
import logging
from datetime import datetime
from typing import Optional, List
import json
from sqlalchemy import select
_current_file = Path(__file__).resolve()
_script_dir = _current_file.parent
_app_dir = _script_dir.parent
print(f"DEBUG: Script path: {_current_file}")
print(f"DEBUG: App dir: {_app_dir}")
print(f"DEBUG: Current working dir: {os.getcwd()}")
if str(_app_dir) in sys.path:
sys.path.remove(str(_app_dir))
sys.path.insert(0, str(_app_dir))
os.chdir(str(_app_dir))
print(f"DEBUG: sys.path[0]: {sys.path[0]}")
print(f"DEBUG: New working dir: {os.getcwd()}")
_test_imports = [
"database",
"database.db_config",
"database.models",
"services",
"security",
]
print("\nDEBUG: Vérification des imports...")
for module in _test_imports:
try:
__import__(module)
print(f"{module}")
except ImportError as e:
print(f"{module}: {e}")
try:
from database.db_config import async_session_factory
from database.models.api_key import SwaggerUser, ApiKey
from services.api_key import ApiKeyService
from security.auth import hash_password
except ImportError as e:
print(f"\n ERREUR D'IMPORT: {e}")
print(" Vérifiez que vous êtes dans /app")
print(" Commande correcte: cd /app && python scripts/manage_security.py ...")
sys.exit(1)
logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
AVAILABLE_TAGS = {
"Authentication": " Authentification et gestion des comptes",
"API Keys Management": "🔑 Gestion des clés API",
"Clients": "👥 Gestion des clients",
"Fournisseurs": "🏭 Gestion des fournisseurs",
"Prospects": "🎯 Gestion des prospects",
"Tiers": "📋 Gestion générale des tiers",
"Contacts": "📞 Contacts des tiers",
"Articles": "📦 Catalogue articles",
"Familles": "🏷️ Familles d'articles",
"Stock": "📊 Mouvements de stock",
"Devis": "📄 Devis",
"Commandes": "🛒 Commandes",
"Livraisons": "🚚 Bons de livraison",
"Factures": "💰 Factures",
"Avoirs": "↩️ Avoirs",
"Règlements": "💳 Règlements et encaissements",
"Workflows": " Transformations de documents",
"Documents": "📑 Gestion documents (PDF)",
"Emails": "📧 Envoi d'emails",
"Validation": " Validations métier",
"Collaborateurs": "👔 Collaborateurs internes",
"Société": "🏢 Informations société",
"Référentiels": "📚 Données de référence",
"System": "⚙️ Système et santé",
"Admin": "🛠️ Administration",
"Debug": "🐛 Debug et diagnostics",
}
PRESET_PROFILES = {
"commercial": [
"Clients",
"Contacts",
"Devis",
"Commandes",
"Factures",
"Articles",
"Documents",
"Emails",
],
"comptable": [
"Clients",
"Fournisseurs",
"Factures",
"Avoirs",
"Règlements",
"Documents",
"Emails",
],
"logistique": [
"Articles",
"Stock",
"Commandes",
"Livraisons",
"Fournisseurs",
"Documents",
],
"readonly": ["Clients", "Articles", "Devis", "Commandes", "Factures", "Documents"],
"developer": [
"Authentication",
"API Keys Management",
"System",
"Clients",
"Articles",
"Devis",
"Commandes",
"Factures",
],
}
async def add_swagger_user(
username: str,
password: str,
full_name: str = None,
tags: Optional[List[str]] = None,
preset: Optional[str] = None,
):
"""Ajouter un utilisateur Swagger avec configuration avancée"""
async with async_session_factory() as session:
result = await session.execute(
select(SwaggerUser).where(SwaggerUser.username == username)
)
existing = result.scalar_one_or_none()
if existing:
logger.error(f" L'utilisateur '{username}' existe déjà")
return
if preset:
if preset not in PRESET_PROFILES:
logger.error(
f" Preset '{preset}' inconnu. Disponibles: {list(PRESET_PROFILES.keys())}"
)
return
tags = PRESET_PROFILES[preset]
logger.info(f"📋 Application du preset '{preset}': {len(tags)} tags")
swagger_user = SwaggerUser(
username=username,
hashed_password=hash_password(password),
full_name=full_name or username,
is_active=True,
allowed_tags=json.dumps(tags) if tags else None,
)
session.add(swagger_user)
await session.commit()
logger.info(f" Utilisateur Swagger créé: {username}")
logger.info(f" Nom complet: {swagger_user.full_name}")
if tags:
logger.info(f" 🏷️ Tags autorisés ({len(tags)}):")
for tag in tags:
desc = AVAILABLE_TAGS.get(tag, "")
logger.info(f"{tag} {desc}")
else:
logger.info(" 👑 Accès ADMIN COMPLET (tous les tags)")
async def list_swagger_users():
"""Lister tous les utilisateurs Swagger avec détails"""
async with async_session_factory() as session:
result = await session.execute(select(SwaggerUser))
users = result.scalars().all()
if not users:
logger.info("🔭 Aucun utilisateur Swagger")
return
logger.info(f"\n👥 {len(users)} utilisateur(s) Swagger:\n")
logger.info("=" * 80)
for user in users:
status = " ACTIF" if user.is_active else " NON ACTIF"
logger.info(f"\n{status} {user.username}")
logger.info(f"📛 Nom: {user.full_name}")
logger.info(f"🆔 ID: {user.id}")
logger.info(f"📅 Créé: {user.created_at}")
logger.info(f"🕐 Dernière connexion: {user.last_login or 'Jamais'}")
if user.allowed_tags:
try:
tags = json.loads(user.allowed_tags)
if tags:
logger.info(f"🏷️ Tags autorisés ({len(tags)}):")
for tag in tags:
desc = AVAILABLE_TAGS.get(tag, "")
logger.info(f"{tag} {desc}")
auth_schemes = []
if "Authentication" in tags:
auth_schemes.append("JWT (Bearer)")
if "API Keys Management" in tags or len(tags) > 3:
auth_schemes.append("X-API-Key")
if not auth_schemes:
auth_schemes.append("JWT (Bearer)")
logger.info(
f" Authentification autorisée: {', '.join(auth_schemes)}"
)
else:
logger.info("👑 Tags autorisés: ADMIN COMPLET (tous)")
logger.info(" Authentification: JWT + X-API-Key (tout)")
except json.JSONDecodeError:
logger.info(" Tags: Erreur format")
else:
logger.info("👑 Tags autorisés: ADMIN COMPLET (tous)")
logger.info(" Authentification: JWT + X-API-Key (tout)")
logger.info("\n" + "=" * 80)
async def update_swagger_user(
username: str,
add_tags: Optional[List[str]] = None,
remove_tags: Optional[List[str]] = None,
set_tags: Optional[List[str]] = None,
preset: Optional[str] = None,
active: Optional[bool] = None,
):
"""Mettre à jour un utilisateur Swagger"""
async with async_session_factory() as session:
result = await session.execute(
select(SwaggerUser).where(SwaggerUser.username == username)
)
user = result.scalar_one_or_none()
if not user:
logger.error(f" Utilisateur '{username}' introuvable")
return
modified = False
if preset:
if preset not in PRESET_PROFILES:
logger.error(f" Preset '{preset}' inconnu")
return
user.allowed_tags = json.dumps(PRESET_PROFILES[preset])
logger.info(f"📋 Preset '{preset}' appliqué")
modified = True
elif set_tags is not None:
user.allowed_tags = json.dumps(set_tags) if set_tags else None
logger.info(f" Tags remplacés: {len(set_tags) if set_tags else 0}")
modified = True
elif add_tags or remove_tags:
current_tags = []
if user.allowed_tags:
try:
current_tags = json.loads(user.allowed_tags)
except json.JSONDecodeError:
current_tags = []
if add_tags:
for tag in add_tags:
if tag not in current_tags:
current_tags.append(tag)
logger.info(f" Tag ajouté: {tag}")
modified = True
if remove_tags:
for tag in remove_tags:
if tag in current_tags:
current_tags.remove(tag)
logger.info(f" Tag retiré: {tag}")
modified = True
user.allowed_tags = json.dumps(current_tags) if current_tags else None
if active is not None:
user.is_active = active
logger.info(f" Statut: {'ACTIF' if active else 'INACTIF'}")
modified = True
if modified:
await session.commit()
logger.info(f" Utilisateur '{username}' mis à jour")
else:
logger.info(" Aucune modification effectuée")
async def delete_swagger_user(username: str):
"""Supprimer un utilisateur Swagger"""
async with async_session_factory() as session:
result = await session.execute(
select(SwaggerUser).where(SwaggerUser.username == username)
)
user = result.scalar_one_or_none()
if not user:
logger.error(f" Utilisateur '{username}' introuvable")
return
await session.delete(user)
await session.commit()
logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}")
async def list_available_tags():
"""Liste tous les tags disponibles avec description"""
logger.info("\n🏷️ TAGS DISPONIBLES:\n")
logger.info("=" * 80)
for tag, desc in AVAILABLE_TAGS.items():
logger.info(f" {desc}")
logger.info(f" Nom: {tag}\n")
logger.info("=" * 80)
logger.info("\n📦 PRESETS DISPONIBLES:\n")
for preset_name, tags in PRESET_PROFILES.items():
logger.info(f" {preset_name}:")
logger.info(f" {', '.join(tags)}\n")
logger.info("=" * 80)
async def create_api_key(
name: str,
description: str = None,
expires_in_days: int = 365,
rate_limit: int = 60,
endpoints: list = None,
):
"""Créer une clé API"""
async with async_session_factory() as session:
service = ApiKeyService(session)
api_key_obj, api_key_plain = await service.create_api_key(
name=name,
description=description,
created_by="cli",
expires_in_days=expires_in_days,
rate_limit_per_minute=rate_limit,
allowed_endpoints=endpoints,
)
logger.info("=" * 70)
logger.info("🔑 Clé API créée avec succès")
logger.info("=" * 70)
logger.info(f" ID: {api_key_obj.id}")
logger.info(f" Nom: {api_key_obj.name}")
logger.info(f" Clé: {api_key_plain}")
logger.info(f" Préfixe: {api_key_obj.key_prefix}")
logger.info(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min")
logger.info(f" Expire le: {api_key_obj.expires_at}")
if api_key_obj.allowed_endpoints:
try:
endpoints_list = json.loads(api_key_obj.allowed_endpoints)
logger.info(f" Endpoints: {', '.join(endpoints_list)}")
except Exception:
logger.info(f" Endpoints: {api_key_obj.allowed_endpoints}")
else:
logger.info(" Endpoints: Tous (aucune restriction)")
logger.info("=" * 70)
logger.info(" SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !")
logger.info("=" * 70)
async def list_api_keys():
"""Lister toutes les clés API"""
async with async_session_factory() as session:
service = ApiKeyService(session)
keys = await service.list_api_keys()
if not keys:
logger.info("🔭 Aucune clé API")
return
logger.info(f"🔑 {len(keys)} clé(s) API:\n")
for key in keys:
is_valid = key.is_active and (
not key.expires_at or key.expires_at > datetime.now()
)
status = "" if is_valid else ""
logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)")
logger.info(f" ID: {key.id}")
logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min")
logger.info(f" Requêtes: {key.total_requests}")
logger.info(f" Expire: {key.expires_at or 'Jamais'}")
logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}")
if key.allowed_endpoints:
try:
endpoints = json.loads(key.allowed_endpoints)
display = ", ".join(endpoints[:4])
if len(endpoints) > 4:
display += f"... (+{len(endpoints) - 4})"
logger.info(f" Endpoints: {display}")
except Exception:
pass
else:
logger.info(" Endpoints: Tous")
logger.info("")
async def revoke_api_key(key_id: str):
"""Révoquer une clé API"""
async with async_session_factory() as session:
result = await session.execute(select(ApiKey).where(ApiKey.id == key_id))
key = result.scalar_one_or_none()
if not key:
logger.error(f" Clé API '{key_id}' introuvable")
return
key.is_active = False
key.revoked_at = datetime.now()
await session.commit()
logger.info(f"🗑️ Clé API révoquée: {key.name}")
logger.info(f" ID: {key.id}")
async def verify_api_key(api_key: str):
"""Vérifier une clé API"""
async with async_session_factory() as session:
service = ApiKeyService(session)
key = await service.verify_api_key(api_key)
if not key:
logger.error(" Clé API invalide ou expirée")
return
logger.info("=" * 60)
logger.info(" Clé API valide")
logger.info("=" * 60)
logger.info(f" Nom: {key.name}")
logger.info(f" ID: {key.id}")
logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min")
logger.info(f" Requêtes totales: {key.total_requests}")
logger.info(f" Expire: {key.expires_at or 'Jamais'}")
if key.allowed_endpoints:
try:
endpoints = json.loads(key.allowed_endpoints)
logger.info(f" Endpoints autorisés: {endpoints}")
except Exception:
pass
else:
logger.info(" Endpoints autorisés: Tous")
logger.info("=" * 60)
async def main():
parser = argparse.ArgumentParser(
description="Gestion avancée des utilisateurs Swagger et clés API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
EXEMPLES D'UTILISATION:
=== UTILISATEURS SWAGGER ===
1. Créer un utilisateur avec preset:
python scripts/manage_security.py swagger add commercial Pass123! --preset commercial
2. Créer un admin complet:
python scripts/manage_security.py swagger add admin AdminPass
3. Créer avec tags spécifiques:
python scripts/manage_security.py swagger add client Pass123! --tags Clients Devis Factures
4. Mettre à jour un utilisateur (ajouter des tags):
python scripts/manage_security.py swagger update client --add-tags Commandes Livraisons
5. Changer complètement les tags:
python scripts/manage_security.py swagger update client --set-tags Clients Articles
6. Appliquer un preset:
python scripts/manage_security.py swagger update client --preset comptable
7. Lister les tags disponibles:
python scripts/manage_security.py swagger tags
8. Désactiver temporairement:
python scripts/manage_security.py swagger update client --inactive
=== CLÉS API ===
9. Créer une clé API:
python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100
10. Créer avec endpoints restreints:
python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*"
11. Lister les clés:
python scripts/manage_security.py apikey list
12. Vérifier une clé:
python scripts/manage_security.py apikey verify sdk_live_xxxxx
13. Révoquer une clé:
python scripts/manage_security.py apikey revoke <key_id>
""",
)
subparsers = parser.add_subparsers(dest="command", help="Commandes")
swagger_parser = subparsers.add_parser("swagger", help="Gestion Swagger")
swagger_sub = swagger_parser.add_subparsers(dest="swagger_command")
add_p = swagger_sub.add_parser("add", help="Ajouter utilisateur")
add_p.add_argument("username", help="Nom d'utilisateur")
add_p.add_argument("password", help="Mot de passe")
add_p.add_argument("--full-name", help="Nom complet", default=None)
add_p.add_argument(
"--tags",
nargs="*",
help="Tags autorisés. Vide = admin complet",
default=None,
)
add_p.add_argument(
"--preset",
choices=list(PRESET_PROFILES.keys()),
help="Appliquer un preset de tags",
)
update_p = swagger_sub.add_parser("update", help="Mettre à jour utilisateur")
update_p.add_argument("username", help="Nom d'utilisateur")
update_p.add_argument("--add-tags", nargs="+", help="Ajouter des tags")
update_p.add_argument("--remove-tags", nargs="+", help="Retirer des tags")
update_p.add_argument("--set-tags", nargs="*", help="Définir les tags (remplace)")
update_p.add_argument(
"--preset", choices=list(PRESET_PROFILES.keys()), help="Appliquer preset"
)
update_p.add_argument("--active", action="store_true", help="Activer l'utilisateur")
update_p.add_argument(
"--inactive", action="store_true", help="Désactiver l'utilisateur"
)
swagger_sub.add_parser("list", help="Lister utilisateurs")
del_p = swagger_sub.add_parser("delete", help="Supprimer utilisateur")
del_p.add_argument("username", help="Nom d'utilisateur")
swagger_sub.add_parser("tags", help="Lister les tags disponibles")
apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API")
apikey_sub = apikey_parser.add_subparsers(dest="apikey_command")
create_p = apikey_sub.add_parser("create", help="Créer clé API")
create_p.add_argument("name", help="Nom de la clé")
create_p.add_argument("--description", help="Description")
create_p.add_argument("--days", type=int, default=365, help="Expiration (jours)")
create_p.add_argument("--rate-limit", type=int, default=60, help="Req/min")
create_p.add_argument("--endpoints", nargs="+", help="Endpoints autorisés")
apikey_sub.add_parser("list", help="Lister clés")
rev_p = apikey_sub.add_parser("revoke", help="Révoquer clé")
rev_p.add_argument("key_id", help="ID de la clé")
ver_p = apikey_sub.add_parser("verify", help="Vérifier clé")
ver_p.add_argument("api_key", help="Clé API complète")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
if args.command == "swagger":
if args.swagger_command == "add":
await add_swagger_user(
args.username,
args.password,
args.full_name,
args.tags,
args.preset,
)
elif args.swagger_command == "update":
active = None
if args.active:
active = True
elif args.inactive:
active = False
await update_swagger_user(
args.username,
add_tags=args.add_tags,
remove_tags=args.remove_tags,
set_tags=args.set_tags,
preset=args.preset,
active=active,
)
elif args.swagger_command == "list":
await list_swagger_users()
elif args.swagger_command == "delete":
await delete_swagger_user(args.username)
elif args.swagger_command == "tags":
await list_available_tags()
else:
swagger_parser.print_help()
elif args.command == "apikey":
if args.apikey_command == "create":
await create_api_key(
name=args.name,
description=args.description,
expires_in_days=args.days,
rate_limit=args.rate_limit,
endpoints=args.endpoints,
)
elif args.apikey_command == "list":
await list_api_keys()
elif args.apikey_command == "revoke":
await revoke_api_key(args.key_id)
elif args.apikey_command == "verify":
await verify_api_key(args.api_key)
else:
apikey_parser.print_help()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n Interrupted")
sys.exit(0)
except Exception as e:
logger.error(f" Erreur: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

354
scripts/test_security.py Normal file
View file

@ -0,0 +1,354 @@
import requests
import argparse
import sys
from typing import Tuple
class SecurityTester:
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
self.results = {"passed": 0, "failed": 0, "tests": []}
def log_test(self, name: str, passed: bool, details: str = ""):
"""Enregistrer le résultat d'un test"""
status = " PASS" if passed else " FAIL"
print(f"{status} - {name}")
if details:
print(f" {details}")
self.results["tests"].append(
{"name": name, "passed": passed, "details": details}
)
if passed:
self.results["passed"] += 1
else:
self.results["failed"] += 1
def test_swagger_without_auth(self) -> bool:
"""Test 1: Swagger UI devrait demander une authentification"""
print("\n Test 1: Protection Swagger UI")
try:
response = requests.get(f"{self.base_url}/docs", timeout=5)
if response.status_code == 401:
self.log_test(
"Swagger protégé",
True,
"Code 401 retourné sans authentification",
)
return True
else:
self.log_test(
"Swagger protégé",
False,
f"Code {response.status_code} au lieu de 401",
)
return False
except Exception as e:
self.log_test("Swagger protégé", False, f"Erreur: {str(e)}")
return False
def test_swagger_with_auth(self, username: str, password: str) -> bool:
"""Test 2: Swagger UI accessible avec credentials valides"""
print("\n Test 2: Accès Swagger avec authentification")
try:
response = requests.get(
f"{self.base_url}/docs", auth=(username, password), timeout=5
)
if response.status_code == 200:
self.log_test(
"Accès Swagger avec auth",
True,
f"Authentifié comme {username}",
)
return True
else:
self.log_test(
"Accès Swagger avec auth",
False,
f"Code {response.status_code}, credentials invalides?",
)
return False
except Exception as e:
self.log_test("Accès Swagger avec auth", False, f"Erreur: {str(e)}")
return False
def test_api_without_auth(self) -> bool:
"""Test 3: Endpoints API devraient demander une authentification"""
print("\n Test 3: Protection des endpoints API")
test_endpoints = ["/api/v1/clients", "/api/v1/documents"]
all_protected = True
for endpoint in test_endpoints:
try:
response = requests.get(f"{self.base_url}{endpoint}", timeout=5)
if response.status_code == 401:
print(f" {endpoint} protégé (401)")
else:
print(
f" {endpoint} accessible sans auth (code {response.status_code})"
)
all_protected = False
except Exception as e:
print(f" {endpoint} erreur: {str(e)}")
all_protected = False
self.log_test("Endpoints API protégés", all_protected)
return all_protected
def test_health_endpoint_public(self) -> bool:
"""Test 4: Endpoint /health devrait être accessible sans auth"""
print("\n Test 4: Endpoint /health public")
try:
response = requests.get(f"{self.base_url}/health", timeout=5)
if response.status_code == 200:
self.log_test("/health accessible", True, "Endpoint public fonctionne")
return True
else:
self.log_test(
"/health accessible",
False,
f"Code {response.status_code} inattendu",
)
return False
except Exception as e:
self.log_test("/health accessible", False, f"Erreur: {str(e)}")
return False
def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]:
"""Test 5: Créer une clé API via l'endpoint"""
print("\n Test 5: Création d'une clé API")
try:
login_response = requests.post(
f"{self.base_url}/api/v1/auth/login",
json={"email": username, "password": password},
timeout=5,
)
if login_response.status_code != 200:
self.log_test(
"Création clé API",
False,
"Impossible de se connecter pour obtenir un JWT",
)
return False, ""
jwt_token = login_response.json().get("access_token")
create_response = requests.post(
f"{self.base_url}/api/v1/api-keys",
headers={"Authorization": f"Bearer {jwt_token}"},
json={
"name": "Test API Key",
"description": "Clé de test automatisé",
"rate_limit_per_minute": 60,
"expires_in_days": 30,
},
timeout=5,
)
if create_response.status_code == 201:
api_key = create_response.json().get("api_key")
self.log_test("Création clé API", True, f"Clé créée: {api_key[:20]}...")
return True, api_key
else:
self.log_test(
"Création clé API",
False,
f"Code {create_response.status_code}",
)
return False, ""
except Exception as e:
self.log_test("Création clé API", False, f"Erreur: {str(e)}")
return False, ""
def test_api_key_usage(self, api_key: str) -> bool:
"""Test 6: Utiliser une clé API pour accéder à un endpoint"""
print("\n Test 6: Utilisation d'une clé API")
if not api_key:
self.log_test("Utilisation clé API", False, "Pas de clé disponible")
return False
try:
response = requests.get(
f"{self.base_url}/api/v1/clients",
headers={"X-API-Key": api_key},
timeout=5,
)
if response.status_code == 200:
self.log_test("Utilisation clé API", True, "Clé acceptée")
return True
else:
self.log_test(
"Utilisation clé API",
False,
f"Code {response.status_code}, clé refusée?",
)
return False
except Exception as e:
self.log_test("Utilisation clé API", False, f"Erreur: {str(e)}")
return False
def test_invalid_api_key(self) -> bool:
"""Test 7: Une clé invalide devrait être refusée"""
print("\n Test 7: Rejet de clé API invalide")
invalid_key = "sdk_live_invalid_key_12345"
try:
response = requests.get(
f"{self.base_url}/api/v1/clients",
headers={"X-API-Key": invalid_key},
timeout=5,
)
if response.status_code == 401:
self.log_test("Clé invalide rejetée", True, "Code 401 comme attendu")
return True
else:
self.log_test(
"Clé invalide rejetée",
False,
f"Code {response.status_code} au lieu de 401",
)
return False
except Exception as e:
self.log_test("Clé invalide rejetée", False, f"Erreur: {str(e)}")
return False
def test_rate_limiting(self, api_key: str) -> bool:
"""Test 8: Rate limiting (optionnel, peut prendre du temps)"""
print("\n Test 8: Rate limiting (test simple)")
if not api_key:
self.log_test("Rate limiting", False, "Pas de clé disponible")
return False
print(" Envoi de 70 requêtes rapides...")
rate_limited = False
for i in range(70):
try:
response = requests.get(
f"{self.base_url}/health",
headers={"X-API-Key": api_key},
timeout=1,
)
if response.status_code == 429:
rate_limited = True
print(f" Rate limit atteint à la requête {i + 1}")
break
except Exception:
pass
if rate_limited:
self.log_test("Rate limiting", True, "Rate limit détecté")
return True
else:
self.log_test(
"Rate limiting",
True,
"Aucun rate limit détecté (peut être normal si pas implémenté)",
)
return True
def print_summary(self):
"""Afficher le résumé des tests"""
print("\n" + "=" * 60)
print(" RÉSUMÉ DES TESTS")
print("=" * 60)
total = self.results["passed"] + self.results["failed"]
success_rate = (self.results["passed"] / total * 100) if total > 0 else 0
print(f"\nTotal: {total} tests")
print(f" Réussis: {self.results['passed']}")
print(f" Échoués: {self.results['failed']}")
print(f"Taux de réussite: {success_rate:.1f}%\n")
if self.results["failed"] == 0:
print("🎉 Tous les tests sont passés ! Sécurité OK.")
return 0
else:
print(" Certains tests ont échoué. Vérifiez la configuration.")
return 1
def main():
parser = argparse.ArgumentParser(
description="Test automatisé de la sécurité de l'API"
)
parser.add_argument(
"--url",
required=True,
help="URL de base de l'API (ex: http://localhost:8000)",
)
parser.add_argument(
"--swagger-user", required=True, help="Utilisateur Swagger pour les tests"
)
parser.add_argument(
"--swagger-pass", required=True, help="Mot de passe Swagger pour les tests"
)
parser.add_argument(
"--skip-rate-limit",
action="store_true",
help="Sauter le test de rate limiting (long)",
)
args = parser.parse_args()
print(" Démarrage des tests de sécurité")
print(f" URL cible: {args.url}\n")
tester = SecurityTester(args.url)
tester.test_swagger_without_auth()
tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass)
tester.test_api_without_auth()
tester.test_health_endpoint_public()
success, api_key = tester.test_api_key_creation(
args.swagger_user, args.swagger_pass
)
if success and api_key:
tester.test_api_key_usage(api_key)
tester.test_invalid_api_key()
if not args.skip_rate_limit:
tester.test_rate_limiting(api_key)
else:
print("\n Test de rate limiting sauté")
else:
print("\n Tests avec clé API sautés (création échouée)")
exit_code = tester.print_summary()
sys.exit(exit_code)
if __name__ == "__main__":
main()

View file

@ -1,55 +0,0 @@
from security.auth import (
hash_password,
verify_password,
validate_password_strength,
generate_verification_token,
generate_reset_token,
generate_csrf_token,
generate_secure_token,
hash_token,
constant_time_compare,
create_access_token,
create_refresh_token,
decode_token,
generate_session_id,
)
from security.cookies import CookieManager, set_auth_cookies
from security.fingerprint import (
DeviceFingerprint,
get_fingerprint_hash,
validate_fingerprint,
get_client_ip,
)
from security.csrf import CSRFProtection, verify_csrf, generate_csrf_for_session
from security.rate_limiter import RateLimiter, check_rate_limit_dependency
__all__ = [
"hash_password",
"verify_password",
"validate_password_strength",
"generate_verification_token",
"generate_reset_token",
"generate_csrf_token",
"generate_secure_token",
"hash_token",
"constant_time_compare",
"create_access_token",
"create_refresh_token",
"decode_token",
"generate_session_id",
"CookieManager",
"set_auth_cookies",
"DeviceFingerprint",
"get_fingerprint_hash",
"validate_fingerprint",
"get_client_ip",
"CSRFProtection",
"verify_csrf",
"generate_csrf_for_session",
"RateLimiter",
"check_rate_limit_dependency",
]

View file

@ -1,17 +1,18 @@
from passlib.context import CryptContext
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
from datetime import datetime, timedelta
from typing import Optional, Dict
import jwt
import secrets
import hashlib
import hmac
import logging
from config.config import settings
logger = logging.getLogger(__name__)
SECRET_KEY = settings.jwt_secret
ALGORITHM = settings.jwt_algorithm
ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes
REFRESH_TOKEN_EXPIRE_DAYS = settings.refresh_token_expire_days
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
@ -19,192 +20,79 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception as e:
logger.warning(f"Erreur verification mot de passe: {e}")
return False
def generate_secure_token(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def generate_verification_token() -> str:
return generate_secure_token(32)
return secrets.token_urlsafe(32)
def generate_reset_token() -> str:
return generate_secure_token(32)
def generate_csrf_token() -> str:
return generate_secure_token(32)
def generate_refresh_token_id() -> str:
return generate_secure_token(16)
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def constant_time_compare(val1: str, val2: str) -> bool:
return hmac.compare_digest(val1.encode(), val2.encode())
def create_access_token(
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None,
fingerprint_hash: Optional[str] = None,
) -> str:
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
expire = datetime.utcnow() + expires_delta
else:
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update(
{
"exp": expire,
"iat": now,
"nbf": now,
"type": "access",
"jti": generate_secure_token(8),
}
)
to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"})
if fingerprint_hash:
to_encode["fph"] = fingerprint_hash
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(
user_id: str,
token_id: Optional[str] = None,
fingerprint_hash: Optional[str] = None,
expires_delta: Optional[timedelta] = None,
) -> Tuple[str, str]:
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(days=settings.refresh_token_expire_days)
if not token_id:
token_id = generate_refresh_token_id()
def create_refresh_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"sub": user_id,
"exp": expire,
"iat": now,
"nbf": now,
"iat": datetime.utcnow(),
"type": "refresh",
"jti": token_id,
"jti": secrets.token_urlsafe(16), # Unique ID
}
if fingerprint_hash:
to_encode["fph"] = fingerprint_hash
token = jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return token, token_id
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_csrf_token(session_id: str) -> str:
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.csrf_token_expire_minutes)
to_encode = {
"sid": session_id,
"exp": expire,
"iat": now,
"type": "csrf",
"jti": generate_secure_token(8),
}
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(
token: str, expected_type: Optional[str] = None
) -> Optional[Dict[str, Any]]:
def decode_token(token: str) -> Optional[Dict]:
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
options={
"require": ["exp", "iat", "type"],
"verify_exp": True,
"verify_iat": True,
"verify_nbf": True,
},
)
if expected_type and payload.get("type") != expected_type:
logger.warning(
f"Type de token incorrect: attendu={expected_type}, recu={payload.get('type')}"
)
return None
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
logger.debug("Token expire")
return None
raise jwt.InvalidTokenError("Token expiré")
except jwt.DecodeError:
raise jwt.InvalidTokenError("Token invalide (format incorrect)")
except jwt.InvalidTokenError as e:
logger.warning(f"Token invalide: {e}")
return None
raise jwt.InvalidTokenError(f"Token invalide: {str(e)}")
except Exception as e:
logger.error(f"Erreur decodage token: {e}")
return None
raise jwt.InvalidTokenError(f"Erreur lors du décodage du token: {str(e)}")
def validate_password_strength(password: str) -> Tuple[bool, str]:
if len(password) < settings.password_min_length:
return (
False,
f"Le mot de passe doit contenir au moins {settings.password_min_length} caracteres",
)
def validate_password_strength(password: str) -> tuple[bool, str]:
if len(password) < 8:
return False, "Le mot de passe doit contenir au moins 8 caractères"
if settings.password_require_uppercase and not any(c.isupper() for c in password):
if not any(c.isupper() for c in password):
return False, "Le mot de passe doit contenir au moins une majuscule"
if settings.password_require_lowercase and not any(c.islower() for c in password):
if not any(c.islower() for c in password):
return False, "Le mot de passe doit contenir au moins une minuscule"
if settings.password_require_digit and not any(c.isdigit() for c in password):
if not any(c.isdigit() for c in password):
return False, "Le mot de passe doit contenir au moins un chiffre"
if settings.password_require_special:
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?/~`"
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
if not any(c in special_chars for c in password):
return False, "Le mot de passe doit contenir au moins un caractere special"
common_passwords = [
"password",
"123456",
"qwerty",
"admin",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"login",
]
if password.lower() in common_passwords:
return False, "Ce mot de passe est trop courant"
return False, "Le mot de passe doit contenir au moins un caractère spécial"
return True, ""
def generate_session_id() -> str:
"""Genere un identifiant de session unique."""
return generate_secure_token(24)

View file

@ -1,157 +0,0 @@
from fastapi import Response, Request
from typing import Optional
import logging
from config.config import settings
logger = logging.getLogger(__name__)
class CookieManager:
@staticmethod
def _get_samesite_value() -> str:
value = settings.cookie_samesite.lower()
if value in ("strict", "lax", "none"):
return value
return "strict"
@staticmethod
def _should_be_secure() -> bool:
if settings.is_development and not settings.cookie_secure:
return False
return True
@classmethod
def set_access_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.access_token_expire_minutes * 60
response.set_cookie(
key=settings.cookie_access_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie access_token defini")
@classmethod
def set_refresh_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.refresh_token_expire_days * 24 * 60 * 60
response.set_cookie(
key=settings.cookie_refresh_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/auth",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie refresh_token defini")
@classmethod
def set_csrf_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.csrf_token_expire_minutes * 60
response.set_cookie(
key=settings.cookie_csrf_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=False,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie csrf_token defini")
@classmethod
def clear_access_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_access_token_name,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie access_token supprime")
@classmethod
def clear_refresh_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_refresh_token_name,
path="/auth",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie refresh_token supprime")
@classmethod
def clear_csrf_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_csrf_token_name,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=False,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie csrf_token supprime")
@classmethod
def clear_all_auth_cookies(cls, response: Response) -> None:
cls.clear_access_token(response)
cls.clear_refresh_token(response)
cls.clear_csrf_token(response)
logger.debug("Tous les cookies auth supprimes")
@classmethod
def get_access_token(cls, request: Request) -> Optional[str]:
token = request.cookies.get(settings.cookie_access_token_name)
if token:
return token
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:]
return None
@classmethod
def get_refresh_token(cls, request: Request) -> Optional[str]:
return request.cookies.get(settings.cookie_refresh_token_name)
@classmethod
def get_csrf_token(cls, request: Request) -> Optional[str]:
csrf_header = request.headers.get("X-CSRF-Token")
if csrf_header:
return csrf_header
return request.cookies.get(settings.cookie_csrf_token_name)
def set_auth_cookies(
response: Response, access_token: str, refresh_token: str, csrf_token: str
) -> None:
CookieManager.set_access_token(response, access_token)
CookieManager.set_refresh_token(response, refresh_token)
CookieManager.set_csrf_token(response, csrf_token)

View file

@ -1,117 +0,0 @@
"""
security/csrf.py - Protection contre les attaques Cross-Site Request Forgery
"""
from fastapi import Request, HTTPException, status
from typing import Optional, Set
import logging
from config.config import settings
from security.auth import decode_token, create_csrf_token, constant_time_compare
logger = logging.getLogger(__name__)
SAFE_METHODS: Set[str] = {"GET", "HEAD", "OPTIONS", "TRACE"}
CSRF_EXEMPT_PATHS: Set[str] = {
"/auth/login",
"/auth/register",
"/auth/forgot-password",
"/auth/verify-email",
"/auth/resend-verification",
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/webhooks/universign",
}
class CSRFProtection:
@classmethod
def is_exempt(cls, request: Request) -> bool:
if request.method in SAFE_METHODS:
return True
path = request.url.path.rstrip("/")
if path in CSRF_EXEMPT_PATHS:
return True
for exempt_path in CSRF_EXEMPT_PATHS:
if path.startswith(exempt_path):
return True
return False
@classmethod
def generate_token(cls, session_id: str) -> str:
return create_csrf_token(session_id)
@classmethod
def validate_token(cls, request: Request, session_id: Optional[str] = None) -> bool:
csrf_header = request.headers.get("X-CSRF-Token")
if not csrf_header:
logger.warning("Token CSRF manquant dans le header")
return False
payload = decode_token(csrf_header, expected_type="csrf")
if not payload:
logger.warning("Token CSRF invalide ou expire")
return False
if session_id and payload.get("sid") != session_id:
logger.warning("Token CSRF ne correspond pas a la session")
return False
return True
@classmethod
def validate_double_submit(cls, request: Request) -> bool:
header_token = request.headers.get("X-CSRF-Token")
cookie_token = request.cookies.get(settings.cookie_csrf_token_name)
if not header_token or not cookie_token:
logger.warning("Token CSRF manquant (header ou cookie)")
return False
if not constant_time_compare(header_token, cookie_token):
logger.warning("Tokens CSRF ne correspondent pas")
return False
return True
@classmethod
def validate_request(
cls,
request: Request,
session_id: Optional[str] = None,
use_double_submit: bool = True,
) -> bool:
if cls.is_exempt(request):
return True
if use_double_submit:
if not cls.validate_double_submit(request):
return False
return cls.validate_token(request, session_id)
async def verify_csrf(request: Request, session_id: Optional[str] = None) -> None:
if CSRFProtection.is_exempt(request):
return
if not CSRFProtection.validate_request(request, session_id):
logger.warning(
f"Verification CSRF echouee pour {request.method} {request.url.path}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Verification CSRF echouee"
)
def generate_csrf_for_session(session_id: str) -> str:
return CSRFProtection.generate_token(session_id)

View file

@ -1,122 +0,0 @@
from fastapi import Request
from typing import Dict
import hashlib
import hmac
import logging
from config.config import settings
logger = logging.getLogger(__name__)
class DeviceFingerprint:
COMPONENT_EXTRACTORS = {
"user_agent": lambda r: r.headers.get("User-Agent", ""),
"accept_language": lambda r: r.headers.get("Accept-Language", ""),
"accept_encoding": lambda r: r.headers.get("Accept-Encoding", ""),
"accept": lambda r: r.headers.get("Accept", ""),
"connection": lambda r: r.headers.get("Connection", ""),
"cache_control": lambda r: r.headers.get("Cache-Control", ""),
"client_ip": lambda r: DeviceFingerprint._get_client_ip(r),
"sec_ch_ua": lambda r: r.headers.get("Sec-CH-UA", ""),
"sec_ch_ua_platform": lambda r: r.headers.get("Sec-CH-UA-Platform", ""),
"sec_ch_ua_mobile": lambda r: r.headers.get("Sec-CH-UA-Mobile", ""),
}
@staticmethod
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
if request.client:
return request.client.host
return ""
@classmethod
def extract_components(cls, request: Request) -> Dict[str, str]:
components = {}
for component_name in settings.fingerprint_components:
extractor = cls.COMPONENT_EXTRACTORS.get(component_name)
if extractor:
try:
value = extractor(request)
components[component_name] = value if value else ""
except Exception as e:
logger.warning(f"Erreur extraction composant {component_name}: {e}")
components[component_name] = ""
else:
logger.warning(f"Extracteur inconnu pour composant: {component_name}")
return components
@classmethod
def generate_hash(cls, request: Request, include_ip: bool = False) -> str:
components = cls.extract_components(request)
if not include_ip and "client_ip" in components:
del components["client_ip"]
sorted_keys = sorted(components.keys())
fingerprint_data = "|".join(f"{k}:{components[k]}" for k in sorted_keys)
secret = settings.fingerprint_secret or settings.jwt_secret
signature = hmac.new(
secret.encode(), fingerprint_data.encode(), hashlib.sha256
).hexdigest()
return signature
@classmethod
def generate_from_components(cls, components: Dict[str, str]) -> str:
sorted_keys = sorted(components.keys())
fingerprint_data = "|".join(f"{k}:{components.get(k, '')}" for k in sorted_keys)
secret = settings.fingerprint_secret or settings.jwt_secret
signature = hmac.new(
secret.encode(), fingerprint_data.encode(), hashlib.sha256
).hexdigest()
return signature
@classmethod
def validate(
cls, request: Request, stored_hash: str, include_ip: bool = False
) -> bool:
if not stored_hash:
return True
current_hash = cls.generate_hash(request, include_ip=include_ip)
return hmac.compare_digest(current_hash, stored_hash)
@classmethod
def get_device_info(cls, request: Request) -> Dict[str, str]:
user_agent = request.headers.get("User-Agent", "")
return {
"user_agent": user_agent[:500] if user_agent else "",
"ip_address": cls._get_client_ip(request),
"accept_language": request.headers.get("Accept-Language", "")[:100],
"fingerprint_hash": cls.generate_hash(request),
}
def get_fingerprint_hash(request: Request) -> str:
return DeviceFingerprint.generate_hash(request)
def validate_fingerprint(request: Request, stored_hash: str) -> bool:
return DeviceFingerprint.validate(request, stored_hash)
def get_client_ip(request: Request) -> str:
return DeviceFingerprint._get_client_ip(request)

View file

@ -1,147 +0,0 @@
from fastapi import Request, HTTPException, status
from typing import Optional, Tuple
import logging
from config.config import settings
from services.redis_service import redis_service
from security.fingerprint import get_client_ip
logger = logging.getLogger(__name__)
class RateLimiter:
@staticmethod
def _make_key(identifier: str, action: str) -> str:
return f"{action}:{identifier}"
@classmethod
async def check_login_rate_limit(
cls, email: str, ip_address: str
) -> Tuple[bool, Optional[str], int]:
window_seconds = settings.rate_limit_login_window_minutes * 60
max_attempts = settings.rate_limit_login_attempts
email_key = cls._make_key(email.lower(), "login_email")
email_count = await redis_service.get_rate_limit_count(email_key)
if email_count >= max_attempts:
return (
False,
f"Trop de tentatives pour cet email. Reessayez dans {settings.rate_limit_login_window_minutes} minutes.",
window_seconds,
)
ip_key = cls._make_key(ip_address, "login_ip")
ip_count = await redis_service.get_rate_limit_count(ip_key)
ip_limit = max_attempts * 3
if ip_count >= ip_limit:
return (
False,
window_seconds,
)
return (True, None, 0)
@classmethod
async def record_login_attempt(
cls, email: str, ip_address: str, success: bool
) -> None:
window_seconds = settings.rate_limit_login_window_minutes * 60
if success:
email_key = cls._make_key(email.lower(), "login_email")
await redis_service.reset_rate_limit(email_key)
logger.debug(f"Rate limit reinitialise pour {email}")
else:
email_key = cls._make_key(email.lower(), "login_email")
await redis_service.increment_rate_limit(email_key, window_seconds)
ip_key = cls._make_key(ip_address, "login_ip")
await redis_service.increment_rate_limit(ip_key, window_seconds)
logger.debug(
f"Tentative echouee enregistree pour {email} depuis {ip_address}"
)
@classmethod
async def check_api_rate_limit(
cls, identifier: str, endpoint: Optional[str] = None
) -> Tuple[bool, int, int]:
window_seconds = settings.rate_limit_api_window_seconds
max_requests = settings.rate_limit_api_requests
if endpoint:
key = cls._make_key(f"{identifier}:{endpoint}", "api")
else:
key = cls._make_key(identifier, "api")
count = await redis_service.increment_rate_limit(key, window_seconds)
remaining = max(0, max_requests - count)
if count > max_requests:
return (False, remaining, window_seconds)
return (True, remaining, window_seconds)
@classmethod
async def check_password_reset_rate_limit(
cls, email: str, ip_address: str
) -> Tuple[bool, Optional[str]]:
window_seconds = 3600
max_attempts_email = 3
max_attempts_ip = 10
email_key = cls._make_key(email.lower(), "reset_email")
email_count = await redis_service.get_rate_limit_count(email_key)
if email_count >= max_attempts_email:
return (False, "Trop de demandes de reinitialisation pour cet email.")
ip_key = cls._make_key(ip_address, "reset_ip")
ip_count = await redis_service.get_rate_limit_count(ip_key)
if ip_count >= max_attempts_ip:
return (False, "Trop de demandes depuis cette adresse IP.")
await redis_service.increment_rate_limit(email_key, window_seconds)
await redis_service.increment_rate_limit(ip_key, window_seconds)
return (True, None)
@classmethod
async def check_registration_rate_limit(
cls, ip_address: str
) -> Tuple[bool, Optional[str]]:
window_seconds = 3600
max_registrations = 5
key = cls._make_key(ip_address, "register_ip")
count = await redis_service.get_rate_limit_count(key)
if count >= max_registrations:
return (False, "Trop d'inscriptions depuis cette adresse IP.")
await redis_service.increment_rate_limit(key, window_seconds)
return (True, None)
async def check_rate_limit_dependency(request: Request) -> None:
ip = get_client_ip(request)
allowed, remaining, reset_seconds = await RateLimiter.check_api_rate_limit(ip)
request.state.rate_limit_remaining = remaining
request.state.rate_limit_reset = reset_seconds
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Limite de requetes atteinte. Reessayez plus tard.",
headers={
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_seconds),
"Retry-After": str(reset_seconds),
},
)

223
services/api_key.py Normal file
View file

@ -0,0 +1,223 @@
import secrets
import hashlib
import json
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
import logging
from database.models.api_key import ApiKey
logger = logging.getLogger(__name__)
class ApiKeyService:
"""Service de gestion des clés API"""
def __init__(self, session: AsyncSession):
self.session = session
@staticmethod
def generate_api_key() -> str:
"""Génère une clé API unique et sécurisée"""
random_part = secrets.token_urlsafe(32)
return f"sdk_live_{random_part}"
@staticmethod
def hash_api_key(api_key: str) -> str:
"""Hash la clé API pour stockage sécurisé"""
return hashlib.sha256(api_key.encode()).hexdigest()
@staticmethod
def get_key_prefix(api_key: str) -> str:
"""Extrait le préfixe de la clé pour identification"""
return api_key[:12] if len(api_key) >= 12 else api_key
async def create_api_key(
self,
name: str,
description: Optional[str] = None,
created_by: str = "system",
user_id: Optional[str] = None,
expires_in_days: Optional[int] = None,
rate_limit_per_minute: int = 60,
allowed_endpoints: Optional[List[str]] = None,
) -> tuple[ApiKey, str]:
api_key_plain = self.generate_api_key()
key_hash = self.hash_api_key(api_key_plain)
key_prefix = self.get_key_prefix(api_key_plain)
expires_at = None
if expires_in_days:
expires_at = datetime.now() + timedelta(days=expires_in_days)
api_key_obj = ApiKey(
key_hash=key_hash,
key_prefix=key_prefix,
name=name,
description=description,
created_by=created_by,
user_id=user_id,
expires_at=expires_at,
rate_limit_per_minute=rate_limit_per_minute,
allowed_endpoints=json.dumps(allowed_endpoints)
if allowed_endpoints
else None,
)
self.session.add(api_key_obj)
await self.session.commit()
await self.session.refresh(api_key_obj)
logger.info(f" Clé API créée: {name} (prefix: {key_prefix})")
return api_key_obj, api_key_plain
async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]:
key_hash = self.hash_api_key(api_key_plain)
result = await self.session.execute(
select(ApiKey).where(
and_(
ApiKey.key_hash == key_hash,
ApiKey.is_active,
ApiKey.revoked_at.is_(None),
or_(
ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now()
),
)
)
)
api_key_obj = result.scalar_one_or_none()
if api_key_obj:
api_key_obj.total_requests += 1
api_key_obj.last_used_at = datetime.now()
await self.session.commit()
logger.debug(f" Clé API validée: {api_key_obj.name}")
else:
logger.warning(" Clé API invalide ou expirée")
return api_key_obj
async def list_api_keys(
self,
include_revoked: bool = False,
user_id: Optional[str] = None,
) -> List[ApiKey]:
"""Liste les clés API"""
query = select(ApiKey)
if not include_revoked:
query = query.where(ApiKey.revoked_at.is_(None))
if user_id:
query = query.where(ApiKey.user_id == user_id)
query = query.order_by(ApiKey.created_at.desc())
result = await self.session.execute(query)
return list(result.scalars().all())
async def revoke_api_key(self, key_id: str) -> bool:
"""Révoque une clé API"""
result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id))
api_key_obj = result.scalar_one_or_none()
if not api_key_obj:
return False
api_key_obj.is_active = False
api_key_obj.revoked_at = datetime.now()
await self.session.commit()
logger.info(f"🗑️ Clé API révoquée: {api_key_obj.name}")
return True
async def get_by_id(self, key_id: str) -> Optional[ApiKey]:
"""Récupère une clé API par son ID"""
result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id))
return result.scalar_one_or_none()
async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]:
return True, {
"allowed": True,
"limit": api_key_obj.rate_limit_per_minute,
"remaining": api_key_obj.rate_limit_per_minute,
}
async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool:
if not api_key_obj.allowed_endpoints:
logger.debug(
f"🔓 API Key {api_key_obj.name}: Aucune restriction d'endpoint"
)
return True
try:
allowed = json.loads(api_key_obj.allowed_endpoints)
if "*" in allowed or "/*" in allowed:
logger.debug(f"🔓 API Key {api_key_obj.name}: Accès global autorisé")
return True
for pattern in allowed:
if pattern == endpoint:
logger.debug(f" Match exact: {pattern} == {endpoint}")
return True
if pattern.endswith("/*"):
base = pattern[:-2] # "/clients/*" → "/clients"
if endpoint == base or endpoint.startswith(base + "/"):
logger.debug(f" Match wildcard: {pattern}{endpoint}")
return True
elif pattern.endswith("*"):
base = pattern[:-1] # "/clients*" → "/clients"
if endpoint.startswith(base):
logger.debug(f" Match prefix: {pattern}{endpoint}")
return True
logger.warning(
f" API Key {api_key_obj.name}: Accès refusé à {endpoint}\n"
f" Endpoints autorisés: {allowed}"
)
return False
except json.JSONDecodeError:
logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}")
return False
def api_key_to_response(api_key_obj: ApiKey, show_key: bool = False) -> Dict:
"""Convertit un objet ApiKey en réponse API"""
allowed_endpoints = None
if api_key_obj.allowed_endpoints:
try:
allowed_endpoints = json.loads(api_key_obj.allowed_endpoints)
except json.JSONDecodeError:
pass
is_expired = False
if api_key_obj.expires_at:
is_expired = api_key_obj.expires_at < datetime.now()
return {
"id": api_key_obj.id,
"name": api_key_obj.name,
"description": api_key_obj.description,
"key_prefix": api_key_obj.key_prefix,
"is_active": api_key_obj.is_active,
"is_expired": is_expired,
"rate_limit_per_minute": api_key_obj.rate_limit_per_minute,
"allowed_endpoints": allowed_endpoints,
"total_requests": api_key_obj.total_requests,
"last_used_at": api_key_obj.last_used_at,
"created_at": api_key_obj.created_at,
"expires_at": api_key_obj.expires_at,
"revoked_at": api_key_obj.revoked_at,
"created_by": api_key_obj.created_by,
}

View file

@ -1,318 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select, and_
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from fastapi import Request
import uuid
import json
import logging
from database import AuditLog, AuditEventType, LoginAttempt
from security.fingerprint import DeviceFingerprint, get_client_ip
logger = logging.getLogger(__name__)
class AuditService:
@classmethod
async def log_event(
cls,
session: AsyncSession,
event_type: AuditEventType,
request: Optional[Request] = None,
user_id: Optional[str] = None,
description: Optional[str] = None,
success: bool = True,
failure_reason: Optional[str] = None,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> AuditLog:
ip_address = None
user_agent = None
fingerprint_hash = None
request_method = None
request_path = None
if request:
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")[:500]
fingerprint_hash = DeviceFingerprint.generate_hash(request)
request_method = request.method
request_path = str(request.url.path)[:500]
metadata_json = None
if metadata:
try:
metadata_json = json.dumps(metadata, default=str)
except Exception as e:
logger.warning(f"Erreur serialisation metadata audit: {e}")
audit_log = AuditLog(
id=str(uuid.uuid4()),
user_id=user_id,
event_type=event_type,
event_description=description,
ip_address=ip_address,
user_agent=user_agent,
fingerprint_hash=fingerprint_hash,
resource_type=resource_type,
resource_id=resource_id,
request_method=request_method,
request_path=request_path,
metadata=metadata_json,
success=success,
failure_reason=failure_reason,
created_at=datetime.now(),
)
session.add(audit_log)
await session.flush()
log_level = logging.INFO if success else logging.WARNING
logger.log(
log_level,
f"Audit: {event_type.value} user={user_id} success={success} ip={ip_address}",
)
return audit_log
@classmethod
async def log_login_success(
cls, session: AsyncSession, request: Request, user_id: str, email: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGIN_SUCCESS,
request=request,
user_id=user_id,
description=f"Connexion reussie pour {email}",
success=True,
metadata={"email": email},
)
@classmethod
async def log_login_failed(
cls,
session: AsyncSession,
request: Request,
email: str,
reason: str,
user_id: Optional[str] = None,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGIN_FAILED,
request=request,
user_id=user_id,
description=f"Echec connexion pour {email}: {reason}",
success=False,
failure_reason=reason,
metadata={"email": email},
)
@classmethod
async def log_logout(
cls, session: AsyncSession, request: Request, user_id: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGOUT,
request=request,
user_id=user_id,
description="Deconnexion utilisateur",
success=True,
)
@classmethod
async def log_password_change(
cls,
session: AsyncSession,
request: Request,
user_id: str,
method: str = "user_initiated",
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.PASSWORD_CHANGE,
request=request,
user_id=user_id,
description=f"Mot de passe modifie ({method})",
success=True,
metadata={"method": method},
)
@classmethod
async def log_password_reset_request(
cls,
session: AsyncSession,
request: Request,
email: str,
user_id: Optional[str] = None,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.PASSWORD_RESET_REQUEST,
request=request,
user_id=user_id,
description=f"Demande reset mot de passe pour {email}",
success=True,
metadata={"email": email},
)
@classmethod
async def log_account_locked(
cls, session: AsyncSession, request: Request, user_id: str, reason: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.ACCOUNT_LOCKED,
request=request,
user_id=user_id,
description=f"Compte verrouille: {reason}",
success=True,
metadata={"reason": reason},
)
@classmethod
async def log_token_refresh(
cls, session: AsyncSession, request: Request, user_id: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.TOKEN_REFRESH,
request=request,
user_id=user_id,
description="Token rafraichi",
success=True,
)
@classmethod
async def log_suspicious_activity(
cls,
session: AsyncSession,
request: Request,
user_id: Optional[str],
activity_type: str,
details: str,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.SUSPICIOUS_ACTIVITY,
request=request,
user_id=user_id,
description=f"Activite suspecte: {activity_type} - {details}",
success=False,
failure_reason=activity_type,
metadata={"activity_type": activity_type, "details": details},
)
@classmethod
async def record_login_attempt(
cls,
session: AsyncSession,
request: Request,
email: str,
success: bool,
failure_reason: Optional[str] = None,
) -> LoginAttempt:
attempt = LoginAttempt(
email=email.lower(),
ip_address=get_client_ip(request),
user_agent=request.headers.get("User-Agent", "")[:500],
fingerprint_hash=DeviceFingerprint.generate_hash(request),
success=success,
failure_reason=failure_reason,
timestamp=datetime.now(),
)
session.add(attempt)
await session.flush()
return attempt
@classmethod
async def get_recent_failed_attempts(
cls, session: AsyncSession, email: str, window_minutes: int = 15
) -> int:
time_threshold = datetime.now() - timedelta(minutes=window_minutes)
result = await session.execute(
select(LoginAttempt).where(
and_(
LoginAttempt.email == email.lower(),
LoginAttempt.success.is_(false()),
LoginAttempt.timestamp >= time_threshold,
)
)
)
return len(result.scalars().all())
@classmethod
async def get_user_audit_history(
cls,
session: AsyncSession,
user_id: str,
limit: int = 50,
event_types: Optional[List[AuditEventType]] = None,
) -> List[AuditLog]:
query = select(AuditLog).where(AuditLog.user_id == user_id)
if event_types:
query = query.where(AuditLog.event_type.in_(event_types))
query = query.order_by(AuditLog.created_at.desc()).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
@classmethod
async def detect_suspicious_patterns(
cls, session: AsyncSession, user_id: str
) -> Dict[str, Any]:
one_hour_ago = datetime.now() - timedelta(hours=1)
one_day_ago = datetime.now() - timedelta(days=1)
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.LOGIN_FAILED,
AuditLog.created_at >= one_hour_ago,
)
)
)
failed_logins_hour = len(result.scalars().all())
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.LOGIN_SUCCESS,
AuditLog.created_at >= one_day_ago,
)
)
)
login_logs = result.scalars().all()
unique_ips = set(log.ip_address for log in login_logs if log.ip_address)
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.PASSWORD_RESET_REQUEST,
AuditLog.created_at >= one_day_ago,
)
)
)
password_resets = len(result.scalars().all())
return {
"failed_logins_last_hour": failed_logins_hour,
"unique_ips_last_day": len(unique_ips),
"password_reset_requests_last_day": password_resets,
"is_suspicious": (
failed_logins_hour >= 5 or len(unique_ips) >= 5 or password_resets >= 3
),
}

View file

@ -1,571 +0,0 @@
import threading
import queue
import time
import asyncio
from datetime import datetime, timedelta
import smtplib
import ssl
import socket
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from config.config import settings
import logging
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import cm
from io import BytesIO
logger = logging.getLogger(__name__)
ULTRA_DEBUG = True
def debug_log(message: str, level: str = "INFO"):
if ULTRA_DEBUG:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
prefix = {
"INFO": "[INFO]",
"SUCCESS": "[SUCCESS]",
"ERROR": "[ERROR]",
"WARN": "[WARN]",
"STEP": "[STEP]",
"DATA": "[DATA]",
}.get(level, "")
logger.info(f"{prefix} [{timestamp}] {message}")
class EmailQueue:
def __init__(self):
self.queue = queue.Queue()
self.workers = []
self.running = False
self.session_factory = None
self.sage_client = None
def start(self, num_workers: int = 3):
if self.running:
logger.warning("Queue déjà démarrée")
return
self.running = True
for i in range(num_workers):
worker = threading.Thread(
target=self._worker, name=f"EmailWorker-{i}", daemon=True
)
worker.start()
self.workers.append(worker)
logger.info(f" Queue email démarrée avec {num_workers} worker(s)")
def stop(self):
logger.info("Arrêt de la queue email...")
self.running = False
try:
self.queue.join()
logger.info(" Queue email arrêtée proprement")
except Exception:
logger.warning(" Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str):
self.queue.put(email_log_id)
debug_log(
f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})"
)
def _worker(self):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
worker_name = threading.current_thread().name
debug_log(f"Worker {worker_name} démarré", "SUCCESS")
try:
while self.running:
try:
email_log_id = self.queue.get(timeout=1)
debug_log(
f"[{worker_name}] Traitement email {email_log_id}", "STEP"
)
loop.run_until_complete(self._process_email(email_log_id))
self.queue.task_done()
except queue.Empty:
continue
except Exception as e:
logger.error(f" Erreur worker {worker_name}: {e}", exc_info=True)
try:
self.queue.task_done()
except Exception:
pass
finally:
loop.close()
debug_log(f"Worker {worker_name} arrêté", "WARN")
async def _process_email(self, email_log_id: str):
from database import EmailLog, StatutEmail
from sqlalchemy import select
debug_log(f"═══ DÉBUT TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
if not self.session_factory:
logger.error(" session_factory non configuré")
return
async with self.session_factory() as session:
result = await session.execute(
select(EmailLog).where(EmailLog.id == email_log_id)
)
email_log = result.scalar_one_or_none()
if not email_log:
logger.error(f" Email log {email_log_id} introuvable en DB")
return
debug_log("Email trouvé en DB:", "DATA")
debug_log(f" → Destinataire: {email_log.destinataire}")
debug_log(f" → Sujet: {email_log.sujet[:50]}...")
debug_log(f" → Tentative: {email_log.nb_tentatives + 1}")
debug_log(f" → Documents: {email_log.document_ids}")
email_log.statut = StatutEmail.EN_COURS
email_log.nb_tentatives += 1
await session.commit()
try:
await self._send_with_retry(email_log)
email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None
debug_log(
f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS"
)
except Exception as e:
error_msg = str(e)
debug_log(f"ÉCHEC ENVOI: {error_msg}", "ERROR")
email_log.statut = StatutEmail.ERREUR
email_log.derniere_erreur = error_msg[:1000]
if email_log.nb_tentatives < settings.max_retry_attempts:
delay = settings.retry_delay_seconds * (
2 ** (email_log.nb_tentatives - 1)
)
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
timer.daemon = True
timer.start()
debug_log(
f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s",
"WARN",
)
else:
debug_log(
f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives",
"ERROR",
)
await session.commit()
debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
async def _send_with_retry(self, email_log):
debug_log("Construction du message MIME...", "STEP")
msg = MIMEMultipart()
msg["From"] = settings.smtp_from
msg["To"] = email_log.destinataire
msg["Subject"] = email_log.sujet
debug_log("Headers configurés:", "DATA")
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → To: {email_log.destinataire}")
debug_log(f" → Subject: {email_log.sujet}")
msg.attach(MIMEText(email_log.corps_html, "html"))
debug_log(f"Corps HTML attaché ({len(email_log.corps_html)} chars)")
# Attachement des PDFs
if email_log.document_ids:
document_ids = email_log.document_ids.split(",")
type_doc = email_log.type_document
debug_log(f"Documents à attacher: {document_ids}")
for doc_id in document_ids:
doc_id = doc_id.strip()
if not doc_id:
continue
try:
debug_log(f"Génération PDF pour {doc_id}...")
pdf_bytes = await asyncio.to_thread(
self._generate_pdf, doc_id, type_doc
)
if pdf_bytes:
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
part["Content-Disposition"] = (
f'attachment; filename="{doc_id}.pdf"'
)
msg.attach(part)
debug_log(
f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)",
"SUCCESS",
)
except Exception as e:
debug_log(f"Erreur génération PDF {doc_id}: {e}", "ERROR")
# Envoi SMTP
debug_log("Lancement envoi SMTP...", "STEP")
await asyncio.to_thread(self._send_smtp, msg)
def _send_smtp(self, msg):
debug_log("═══════════════════════════════════════════", "STEP")
debug_log(" DÉBUT ENVOI SMTP ULTRA DEBUG", "STEP")
debug_log("═══════════════════════════════════════════", "STEP")
# ═══ CONFIGURATION ═══
debug_log("CONFIGURATION SMTP:", "DATA")
debug_log(f" → Host: {settings.smtp_host}")
debug_log(f" → Port: {settings.smtp_port}")
debug_log(f" → User: {settings.smtp_user}")
debug_log(
f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}"
)
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → TLS: {settings.smtp_use_tls}")
debug_log(f" → To: {msg['To']}")
server = None
try:
# ═══ ÉTAPE 1: RÉSOLUTION DNS ═══
debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP")
try:
ip_addresses = socket.getaddrinfo(
settings.smtp_host, settings.smtp_port
)
debug_log(f" → DNS résolu: {ip_addresses[0][4]}", "SUCCESS")
except socket.gaierror as e:
debug_log(f" → ÉCHEC DNS: {e}", "ERROR")
raise Exception(
f"Résolution DNS échouée pour {settings.smtp_host}: {e}"
)
# ═══ ÉTAPE 2: CONNEXION TCP ═══
debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP")
start_time = time.time()
try:
server = smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30
)
server.set_debuglevel(
2 if ULTRA_DEBUG else 0
) # Active le debug SMTP natif
connect_time = time.time() - start_time
debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS")
except socket.timeout:
debug_log(" → TIMEOUT connexion (>30s)", "ERROR")
raise Exception(
f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}"
)
except ConnectionRefusedError:
debug_log(" → CONNEXION REFUSÉE", "ERROR")
raise Exception(
f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}"
)
except Exception as e:
debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR")
raise
# ═══ ÉTAPE 3: EHLO ═══
debug_log("ÉTAPE 3/7: Envoi EHLO...", "STEP")
try:
ehlo_code, ehlo_msg = server.ehlo()
debug_log(f" → EHLO Response: {ehlo_code}", "SUCCESS")
debug_log(f" → Capabilities: {ehlo_msg.decode()[:200]}...")
except Exception as e:
debug_log(f" → ÉCHEC EHLO: {e}", "ERROR")
raise
# ═══ ÉTAPE 4: STARTTLS ═══
if settings.smtp_use_tls:
debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP")
try:
# Vérifier si le serveur supporte STARTTLS
if server.has_extn("STARTTLS"):
debug_log(" → Serveur supporte STARTTLS", "SUCCESS")
# Créer un contexte SSL
context = ssl.create_default_context()
debug_log(
f" → Contexte SSL créé (protocole: {context.protocol})"
)
tls_code, tls_msg = server.starttls(context=context)
debug_log(
f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS"
)
# Re-EHLO après STARTTLS
server.ehlo()
debug_log(" → Re-EHLO après TLS: OK", "SUCCESS")
else:
debug_log(" → Serveur ne supporte PAS STARTTLS!", "WARN")
except smtplib.SMTPNotSupportedError:
debug_log(" → STARTTLS non supporté par le serveur", "WARN")
except ssl.SSLError as e:
debug_log(f" → ERREUR SSL: {e}", "ERROR")
raise Exception(f"Erreur SSL/TLS: {e}")
except Exception as e:
debug_log(f" → ÉCHEC STARTTLS: {type(e).__name__}: {e}", "ERROR")
raise
else:
debug_log("ÉTAPE 4/7: STARTTLS désactivé (smtp_use_tls=False)", "WARN")
# ═══ ÉTAPE 5: AUTHENTIFICATION ═══
debug_log("ÉTAPE 5/7: Authentification...", "STEP")
if settings.smtp_user and settings.smtp_password:
debug_log(f" → Tentative login avec: {settings.smtp_user}")
try:
# Lister les méthodes d'auth supportées
if server.has_extn("AUTH"):
auth_methods = server.esmtp_features.get("auth", "")
debug_log(f" → Méthodes AUTH supportées: {auth_methods}")
server.login(settings.smtp_user, settings.smtp_password)
debug_log(" → Authentification RÉUSSIE", "SUCCESS")
except smtplib.SMTPAuthenticationError as e:
debug_log(
f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}",
"ERROR",
)
debug_log(f" → Code: {e.smtp_code}", "ERROR")
debug_log(
f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}",
"ERROR",
)
# Diagnostic spécifique selon le code d'erreur
if e.smtp_code == 535:
debug_log(
" → 535 = Identifiants incorrects ou app password requis",
"ERROR",
)
elif e.smtp_code == 534:
debug_log(
" → 534 = 2FA requis, utiliser un App Password", "ERROR"
)
elif e.smtp_code == 530:
debug_log(
" → 530 = Authentification requise mais échouée", "ERROR"
)
raise Exception(
f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}"
)
except smtplib.SMTPException as e:
debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR")
raise
else:
debug_log(" → Pas d'authentification configurée", "WARN")
# ═══ ÉTAPE 6: ENVOI DU MESSAGE ═══
debug_log("ÉTAPE 6/7: Envoi du message...", "STEP")
debug_log(f" → From: {msg['From']}")
debug_log(f" → To: {msg['To']}")
debug_log(f" → Subject: {msg['Subject']}")
debug_log(f" → Taille message: {len(msg.as_string())} bytes")
try:
# send_message retourne un dict des destinataires refusés
refused = server.send_message(msg)
if refused:
debug_log(f" → DESTINATAIRES REFUSÉS: {refused}", "ERROR")
raise Exception(f"Destinataires refusés: {refused}")
else:
debug_log(" → Message envoyé avec succès!", "SUCCESS")
except smtplib.SMTPRecipientsRefused as e:
debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR")
raise Exception(f"Destinataire refusé: {e.recipients}")
except smtplib.SMTPSenderRefused as e:
debug_log(
f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR"
)
debug_log(
f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer",
"ERROR",
)
raise Exception(f"Expéditeur refusé: {e.smtp_code} - {e.smtp_error}")
except smtplib.SMTPDataError as e:
debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR")
raise Exception(f"Erreur DATA SMTP: {e.smtp_code} - {e.smtp_error}")
# ═══ ÉTAPE 7: QUIT ═══
debug_log("ÉTAPE 7/7: Fermeture connexion...", "STEP")
try:
server.quit()
debug_log(" → Connexion fermée proprement", "SUCCESS")
except Exception:
pass
debug_log("═══════════════════════════════════════════", "SUCCESS")
debug_log(" ENVOI SMTP TERMINÉ AVEC SUCCÈS", "SUCCESS")
debug_log("═══════════════════════════════════════════", "SUCCESS")
except Exception as e:
debug_log("═══════════════════════════════════════════", "ERROR")
debug_log(f" ÉCHEC ENVOI SMTP: {type(e).__name__}", "ERROR")
debug_log(f" Message: {str(e)}", "ERROR")
debug_log("═══════════════════════════════════════════", "ERROR")
# Fermer la connexion si elle existe
if server:
try:
server.quit()
except Exception:
pass
raise Exception(f"Erreur SMTP: {str(e)}")
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
if not self.sage_client:
logger.error(" sage_client non configuré")
raise Exception("sage_client non disponible")
try:
doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e:
logger.error(f" Erreur récupération document {doc_id}: {e}")
raise Exception(f"Document {doc_id} inaccessible")
if not doc:
raise Exception(f"Document {doc_id} introuvable")
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
pdf.setFont("Helvetica-Bold", 20)
pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}")
type_labels = {
0: "DEVIS",
1: "BON DE LIVRAISON",
2: "BON DE RETOUR",
3: "COMMANDE",
4: "PRÉPARATION",
5: "FACTURE",
}
type_label = type_labels.get(type_doc, "DOCUMENT")
pdf.setFont("Helvetica", 12)
pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}")
y = height - 5 * cm
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2 * cm, y, "CLIENT")
y -= 0.8 * cm
pdf.setFont("Helvetica", 11)
pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code') or ''}")
y -= 0.6 * cm
pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule') or ''}")
y -= 0.6 * cm
pdf.drawString(2 * cm, y, f"Date: {doc.get('date') or ''}")
y -= 1.5 * cm
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2 * cm, y, "ARTICLES")
y -= 1 * cm
pdf.setFont("Helvetica-Bold", 10)
pdf.drawString(2 * cm, y, "Désignation")
pdf.drawString(10 * cm, y, "Qté")
pdf.drawString(12 * cm, y, "Prix Unit.")
pdf.drawString(15 * cm, y, "Total HT")
y -= 0.5 * cm
pdf.line(2 * cm, y, width - 2 * cm, y)
y -= 0.7 * cm
pdf.setFont("Helvetica", 9)
for ligne in doc.get("lignes", []):
if y < 3 * cm:
pdf.showPage()
y = height - 3 * cm
pdf.setFont("Helvetica", 9)
# FIX: Gérer les valeurs None correctement
designation = (
ligne.get("designation") or ligne.get("designation_article") or ""
)
if designation:
designation = str(designation)[:50]
else:
designation = ""
pdf.drawString(2 * cm, y, designation)
pdf.drawString(10 * cm, y, str(ligne.get("quantite") or 0))
pdf.drawString(
12 * cm,
y,
f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}",
)
pdf.drawString(
15 * cm,
y,
f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}",
)
y -= 0.6 * cm
y -= 1 * cm
pdf.line(12 * cm, y, width - 2 * cm, y)
y -= 0.8 * cm
pdf.setFont("Helvetica-Bold", 11)
pdf.drawString(12 * cm, y, "Total HT:")
pdf.drawString(15 * cm, y, f"{doc.get('total_ht') or 0:.2f}")
y -= 0.6 * cm
pdf.drawString(12 * cm, y, "TVA (20%):")
tva = (doc.get("total_ttc") or 0) - (doc.get("total_ht") or 0)
pdf.drawString(15 * cm, y, f"{tva:.2f}")
y -= 0.6 * cm
pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(12 * cm, y, "Total TTC:")
pdf.drawString(15 * cm, y, f"{doc.get('total_ttc') or 0:.2f}")
pdf.setFont("Helvetica", 8)
pdf.drawString(
2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}"
)
pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven")
pdf.save()
buffer.seek(0)
logger.info(f" PDF généré: {doc_id}.pdf")
return buffer.read()
email_queue = EmailQueue()

View file

@ -1,39 +1,22 @@
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional, List
import logging
from config.config import settings
import logging
logger = logging.getLogger(__name__)
class AuthEmailService:
@staticmethod
def _send_email(
to: str,
subject: str,
html_body: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
) -> bool:
def _send_email(to: str, subject: str, html_body: str) -> bool:
try:
msg = MIMEMultipart("alternative")
msg = MIMEMultipart()
msg["From"] = settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
if cc:
msg["Cc"] = ", ".join(cc)
msg.attach(MIMEText(html_body, "html", "utf-8"))
recipients = [to]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
msg.attach(MIMEText(html_body, "html"))
with smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30
@ -44,263 +27,176 @@ class AuthEmailService:
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.sendmail(settings.smtp_from, recipients, msg.as_string())
server.send_message(msg)
logger.info(f"Email envoye: {subject} vers {to}")
logger.info(f" Email envoyé: {subject} {to}")
return True
except smtplib.SMTPException as e:
logger.error(f"Erreur SMTP envoi email: {e}")
return False
except Exception as e:
logger.error(f" Erreur envoi email: {e}")
return False
@classmethod
def send_verification_email(cls, email: str, token: str, base_url: str) -> bool:
@staticmethod
def send_verification_email(email: str, token: str, base_url: str) -> bool:
verification_link = f"{base_url}/auth/verify-email?token={token}"
html_body = f"""
<!DOCTYPE html>
<html lang="fr">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verification de votre email</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #4F46E5;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #4F46E5; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Verification de votre email</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Bienvenue sur Sage Dataven. Pour activer votre compte, veuillez verifier votre adresse email en cliquant sur le bouton ci-dessous.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 24px 0;">
<a href="{verification_link}" style="display: inline-block; background-color: #4F46E5; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-size: 16px; font-weight: 500;">Verifier mon email</a>
</td>
</tr>
</table>
<p style="color: #6B7280; font-size: 14px; line-height: 1.6; margin: 24px 0 0;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
</p>
<p style="color: #4F46E5; font-size: 14px; word-break: break-all; background-color: #F3F4F6; padding: 12px; border-radius: 4px; margin: 12px 0 24px;">
<body>
<div class="container">
<div class="header">
<h1>🎉 Bienvenue sur Sage Dataven</h1>
</div>
<div class="content">
<h2>Vérifiez votre adresse email</h2>
<p>Merci de vous être inscrit ! Pour activer votre compte, veuillez cliquer sur le bouton ci-dessous :</p>
<div style="text-align: center;">
<a href="{verification_link}" class="button">Vérifier mon email</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
{verification_link}
</p>
<p style="color: #EF4444; font-size: 14px; margin: 0;">
Ce lien expire dans 24 heures.
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>24 heures</strong>
</p>
</td>
</tr>
<tr>
<td style="background-color: #F9FAFB; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #E5E7EB;">
<p style="color: #9CA3AF; font-size: 12px; margin: 0; text-align: center;">
Si vous n'avez pas cree de compte, ignorez cet email.
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas créé de compte, ignorez cet email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
return cls._send_email(
email, "Verifiez votre adresse email - Sage Dataven", html_body
return AuthEmailService._send_email(
email, "rifiez votre adresse email - Sage Dataven", html_body
)
@classmethod
def send_password_reset_email(
cls, email: str, token: str, frontend_url: str
) -> bool:
reset_link = f"{frontend_url}/reset-password?token={token}"
@staticmethod
def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
reset_link = f"{base_url}/reset?token={token}"
html_body = f"""
<!DOCTYPE html>
<html lang="fr">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reinitialisation de mot de passe</title>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #EF4444; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #EF4444;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #DC2626; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Reinitialisation du mot de passe</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Vous avez demande la reinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour creer un nouveau mot de passe.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 24px 0;">
<a href="{reset_link}" style="display: inline-block; background-color: #DC2626; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-size: 16px; font-weight: 500;">Reinitialiser mon mot de passe</a>
</td>
</tr>
</table>
<p style="color: #6B7280; font-size: 14px; line-height: 1.6; margin: 24px 0 0;">
Si le bouton ne fonctionne pas, copiez ce lien :
</p>
<p style="color: #DC2626; font-size: 14px; word-break: break-all; background-color: #FEF2F2; padding: 12px; border-radius: 4px; margin: 12px 0 24px;">
<body>
<div class="container">
<div class="header">
<h1> Réinitialisation de mot de passe</h1>
</div>
<div class="content">
<h2>Demande de réinitialisation</h2>
<p>Vous avez demandé à réinitialiser votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center;">
<a href="{reset_link}" class="button">Réinitialiser mon mot de passe</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
{reset_link}
</p>
<p style="color: #EF4444; font-size: 14px; margin: 0;">
Ce lien expire dans 1 heure.
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>1 heure</strong>
</p>
</td>
</tr>
<tr>
<td style="background-color: #FEF2F2; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #FECACA;">
<p style="color: #991B1B; font-size: 12px; margin: 0; text-align: center;">
Si vous n'avez pas demande cette reinitialisation, ignorez cet email. Votre mot de passe restera inchange.
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
return cls._send_email(
email, "Reinitialisation de votre mot de passe - Sage Dataven", html_body
return AuthEmailService._send_email(
email, "initialisation de votre mot de passe - Sage Dataven", html_body
)
@classmethod
def send_password_changed_notification(cls, email: str) -> bool:
@staticmethod
def send_password_changed_notification(email: str) -> bool:
html_body = """
<!DOCTYPE html>
<html lang="fr">
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mot de passe modifie</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #10B981; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }
</style>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #059669; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Mot de passe modifie</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Votre mot de passe a ete modifie avec succes.
<body>
<div class="container">
<div class="header">
<h1> Mot de passe modifié</h1>
</div>
<div class="content">
<h2>Votre mot de passe a été changé avec succès</h2>
<p>Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.</p>
<p style="margin-top: 30px; padding: 15px; background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support.
</p>
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Si vous n'etes pas a l'origine de ce changement, contactez immediatement notre support.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="color: #92400E; font-size: 14px; margin: 0;">
<strong>Securite :</strong> Toutes vos sessions actives ont ete deconnectees. Vous devrez vous reconnecter sur tous vos appareils.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background-color: #F9FAFB; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #E5E7EB;">
<p style="color: #9CA3AF; font-size: 12px; margin: 0; text-align: center;">
Sage Dataven - Notification de securite
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</body>
</html>
"""
return cls._send_email(
email, "Votre mot de passe a ete modifie - Sage Dataven", html_body
)
@classmethod
def send_security_alert(
cls, email: str, alert_type: str, details: str, ip_address: Optional[str] = None
) -> bool:
ip_info = (
f"<p style='color: #6B7280; font-size: 14px;'>Adresse IP : {ip_address}</p>"
if ip_address
else ""
)
html_body = f"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alerte de securite</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #B91C1C; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Alerte de securite</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 16px;">
<strong>{alert_type}</strong>
</p>
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
{details}
</p>
{ip_info}
<p style="color: #6B7280; font-size: 14px; margin: 24px 0 0;">
Si vous reconnaissez cette activite, vous pouvez ignorer ce message. Sinon, nous vous recommandons de changer votre mot de passe immediatement.
</p>
</td>
</tr>
<tr>
<td style="background-color: #FEF2F2; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #FECACA;">
<p style="color: #991B1B; font-size: 12px; margin: 0; text-align: center;">
Sage Dataven - Alerte de securite automatique
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
return cls._send_email(
email, f"Alerte de securite : {alert_type} - Sage Dataven", html_body
return AuthEmailService._send_email(
email, " Votre mot de passe a été modifié - Sage Dataven", html_body
)

View file

@ -1,200 +0,0 @@
import redis.asyncio as redis
from typing import Optional
import logging
import json
from config.config import settings
logger = logging.getLogger(__name__)
class RedisService:
_instance: Optional["RedisService"] = None
_client: Optional[redis.Redis] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def connect(self) -> None:
if self._client is not None:
return
try:
self._client = redis.from_url(
settings.redis_url,
password=settings.redis_password,
encoding="utf-8",
decode_responses=True,
socket_timeout=5.0,
socket_connect_timeout=5.0,
)
await self._client.ping()
logger.info("Connexion Redis etablie")
except Exception as e:
logger.error(f"Erreur connexion Redis: {e}")
self._client = None
raise
async def disconnect(self) -> None:
if self._client:
await self._client.close()
self._client = None
logger.info("Connexion Redis fermee")
async def is_connected(self) -> bool:
if not self._client:
return False
try:
await self._client.ping()
return True
except Exception:
return False
@property
def client(self) -> redis.Redis:
if not self._client:
raise RuntimeError("Redis non connecte. Appelez connect() d'abord.")
return self._client
async def blacklist_token(self, token_id: str, ttl_seconds: int) -> bool:
try:
key = f"{settings.token_blacklist_prefix}{token_id}"
await self.client.setex(key, ttl_seconds, "1")
logger.debug(f"Token {token_id[:8]}... ajoute a la blacklist")
return True
except Exception as e:
logger.error(f"Erreur blacklist token: {e}")
return False
async def is_token_blacklisted(self, token_id: str) -> bool:
try:
key = f"{settings.token_blacklist_prefix}{token_id}"
result = await self.client.exists(key)
return result > 0
except Exception as e:
logger.error(f"Erreur verification blacklist: {e}")
return False
async def blacklist_user_tokens(
self, user_id: str, ttl_seconds: int = 86400
) -> bool:
try:
key = f"{settings.token_blacklist_prefix}user:{user_id}"
import time
await self.client.setex(key, ttl_seconds, str(int(time.time())))
logger.info(f"Tokens utilisateur {user_id} invalides")
return True
except Exception as e:
logger.error(f"Erreur invalidation tokens utilisateur: {e}")
return False
async def get_user_token_invalidation_time(self, user_id: str) -> Optional[int]:
try:
key = f"{settings.token_blacklist_prefix}user:{user_id}"
result = await self.client.get(key)
return int(result) if result else None
except Exception as e:
logger.error(f"Erreur lecture invalidation: {e}")
return None
async def increment_rate_limit(self, key: str, window_seconds: int) -> int:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
pipe = self.client.pipeline()
pipe.incr(full_key)
pipe.expire(full_key, window_seconds)
results = await pipe.execute()
return results[0]
except Exception as e:
logger.error(f"Erreur increment rate limit: {e}")
return 0
async def get_rate_limit_count(self, key: str) -> int:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
result = await self.client.get(full_key)
return int(result) if result else 0
except Exception as e:
logger.error(f"Erreur lecture rate limit: {e}")
return 0
async def reset_rate_limit(self, key: str) -> bool:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
await self.client.delete(full_key)
return True
except Exception as e:
logger.error(f"Erreur reset rate limit: {e}")
return False
async def store_refresh_token_metadata(
self, token_id: str, user_id: str, fingerprint_hash: str, ttl_seconds: int
) -> bool:
try:
key = f"refresh_token:{token_id}"
data = json.dumps(
{
"user_id": user_id,
"fingerprint_hash": fingerprint_hash,
"used": False,
}
)
await self.client.setex(key, ttl_seconds, data)
return True
except Exception as e:
logger.error(f"Erreur stockage metadata refresh token: {e}")
return False
async def get_refresh_token_metadata(self, token_id: str) -> Optional[dict]:
try:
key = f"refresh_token:{token_id}"
data = await self.client.get(key)
return json.loads(data) if data else None
except Exception as e:
logger.error(f"Erreur lecture metadata refresh token: {e}")
return None
async def mark_refresh_token_used(self, token_id: str) -> bool:
try:
key = f"refresh_token:{token_id}"
data = await self.client.get(key)
if not data:
return False
metadata = json.loads(data)
metadata["used"] = True
metadata["used_at"] = int(__import__("time").time())
ttl = await self.client.ttl(key)
if ttl > 0:
await self.client.setex(key, ttl, json.dumps(metadata))
return True
except Exception as e:
logger.error(f"Erreur marquage refresh token: {e}")
return False
async def delete_refresh_token(self, token_id: str) -> bool:
try:
key = f"refresh_token:{token_id}"
result = await self.client.delete(key)
return result > 0
except Exception as e:
logger.error(f"Erreur suppression refresh token: {e}")
return False
redis_service = RedisService()
async def get_redis() -> RedisService:
if not await redis_service.is_connected():
await redis_service.connect()
return redis_service

View file

@ -20,6 +20,8 @@ class SageGatewayService:
self.session = session
async def create(self, user_id: str, data: dict) -> SageGatewayConfig:
"""Créer une nouvelle configuration gateway"""
if data.get("is_active"):
await self._deactivate_all_for_user(user_id)
@ -53,6 +55,7 @@ class SageGatewayService:
and_(
SageGatewayConfig.id == gateway_id,
SageGatewayConfig.user_id == user_id,
SageGatewayConfig.is_deleted == false(),
)
)
)
@ -64,7 +67,7 @@ class SageGatewayService:
query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id)
if not include_deleted:
query = query.where(SageGatewayConfig.is_deleted.is_(false()))
query = query.where(SageGatewayConfig.is_deleted == false())
query = query.order_by(
SageGatewayConfig.is_active.desc(),
@ -78,6 +81,8 @@ class SageGatewayService:
async def update(
self, gateway_id: str, user_id: str, data: dict
) -> Optional[SageGatewayConfig]:
"""Mettre à jour une gateway"""
gateway = await self.get_by_id(gateway_id, user_id)
if not gateway:
return None
@ -126,6 +131,7 @@ class SageGatewayService:
async def activate(
self, gateway_id: str, user_id: str
) -> Optional[SageGatewayConfig]:
"""Activer une gateway (désactive les autres)"""
gateway = await self.get_by_id(gateway_id, user_id)
if not gateway:
return None
@ -161,7 +167,7 @@ class SageGatewayService:
and_(
SageGatewayConfig.user_id == user_id,
SageGatewayConfig.is_active,
SageGatewayConfig.is_deleted.is_(false()),
SageGatewayConfig.is_deleted == false(),
)
)
)
@ -271,6 +277,8 @@ class SageGatewayService:
return {"success": False, "status": "error", "error": str(e)}
async def record_request(self, gateway_id: str, success: bool) -> None:
"""Enregistrer une requête (succès/échec)"""
if not gateway_id:
return
@ -289,6 +297,7 @@ class SageGatewayService:
await self.session.commit()
async def get_stats(self, user_id: str) -> dict:
"""Statistiques d'utilisation pour un utilisateur"""
gateways = await self.list_for_user(user_id)
total_requests = sum(g.total_requests for g in gateways)
@ -314,6 +323,8 @@ class SageGatewayService:
}
async def _deactivate_all_for_user(self, user_id: str) -> None:
"""Désactiver toutes les gateways d'un utilisateur"""
await self.session.execute(
update(SageGatewayConfig)
.where(SageGatewayConfig.user_id == user_id)
@ -321,6 +332,8 @@ class SageGatewayService:
)
async def _unset_default_for_user(self, user_id: str) -> None:
"""Retirer le flag default de toutes les gateways"""
await self.session.execute(
update(SageGatewayConfig)
.where(SageGatewayConfig.user_id == user_id)
@ -329,6 +342,8 @@ class SageGatewayService:
def gateway_response_from_model(gateway: SageGatewayConfig) -> dict:
"""Convertir un model en réponse API (masque le token)"""
token_preview = (
f"****{gateway.gateway_token[-4:]}" if gateway.gateway_token else "****"
)
@ -365,6 +380,8 @@ def gateway_response_from_model(gateway: SageGatewayConfig) -> dict:
"description": gateway.description,
"gateway_url": gateway.gateway_url,
"token_preview": token_preview,
"sage_database": gateway.sage_database,
"sage_company": gateway.sage_company,
"is_active": gateway.is_active,
"is_default": gateway.is_default,
"priority": gateway.priority,

View file

@ -1,357 +0,0 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select, and_, or_, delete, true
from datetime import datetime, timedelta
from typing import Optional, Tuple, Dict, Any
import uuid
import logging
import time
from config.config import settings
from database import RefreshToken, User
from services.redis_service import redis_service
from security.auth import (
create_access_token,
create_refresh_token,
create_csrf_token,
decode_token,
hash_token,
generate_session_id,
)
logger = logging.getLogger(__name__)
class TokenService:
@classmethod
async def create_token_pair(
cls,
session: AsyncSession,
user: User,
fingerprint_hash: str,
device_info: str,
ip_address: str,
) -> Tuple[str, str, str, str]:
session_id = generate_session_id()
access_token = create_access_token(
data={
"sub": user.id,
"email": user.email,
"role": user.role,
"sid": session_id,
},
fingerprint_hash=fingerprint_hash,
)
refresh_token_jwt, token_id = create_refresh_token(
user_id=user.id, fingerprint_hash=fingerprint_hash
)
csrf_token = create_csrf_token(session_id)
token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_token(refresh_token_jwt),
token_id=token_id,
fingerprint_hash=fingerprint_hash,
device_info=device_info[:500] if device_info else None,
ip_address=ip_address,
expires_at=datetime.now()
+ timedelta(days=settings.refresh_token_expire_days),
created_at=datetime.now(),
)
session.add(token_record)
await session.flush()
await redis_service.store_refresh_token_metadata(
token_id=token_id,
user_id=user.id,
fingerprint_hash=fingerprint_hash,
ttl_seconds=settings.refresh_token_expire_days * 24 * 60 * 60,
)
logger.info(f"Token pair cree pour utilisateur {user.email}")
return access_token, refresh_token_jwt, csrf_token, session_id
@classmethod
async def refresh_tokens(
cls,
session: AsyncSession,
refresh_token: str,
fingerprint_hash: str,
device_info: str,
ip_address: str,
) -> Optional[Tuple[str, str, str, str]]:
payload = decode_token(refresh_token, expected_type="refresh")
if not payload:
logger.warning("Refresh token invalide ou expire")
return None
user_id = payload.get("sub")
token_id = payload.get("jti")
stored_fingerprint = payload.get("fph")
if not user_id or not token_id:
logger.warning("Refresh token malformed")
return None
if await redis_service.is_token_blacklisted(token_id):
logger.warning(f"Refresh token {token_id[:8]}... est blackliste")
return None
token_hash = hash_token(refresh_token)
result = await session.execute(
select(RefreshToken).where(
and_(
RefreshToken.token_hash == token_hash,
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
RefreshToken.expires_at > datetime.now(),
)
)
)
token_record = result.scalar_one_or_none()
if not token_record:
logger.warning(f"Refresh token non trouve en DB pour user {user_id}")
await cls._handle_potential_token_theft(session, user_id, token_id)
return None
if settings.refresh_token_rotation_enabled and token_record.is_used:
used_at = token_record.used_at
if used_at:
time_since_use = (datetime.now() - used_at).total_seconds()
if time_since_use > settings.refresh_token_reuse_window_seconds:
logger.warning(
f"Reutilisation de refresh token detectee pour user {user_id}"
)
await cls._handle_potential_token_theft(session, user_id, token_id)
return None
if stored_fingerprint and fingerprint_hash:
if stored_fingerprint != fingerprint_hash:
logger.warning(f"Fingerprint mismatch pour user {user_id}")
return None
result = await session.execute(
select(User).where(and_(User.id == user_id, User.is_active.is_(true())))
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"Utilisateur {user_id} introuvable ou inactif")
return None
session_id = generate_session_id()
new_access_token = create_access_token(
data={
"sub": user.id,
"email": user.email,
"role": user.role,
"sid": session_id,
},
fingerprint_hash=fingerprint_hash,
)
new_csrf_token = create_csrf_token(session_id)
if settings.refresh_token_rotation_enabled:
token_record.is_used = True
token_record.used_at = datetime.now()
new_refresh_jwt, new_token_id = create_refresh_token(
user_id=user.id, fingerprint_hash=fingerprint_hash
)
new_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_token(new_refresh_jwt),
token_id=new_token_id,
fingerprint_hash=fingerprint_hash,
device_info=device_info[:500] if device_info else None,
ip_address=ip_address,
expires_at=datetime.now()
+ timedelta(days=settings.refresh_token_expire_days),
created_at=datetime.now(),
)
token_record.replaced_by = new_token_record.id
session.add(new_token_record)
await redis_service.mark_refresh_token_used(token_id)
await redis_service.store_refresh_token_metadata(
token_id=new_token_id,
user_id=user.id,
fingerprint_hash=fingerprint_hash,
ttl_seconds=settings.refresh_token_expire_days * 24 * 60 * 60,
)
logger.info(f"Refresh token rotation pour user {user.email}")
return new_access_token, new_refresh_jwt, new_csrf_token, session_id
else:
token_record.last_used_at = datetime.now()
return new_access_token, refresh_token, new_csrf_token, session_id
@classmethod
async def revoke_token(
cls, session: AsyncSession, refresh_token: str, reason: str = "user_logout"
) -> bool:
payload = decode_token(refresh_token, expected_type="refresh")
if not payload:
return False
token_id = payload.get("jti")
user_id = payload.get("sub")
exp = payload.get("exp", 0)
ttl_seconds = max(0, exp - int(time.time()))
await redis_service.blacklist_token(token_id, ttl_seconds)
token_hash = hash_token(refresh_token)
result = await session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
token_record = result.scalar_one_or_none()
if token_record:
token_record.is_revoked = True
token_record.revoked_at = datetime.now()
token_record.revoked_reason = reason
await redis_service.delete_refresh_token(token_id)
logger.info(f"Token revoque pour user {user_id}: {reason}")
return True
@classmethod
async def revoke_all_user_tokens(
cls, session: AsyncSession, user_id: str, reason: str = "security_action"
) -> int:
result = await session.execute(
select(RefreshToken).where(
and_(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
)
)
)
tokens = result.scalars().all()
count = 0
for token in tokens:
token.is_revoked = True
token.revoked_at = datetime.now()
token.revoked_reason = reason
await redis_service.blacklist_token(
token.token_id, settings.refresh_token_expire_days * 24 * 60 * 60
)
await redis_service.delete_refresh_token(token.token_id)
count += 1
await redis_service.blacklist_user_tokens(
user_id, settings.refresh_token_expire_days * 24 * 60 * 60
)
logger.info(f"{count} tokens revoques pour user {user_id}: {reason}")
return count
@classmethod
async def _handle_potential_token_theft(
cls, session: AsyncSession, user_id: str, token_id: str
) -> None:
logger.warning(
f"Potentiel vol de token detecte pour user {user_id}, token {token_id[:8]}..."
)
await cls.revoke_all_user_tokens(
session, user_id, reason="potential_token_theft"
)
@classmethod
async def validate_access_token(
cls, token: str, fingerprint_hash: Optional[str] = None
) -> Optional[Dict[str, Any]]:
payload = decode_token(token, expected_type="access")
if not payload:
return None
token_id = payload.get("jti")
if token_id and await redis_service.is_token_blacklisted(token_id):
logger.debug(f"Access token {token_id[:8]}... est blackliste")
return None
user_id = payload.get("sub")
if user_id:
invalidation_time = await redis_service.get_user_token_invalidation_time(
user_id
)
if invalidation_time:
token_iat = payload.get("iat", 0)
if token_iat < invalidation_time:
logger.debug("Access token emis avant invalidation globale")
return None
if fingerprint_hash:
stored_fingerprint = payload.get("fph")
if stored_fingerprint and stored_fingerprint != fingerprint_hash:
logger.warning("Fingerprint mismatch sur access token")
return None
return payload
@classmethod
async def cleanup_expired_tokens(cls, session: AsyncSession) -> int:
result = await session.execute(
delete(RefreshToken).where(
or_(
RefreshToken.expires_at < datetime.now(),
and_(
RefreshToken.is_revoked.is_(true()),
RefreshToken.revoked_at < datetime.now() - timedelta(days=7),
),
)
)
)
count = result.rowcount
logger.info(f"{count} tokens expires nettoyes")
return count
@classmethod
async def get_user_active_sessions(
cls, session: AsyncSession, user_id: str
) -> list:
result = await session.execute(
select(RefreshToken)
.where(
and_(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
RefreshToken.expires_at > datetime.now(),
)
)
.order_by(RefreshToken.created_at.desc())
)
tokens = result.scalars().all()
return [
{
"id": t.id,
"device_info": t.device_info,
"ip_address": t.ip_address,
"created_at": t.created_at.isoformat(),
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
}
for t in tokens
]

View file

@ -0,0 +1,361 @@
import os
import logging
import requests
from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents"))
SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True)
class UniversignDocumentService:
"""Service de gestion des documents signés Universign - VERSION CORRIGÉE"""
def __init__(self, api_url: str, api_key: str, timeout: int = 60):
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]:
try:
logger.info(f" Récupération documents pour transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
if response.status_code == 200:
data = response.json()
documents = data.get("documents", [])
logger.info(f"{len(documents)} document(s) trouvé(s)")
for idx, doc in enumerate(documents):
logger.debug(
f" Document {idx}: id={doc.get('id')}, "
f"name={doc.get('name')}, status={doc.get('status')}"
)
return documents
elif response.status_code == 404:
logger.warning(
f"Transaction {transaction_id} introuvable sur Universign"
)
return None
else:
logger.error(
f"Erreur HTTP {response.status_code} pour {transaction_id}: "
f"{response.text[:500]}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ Timeout récupération transaction {transaction_id}")
return None
except Exception as e:
logger.error(f" Erreur fetch documents: {e}", exc_info=True)
return None
def download_signed_document(
self, transaction_id: str, document_id: str
) -> Optional[bytes]:
try:
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{document_id}/download"
)
logger.info(f"Téléchargement depuis: {download_url}")
response = requests.get(
download_url,
auth=self.auth,
timeout=self.timeout,
stream=True,
)
if response.status_code == 200:
content_type = response.headers.get("Content-Type", "")
content_length = response.headers.get("Content-Length", "unknown")
logger.info(
f"Téléchargement réussi: "
f"Content-Type={content_type}, Size={content_length}"
)
if (
"pdf" not in content_type.lower()
and "octet-stream" not in content_type.lower()
):
logger.warning(
f"Type de contenu inattendu: {content_type}. "
f"Tentative de lecture quand même..."
)
content = response.content
if len(content) < 1024:
logger.error(f" Document trop petit: {len(content)} octets")
return None
return content
elif response.status_code == 404:
logger.error(
f" Document {document_id} introuvable pour transaction {transaction_id}"
)
return None
elif response.status_code == 403:
logger.error(
f" Accès refusé au document {document_id}. "
f"Vérifiez que la transaction est bien signée."
)
return None
else:
logger.error(
f" Erreur HTTP {response.status_code}: {response.text[:500]}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ Timeout téléchargement document {document_id}")
return None
except Exception as e:
logger.error(f" Erreur téléchargement: {e}", exc_info=True)
return None
async def download_and_store_signed_document(
self, session: AsyncSession, transaction, force: bool = False
) -> Tuple[bool, Optional[str]]:
if not force and transaction.signed_document_path:
if os.path.exists(transaction.signed_document_path):
logger.debug(
f"Document déjà téléchargé: {transaction.transaction_id}"
)
return True, None
transaction.download_attempts += 1
try:
logger.info(
f"Récupération document signé pour: {transaction.transaction_id}"
)
documents = self.fetch_transaction_documents(transaction.transaction_id)
if not documents:
error = "Aucun document trouvé dans la transaction Universign"
logger.warning(f"{error}")
transaction.download_error = error
await session.commit()
return False, error
document_id = None
for doc in documents:
doc_id = doc.get("id")
doc_status = doc.get("status", "").lower()
if doc_status in ["signed", "completed", "closed"]:
document_id = doc_id
logger.info(
f"Document signé trouvé: {doc_id} (status: {doc_status})"
)
break
if document_id is None:
document_id = doc_id
if not document_id:
error = "Impossible de déterminer l'ID du document à télécharger"
logger.error(f" {error}")
transaction.download_error = error
await session.commit()
return False, error
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = document_id
pdf_content = self.download_signed_document(
transaction_id=transaction.transaction_id, document_id=document_id
)
if not pdf_content:
error = f"Échec téléchargement document {document_id}"
logger.error(f" {error}")
transaction.download_error = error
await session.commit()
return False, error
filename = self._generate_filename(transaction)
file_path = SIGNED_DOCS_DIR / filename
with open(file_path, "wb") as f:
f.write(pdf_content)
file_size = os.path.getsize(file_path)
transaction.signed_document_path = str(file_path)
transaction.signed_document_downloaded_at = datetime.now()
transaction.signed_document_size_bytes = file_size
transaction.download_error = None
transaction.document_url = (
f"{self.api_url}/transactions/{transaction.transaction_id}"
f"/documents/{document_id}/download"
)
await session.commit()
logger.info(
f"Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)"
)
return True, None
except OSError as e:
error = f"Erreur filesystem: {str(e)}"
logger.error(f" {error}")
transaction.download_error = error
await session.commit()
return False, error
except Exception as e:
error = f"Erreur inattendue: {str(e)}"
logger.error(f" {error}", exc_info=True)
transaction.download_error = error
await session.commit()
return False, error
def _generate_filename(self, transaction) -> str:
"""Génère un nom de fichier unique pour le document signé"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
tx_id = transaction.transaction_id.replace("tr_", "")
filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}_signed.pdf"
return filename
def get_document_path(self, transaction) -> Optional[Path]:
"""Retourne le chemin du document signé s'il existe"""
if not transaction.signed_document_path:
return None
path = Path(transaction.signed_document_path)
if path.exists():
return path
return None
async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]:
"""Supprime les anciens documents signés"""
from datetime import timedelta
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
deleted = 0
size_freed = 0
for file_path in SIGNED_DOCS_DIR.glob("*.pdf"):
try:
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_time < cutoff_date:
size_freed += os.path.getsize(file_path)
os.remove(file_path)
deleted += 1
logger.info(f"🗑️ Supprimé: {file_path.name}")
except Exception as e:
logger.error(f"Erreur suppression {file_path}: {e}")
size_freed_mb = size_freed / (1024 * 1024)
logger.info(
f"Nettoyage terminé: {deleted} fichiers supprimés "
f"({size_freed_mb:.2f} MB libérés)"
)
return deleted, int(size_freed_mb)
def diagnose_transaction(self, transaction_id: str) -> Dict:
"""
Diagnostic complet d'une transaction pour debug
"""
result = {
"transaction_id": transaction_id,
"api_url": self.api_url,
"timestamp": datetime.now().isoformat(),
"checks": {},
}
try:
logger.info(f"Diagnostic transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
)
result["checks"]["transaction_fetch"] = {
"status_code": response.status_code,
"success": response.status_code == 200,
}
if response.status_code != 200:
result["checks"]["transaction_fetch"]["error"] = response.text[:500]
return result
data = response.json()
result["checks"]["transaction_data"] = {
"state": data.get("state"),
"documents_count": len(data.get("documents", [])),
"participants_count": len(data.get("participants", [])),
}
documents = data.get("documents", [])
result["checks"]["documents"] = []
for doc in documents:
doc_info = {
"id": doc.get("id"),
"name": doc.get("name"),
"status": doc.get("status"),
}
if doc.get("id"):
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{doc['id']}/download"
)
try:
dl_response = requests.head(
download_url,
auth=self.auth,
timeout=10,
)
doc_info["download_check"] = {
"url": download_url,
"status_code": dl_response.status_code,
"accessible": dl_response.status_code in [200, 302],
"content_type": dl_response.headers.get("Content-Type"),
}
except Exception as e:
doc_info["download_check"] = {"error": str(e)}
result["checks"]["documents"].append(doc_info)
result["success"] = True
except Exception as e:
result["success"] = False
result["error"] = str(e)
return result

714
services/universign_sync.py Normal file
View file

@ -0,0 +1,714 @@
import requests
import json
import logging
import uuid
from typing import Dict, Optional, Tuple
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from database import (
UniversignTransaction,
UniversignSigner,
UniversignSyncLog,
UniversignTransactionStatus,
LocalDocumentStatus,
UniversignSignerStatus,
EmailLog,
StatutEmail,
)
from data.data import templates_signature_email
from services.universign_document import UniversignDocumentService
from utils.universign_status_mapping import (
map_universign_to_local,
is_transition_allowed,
get_status_actions,
is_final_status,
resolve_status_conflict,
)
logger = logging.getLogger(__name__)
class UniversignSyncService:
def __init__(self, api_url: str, api_key: str, timeout: int = 30):
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
self.sage_client = None
self.email_queue = None
self.settings = None
self.document_service = UniversignDocumentService(
api_url=api_url, api_key=api_key, timeout=60
)
def configure(self, sage_client, email_queue, settings):
self.sage_client = sage_client
self.email_queue = email_queue
self.settings = settings
def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]:
start_time = datetime.now()
try:
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
response_time_ms = int((datetime.now() - start_time).total_seconds() * 1000)
if response.status_code == 200:
data = response.json()
logger.info(
f"Fetch OK: {transaction_id} status={data.get('state')} ({response_time_ms}ms)"
)
return {
"transaction": data,
"http_status": 200,
"response_time_ms": response_time_ms,
"fetched_at": datetime.now(),
}
elif response.status_code == 404:
logger.warning(
f"Transaction {transaction_id} introuvable sur Universign"
)
return None
else:
logger.error(
f"Erreur HTTP {response.status_code} pour {transaction_id}: {response.text}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"Timeout récupération {transaction_id} (>{self.timeout}s)")
return None
except Exception as e:
logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True)
return None
async def sync_all_pending(
self, session: AsyncSession, max_transactions: int = 50
) -> Dict[str, int]:
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(
and_(
UniversignTransaction.needs_sync,
or_(
~UniversignTransaction.local_status.in_(
[
LocalDocumentStatus.SIGNED,
LocalDocumentStatus.REJECTED,
LocalDocumentStatus.EXPIRED,
]
),
UniversignTransaction.last_synced_at
< (datetime.now() - timedelta(hours=1)),
UniversignTransaction.last_synced_at.is_(None),
),
)
)
.order_by(UniversignTransaction.created_at.asc())
.limit(max_transactions)
)
result = await session.execute(query)
transactions = result.scalars().all()
stats = {
"total_found": len(transactions),
"success": 0,
"failed": 0,
"skipped": 0,
"status_changes": 0,
}
for transaction in transactions:
try:
previous_status = transaction.local_status.value
success, error = await self.sync_transaction(
session, transaction, force=False
)
if success:
stats["success"] += 1
if transaction.local_status.value != previous_status:
stats["status_changes"] += 1
else:
stats["failed"] += 1
except Exception as e:
logger.error(
f"Erreur sync {transaction.transaction_id}: {e}", exc_info=True
)
stats["failed"] += 1
logger.info(
f"Polling terminé: {stats['success']}/{stats['total_found']} OK, {stats['status_changes']} changements détectés"
)
return stats
async def process_webhook(
self, session: AsyncSession, payload: Dict, transaction_id: str = None
) -> Tuple[bool, Optional[str]]:
"""
Traite un webhook Universign - CORRECTION : meilleure gestion des payloads
"""
try:
if not transaction_id:
if (
payload.get("type", "").startswith("transaction.")
and "payload" in payload
):
nested_object = payload.get("payload", {}).get("object", {})
if nested_object.get("object") == "transaction":
transaction_id = nested_object.get("id")
elif payload.get("type", "").startswith("action."):
transaction_id = (
payload.get("payload", {})
.get("object", {})
.get("transaction_id")
)
elif payload.get("object") == "transaction":
transaction_id = payload.get("id")
if not transaction_id:
return False, "Transaction ID manquant"
event_type = payload.get("type", "webhook")
logger.info(
f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}"
)
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(UniversignTransaction.transaction_id == transaction_id)
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
logger.warning(f"Transaction {transaction_id} inconnue localement")
return False, "Transaction inconnue"
transaction.webhook_received = True
old_status = transaction.local_status.value
success, error = await self.sync_transaction(
session, transaction, force=True
)
if success and transaction.local_status.value != old_status:
logger.info(
f"Webhook traité: {transaction_id} | "
f"{old_status}{transaction.local_status.value}"
)
await self._log_sync_attempt(
session=session,
transaction=transaction,
sync_type=f"webhook:{event_type}",
success=success,
error_message=error,
previous_status=old_status,
new_status=transaction.local_status.value,
changes=json.dumps(
payload, default=str
), # Ajout default=str pour éviter les erreurs JSON
)
await session.commit()
return success, error
except Exception as e:
logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True)
return False, str(e)
async def _sync_signers(
self,
session: AsyncSession,
transaction: UniversignTransaction,
universign_data: Dict,
):
signers_data = universign_data.get("participants", [])
if not signers_data:
signers_data = universign_data.get("signers", [])
if not signers_data:
logger.debug("Aucun signataire dans les données Universign")
return
existing_signers = {s.email: s for s in transaction.signers}
for idx, signer_data in enumerate(signers_data):
email = signer_data.get("email", "")
if not email:
logger.warning(f"Signataire sans email à l'index {idx}, ignoré")
continue
raw_status = signer_data.get("status") or signer_data.get(
"state", "waiting"
)
try:
status = UniversignSignerStatus(raw_status)
except ValueError:
logger.warning(
f"Statut inconnu pour signer {email}: {raw_status}, utilisation de 'unknown'"
)
status = UniversignSignerStatus.UNKNOWN
if email in existing_signers:
signer = existing_signers[email]
signer.status = status
viewed_at = self._parse_date(signer_data.get("viewed_at"))
if viewed_at and not signer.viewed_at:
signer.viewed_at = viewed_at
signed_at = self._parse_date(signer_data.get("signed_at"))
if signed_at and not signer.signed_at:
signer.signed_at = signed_at
refused_at = self._parse_date(signer_data.get("refused_at"))
if refused_at and not signer.refused_at:
signer.refused_at = refused_at
if signer_data.get("name") and not signer.name:
signer.name = signer_data.get("name")
else:
try:
signer = UniversignSigner(
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
transaction_id=transaction.id,
email=email,
name=signer_data.get("name"),
status=status,
order_index=idx,
viewed_at=self._parse_date(signer_data.get("viewed_at")),
signed_at=self._parse_date(signer_data.get("signed_at")),
refused_at=self._parse_date(signer_data.get("refused_at")),
)
session.add(signer)
logger.info(
f" Nouveau signataire ajouté: {email} (statut: {status.value})"
)
except Exception as e:
logger.error(f"Erreur création signer {email}: {e}")
async def sync_transaction(
self,
session,
transaction,
force: bool = False,
):
import json
if is_final_status(transaction.local_status.value) and not force:
logger.debug(
f"⏭️ Skip {transaction.transaction_id}: statut final "
f"{transaction.local_status.value}"
)
transaction.needs_sync = False
await session.commit()
return True, None
logger.info(f"Synchronisation: {transaction.transaction_id}")
result = self.fetch_transaction_status(transaction.transaction_id)
if not result:
error = "Échec récupération données Universign"
logger.error(f" {error}: {transaction.transaction_id}")
transaction.sync_attempts += 1
transaction.sync_error = error
await self._log_sync_attempt(session, transaction, "polling", False, error)
await session.commit()
return False, error
try:
universign_data = result["transaction"]
universign_status_raw = universign_data.get("state", "draft")
logger.info(f" Statut Universign brut: {universign_status_raw}")
new_local_status = map_universign_to_local(universign_status_raw)
previous_local_status = transaction.local_status.value
logger.info(
f"Mapping: {universign_status_raw} (Universign) → "
f"{new_local_status} (Local) | Actuel: {previous_local_status}"
)
if not is_transition_allowed(previous_local_status, new_local_status):
logger.warning(
f"Transition refusée: {previous_local_status}{new_local_status}"
)
new_local_status = resolve_status_conflict(
previous_local_status, new_local_status
)
logger.info(f"Résolution conflit: statut résolu = {new_local_status}")
status_changed = previous_local_status != new_local_status
if status_changed:
logger.info(
f"CHANGEMENT DÉTECTÉ: {previous_local_status}{new_local_status}"
)
try:
transaction.universign_status = UniversignTransactionStatus(
universign_status_raw
)
except ValueError:
logger.warning(f"Statut Universign inconnu: {universign_status_raw}")
if new_local_status == "SIGNE":
transaction.universign_status = (
UniversignTransactionStatus.COMPLETED
)
elif new_local_status == "REFUSE":
transaction.universign_status = UniversignTransactionStatus.REFUSED
elif new_local_status == "EXPIRE":
transaction.universign_status = UniversignTransactionStatus.EXPIRED
else:
transaction.universign_status = UniversignTransactionStatus.STARTED
transaction.local_status = LocalDocumentStatus(new_local_status)
transaction.universign_status_updated_at = datetime.now()
if new_local_status == "EN_COURS" and not transaction.sent_at:
transaction.sent_at = datetime.now()
logger.info("Date d'envoi mise à jour")
if new_local_status == "SIGNE" and not transaction.signed_at:
transaction.signed_at = datetime.now()
logger.info("Date de signature mise à jour")
if new_local_status == "REFUSE" and not transaction.refused_at:
transaction.refused_at = datetime.now()
logger.info(" Date de refus mise à jour")
if new_local_status == "EXPIRE" and not transaction.expired_at:
transaction.expired_at = datetime.now()
logger.info("Date d'expiration mise à jour")
documents = universign_data.get("documents", [])
if documents:
first_doc = documents[0]
logger.info(
f"Document Universign trouvé: id={first_doc.get('id')}, "
f"status={first_doc.get('status')}"
)
if new_local_status == "SIGNE" and not transaction.signed_document_path:
logger.info("Déclenchement téléchargement document signé...")
try:
(
download_success,
download_error,
) = await self.document_service.download_and_store_signed_document(
session=session, transaction=transaction, force=False
)
if download_success:
logger.info("Document signé téléchargé et stocké")
else:
logger.warning(f"Échec téléchargement: {download_error}")
except Exception as e:
logger.error(f" Erreur téléchargement document: {e}", exc_info=True)
await self._sync_signers(session, transaction, universign_data)
transaction.last_synced_at = datetime.now()
transaction.sync_attempts += 1
transaction.needs_sync = not is_final_status(new_local_status)
transaction.sync_error = None
await self._log_sync_attempt(
session=session,
transaction=transaction,
sync_type="polling",
success=True,
error_message=None,
previous_status=previous_local_status,
new_status=new_local_status,
changes=json.dumps(
{
"status_changed": status_changed,
"universign_raw": universign_status_raw,
"documents_count": len(documents),
"response_time_ms": result.get("response_time_ms"),
},
default=str,
),
)
await session.commit()
if status_changed:
logger.info(f"🎬 Exécution actions pour statut: {new_local_status}")
await self._execute_status_actions(
session, transaction, new_local_status
)
logger.info(
f"Sync terminée: {transaction.transaction_id} | "
f"{previous_local_status}{new_local_status}"
)
return True, None
except Exception as e:
error_msg = f"Erreur lors de la synchronisation: {str(e)}"
logger.error(f" {error_msg}", exc_info=True)
transaction.sync_error = error_msg[:1000]
transaction.sync_attempts += 1
await self._log_sync_attempt(
session, transaction, "polling", False, error_msg
)
await session.commit()
return False, error_msg
async def _sync_transaction_documents_corrected(
self, session, transaction, universign_data: dict, new_local_status: str
):
documents = universign_data.get("documents", [])
if documents:
first_doc = documents[0]
first_doc_id = first_doc.get("id")
if first_doc_id:
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = first_doc_id
logger.info(
f"Document Universign: id={first_doc_id}, "
f"name={first_doc.get('name')}, status={first_doc.get('status')}"
)
else:
logger.debug("Aucun document dans la réponse Universign")
if new_local_status == "SIGNE":
if not transaction.signed_document_path:
logger.info("Déclenchement téléchargement document signé...")
try:
(
download_success,
download_error,
) = await self.document_service.download_and_store_signed_document(
session=session, transaction=transaction, force=False
)
if download_success:
logger.info("Document signé téléchargé avec succès")
else:
logger.warning(f"Échec téléchargement: {download_error}")
except Exception as e:
logger.error(f" Erreur téléchargement document: {e}", exc_info=True)
else:
logger.debug(
f"Document déjà téléchargé: {transaction.signed_document_path}"
)
async def _log_sync_attempt(
self,
session: AsyncSession,
transaction: UniversignTransaction,
sync_type: str,
success: bool,
error_message: Optional[str] = None,
previous_status: Optional[str] = None,
new_status: Optional[str] = None,
changes: Optional[str] = None,
):
log = UniversignSyncLog(
transaction_id=transaction.id,
sync_type=sync_type,
sync_timestamp=datetime.now(),
previous_status=previous_status,
new_status=new_status,
changes_detected=changes,
success=success,
error_message=error_message,
)
session.add(log)
async def _execute_status_actions(
self, session: AsyncSession, transaction: UniversignTransaction, new_status: str
):
actions = get_status_actions(new_status)
if not actions:
return
if actions.get("update_sage_status") and self.sage_client:
await self._update_sage_status(transaction, new_status)
elif actions.get("update_sage_status"):
logger.debug(
f"sage_client non configuré, skip MAJ Sage pour {transaction.sage_document_id}"
)
if actions.get("send_notification") and self.email_queue and self.settings:
await self._send_notification(session, transaction, new_status)
elif actions.get("send_notification"):
logger.debug(
f"email_queue/settings non configuré, skip notification pour {transaction.transaction_id}"
)
async def _update_sage_status(
self, transaction: UniversignTransaction, status: str
):
if not self.sage_client:
logger.warning("sage_client non configuré pour mise à jour Sage")
return
try:
type_doc = transaction.sage_document_type.value
doc_id = transaction.sage_document_id
if status == "SIGNE":
self.sage_client.changer_statut_document(
document_type_code=type_doc, numero=doc_id, nouveau_statut=2
)
logger.info(f"Statut Sage mis à jour: {doc_id} → Accepté (2)")
elif status == "EN_COURS":
self.sage_client.changer_statut_document(
document_type_code=type_doc, numero=doc_id, nouveau_statut=1
)
logger.info(f"Statut Sage mis à jour: {doc_id} → Confirmé (1)")
except Exception as e:
logger.error(
f"Erreur mise à jour Sage pour {transaction.sage_document_id}: {e}"
)
async def _send_notification(
self, session: AsyncSession, transaction: UniversignTransaction, status: str
):
if not self.email_queue or not self.settings:
logger.warning("email_queue ou settings non configuré")
return
try:
if status == "SIGNE":
template = templates_signature_email["signature_confirmee"]
type_labels = {
0: "Devis",
10: "Commande",
30: "Bon de Livraison",
60: "Facture",
50: "Avoir",
}
variables = {
"NOM_SIGNATAIRE": transaction.requester_name or "Client",
"TYPE_DOC": type_labels.get(
transaction.sage_document_type.value, "Document"
),
"NUMERO": transaction.sage_document_id,
"DATE_SIGNATURE": transaction.signed_at.strftime("%d/%m/%Y à %H:%M")
if transaction.signed_at
else datetime.now().strftime("%d/%m/%Y à %H:%M"),
"TRANSACTION_ID": transaction.transaction_id,
"CONTACT_EMAIL": self.settings.smtp_from,
}
sujet = template["sujet"]
corps = template["corps_html"]
for var, valeur in variables.items():
sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
email_log = EmailLog(
id=str(uuid.uuid4()),
destinataire=transaction.requester_email,
sujet=sujet,
corps_html=corps,
document_ids=transaction.sage_document_id,
type_document=transaction.sage_document_type.value,
statut=StatutEmail.EN_ATTENTE,
date_creation=datetime.now(),
nb_tentatives=0,
)
session.add(email_log)
await session.flush()
self.email_queue.enqueue(email_log.id)
logger.info(
f"Email confirmation signature envoyé à {transaction.requester_email}"
)
except Exception as e:
logger.error(
f"Erreur envoi notification pour {transaction.transaction_id}: {e}"
)
@staticmethod
def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except Exception:
return None
class UniversignSyncScheduler:
def __init__(self, sync_service: UniversignSyncService, interval_minutes: int = 5):
self.sync_service = sync_service
self.interval_minutes = interval_minutes
self.is_running = False
async def start(self, session_factory):
import asyncio
self.is_running = True
logger.info(
f"Démarrage polling Universign (intervalle: {self.interval_minutes}min)"
)
while self.is_running:
try:
async with session_factory() as session:
stats = await self.sync_service.sync_all_pending(session)
logger.info(
f"Polling: {stats['success']} transactions synchronisées, "
f"{stats['status_changes']} changements"
)
except Exception as e:
logger.error(f"Erreur polling: {e}", exc_info=True)
await asyncio.sleep(self.interval_minutes * 60)
def stop(self):
self.is_running = False
logger.info("Arrêt polling Universign")

15
tools/cleaner.py Normal file
View file

@ -0,0 +1,15 @@
from pathlib import Path
def supprimer_commentaires_ligne(fichier):
path = Path(fichier)
lignes = path.read_text(encoding="utf-8").splitlines()
lignes_sans_commentaires = [line for line in lignes if not line.lstrip().startswith("#")]
path.write_text("\n".join(lignes_sans_commentaires), encoding="utf-8")
if __name__ == "__main__":
base_dir = Path(__file__).resolve().parent.parent
fichier_api = base_dir / "data/data.py"
supprimer_commentaires_ligne(fichier_api)

View file

@ -0,0 +1,52 @@
import ast
import os
import textwrap
SOURCE_FILE = "main.py"
MODELS_DIR = "../models"
os.makedirs(MODELS_DIR, exist_ok=True)
with open(SOURCE_FILE, "r", encoding="utf-8") as f:
source_code = f.read()
tree = ast.parse(source_code)
pydantic_classes = []
other_nodes = []
for node in tree.body:
if isinstance(node, ast.ClassDef):
if any(
isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases
):
pydantic_classes.append(node)
continue
other_nodes.append(node)
imports = """
from pydantic import BaseModel, Field
from typing import Optional, List
"""
for cls in pydantic_classes:
class_name = cls.name
file_name = f"{class_name.lower()}.py"
file_path = os.path.join(MODELS_DIR, file_name)
class_code = ast.get_source_segment(source_code, cls)
class_code = textwrap.dedent(class_code)
with open(file_path, "w", encoding="utf-8") as f:
f.write(imports.strip() + "\n\n")
f.write(class_code)
print(f"✅ Modèle extrait : {class_name}{file_path}")
new_tree = ast.Module(body=other_nodes, type_ignores=[])
new_source = ast.unparse(new_tree)
with open(SOURCE_FILE, "w", encoding="utf-8") as f:
f.write(new_source)
print("\n🎉 Extraction terminée")

136
utils/enterprise.py Normal file
View file

@ -0,0 +1,136 @@
from fastapi import HTTPException
from typing import Optional
import httpx
import logging
from schemas import EntrepriseSearch
logger = logging.getLogger(__name__)
def calculer_tva_intracommunautaire(siren: str) -> Optional[str]:
try:
siren_clean = siren.replace(" ", "").strip()
if not siren_clean.isdigit() or len(siren_clean) != 9:
logger.warning(f"SIREN invalide: {siren}")
return None
siren_int = int(siren_clean)
cle = (12 + 3 * (siren_int % 97)) % 97
cle_str = f"{cle:02d}"
return f"FR{cle_str}{siren_clean}"
except Exception as e:
logger.error(f"Erreur calcul TVA pour SIREN {siren}: {e}")
return None
def formater_adresse(siege_data: dict) -> str:
try:
adresse_parts = []
if siege_data.get("numero_voie"):
adresse_parts.append(siege_data["numero_voie"])
if siege_data.get("type_voie"):
adresse_parts.append(siege_data["type_voie"])
if siege_data.get("libelle_voie"):
adresse_parts.append(siege_data["libelle_voie"])
if siege_data.get("code_postal"):
adresse_parts.append(siege_data["code_postal"])
if siege_data.get("libelle_commune"):
adresse_parts.append(siege_data["libelle_commune"].upper())
return " ".join(adresse_parts)
except Exception as e:
logger.error(f"Erreur formatage adresse: {e}")
return ""
async def rechercher_entreprise_api(query: str, per_page: int = 5) -> dict:
api_url = "https://recherche-entreprises.api.gouv.fr/search"
params = {
"q": query,
"per_page": per_page,
"limite_etablissements": 5,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(api_url, params=params)
if response.status_code == 429:
logger.warning("Rate limit atteint (7 req/s)")
raise HTTPException(
status_code=429,
detail="Trop de requêtes. Veuillez réessayer dans 1 seconde.",
)
if response.status_code == 503:
logger.error("API Sirene indisponible (503)")
raise HTTPException(
status_code=503,
detail="Service de recherche momentanément indisponible.",
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
logger.error(f"Timeout lors de la recherche: {query}")
raise HTTPException(
status_code=504, detail="Délai d'attente dépassé pour l'API de recherche."
)
except httpx.HTTPError as e:
logger.error(f"Erreur HTTP API Sirene: {e}")
raise HTTPException(
status_code=500,
detail=f"Erreur lors de la communication avec l'API: {str(e)}",
)
def mapper_resultat_api(entreprise_data: dict) -> Optional[EntrepriseSearch]:
try:
siren = entreprise_data.get("siren")
if not siren:
logger.warning("Entreprise sans SIREN, ignorée")
return None
tva_number = calculer_tva_intracommunautaire(siren)
if not tva_number:
logger.warning(f"Impossible de calculer TVA pour SIREN: {siren}")
return None
siege = entreprise_data.get("siege", {})
etat_admin = entreprise_data.get("etat_administratif", "A")
is_active = etat_admin == "A"
return EntrepriseSearch(
company_name=entreprise_data.get("nom_complet", ""),
siren=siren,
vat_number=tva_number,
address=formater_adresse(siege),
naf_code=entreprise_data.get("activite_principale", ""),
is_active=is_active,
siret_siege=siege.get("siret"),
code_postal=siege.get("code_postal"),
ville=siege.get("libelle_commune"),
)
except Exception as e:
logger.error(f"Erreur mapping entreprise: {e}", exc_info=True)
return None

View file

@ -1,4 +1,4 @@
from typing import Dict
from typing import Dict, List
from config.config import settings
import logging
@ -7,7 +7,7 @@ import uuid
import requests
from sqlalchemy.ext.asyncio import AsyncSession
from services.email_queue import email_queue
from data.data import templates_signature_email
from database import EmailLog, StatutEmail as StatutEmailEnum
@ -22,6 +22,8 @@ async def universign_envoyer(
doc_data: Dict,
session: AsyncSession,
) -> Dict:
from email_queue import email_queue
try:
api_key = settings.universign_api_key
api_url = settings.universign_api_url
@ -262,3 +264,201 @@ async def universign_statut(transaction_id: str) -> Dict:
except Exception as e:
logger.error(f"Erreur statut Universign: {e}")
return {"statut": "ERREUR", "error": str(e)}
def normaliser_type_doc(type_doc: int) -> int:
TYPES_AUTORISES = {0, 10, 30, 50, 60}
if type_doc not in TYPES_AUTORISES:
raise ValueError(
f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}"
)
return type_doc if type_doc == 0 else type_doc // 10
def _preparer_lignes_document(lignes: List) -> List[Dict]:
return [
{
"article_code": ligne.article_code,
"quantite": ligne.quantite,
"prix_unitaire_ht": ligne.prix_unitaire_ht,
"remise_pourcentage": ligne.remise_pourcentage or 0.0,
}
for ligne in lignes
]
UNIVERSIGN_TO_LOCAL: Dict[str, str] = {
"draft": "EN_ATTENTE",
"ready": "EN_ATTENTE",
"started": "EN_COURS",
"completed": "SIGNE",
"closed": "SIGNE",
"refused": "REFUSE",
"expired": "EXPIRE",
"canceled": "REFUSE",
"failed": "ERREUR",
}
LOCAL_TO_SAGE_STATUS: Dict[str, int] = {
"EN_ATTENTE": 0,
"EN_COURS": 1,
"SIGNE": 2,
"REFUSE": 3,
"EXPIRE": 4,
"ERREUR": 5,
}
STATUS_ACTIONS: Dict[str, Dict[str, any]] = {
"""
Actions automatiques à déclencher selon le statut
"""
"SIGNE": {
"update_sage_status": True, # Mettre à jour Sage
"trigger_workflow": True, # Déclencher transformation (devis→commande)
"send_notification": True, # Email de confirmation
"archive_document": True, # Archiver le PDF signé
"update_sage_field": "CB_DateSignature", # Champ libre Sage
},
"REFUSE": {
"update_sage_status": True,
"trigger_workflow": False,
"send_notification": True,
"archive_document": False,
"alert_sales": True, # Alerter commercial
},
"EXPIRE": {
"update_sage_status": True,
"trigger_workflow": False,
"send_notification": True,
"archive_document": False,
"schedule_reminder": True, # Programmer relance
},
"ERREUR": {
"update_sage_status": False,
"trigger_workflow": False,
"send_notification": False,
"log_error": True,
"retry_sync": True,
},
}
ALLOWED_TRANSITIONS: Dict[str, list] = {
"""
Transitions de statuts autorisées (validation)
"""
"EN_ATTENTE": ["EN_COURS", "ERREUR"],
"EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"],
"SIGNE": [], # État final, pas de retour
"REFUSE": [], # État final
"EXPIRE": [], # État final
"ERREUR": ["EN_ATTENTE", "EN_COURS"], # Retry possible
}
def map_universign_to_local(universign_status: str) -> str:
return UNIVERSIGN_TO_LOCAL.get(
universign_status.lower(),
"ERREUR", # Fallback si statut inconnu
)
def get_sage_status_code(local_status: str) -> int:
return LOCAL_TO_SAGE_STATUS.get(local_status, 5)
def is_transition_allowed(from_status: str, to_status: str) -> bool:
if from_status == to_status:
return True # Même statut = OK (idempotence)
allowed = ALLOWED_TRANSITIONS.get(from_status, [])
return to_status in allowed
def get_status_actions(local_status: str) -> Dict[str, any]:
return STATUS_ACTIONS.get(local_status, {})
def is_final_status(local_status: str) -> bool:
return local_status in ["SIGNE", "REFUSE", "EXPIRE"]
STATUS_PRIORITY: Dict[str, int] = {
"ERREUR": 0,
"EN_ATTENTE": 1,
"EN_COURS": 2,
"EXPIRE": 3,
"REFUSE": 4,
"SIGNE": 5,
}
def resolve_status_conflict(status_a: str, status_b: str) -> str:
priority_a = STATUS_PRIORITY.get(status_a, 0)
priority_b = STATUS_PRIORITY.get(status_b, 0)
return status_a if priority_a >= priority_b else status_b
STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
"EN_ATTENTE": {
"fr": "Document en attente d'envoi",
"en": "Document pending",
"icon": "",
"color": "gray",
},
"EN_COURS": {
"fr": "En attente de signature",
"en": "Awaiting signature",
"icon": "✍️",
"color": "blue",
},
"SIGNE": {
"fr": "Signé avec succès",
"en": "Successfully signed",
"icon": "",
"color": "green",
},
"REFUSE": {
"fr": "Signature refusée",
"en": "Signature refused",
"icon": "",
"color": "red",
},
"EXPIRE": {
"fr": "Délai de signature expiré",
"en": "Signature expired",
"icon": "",
"color": "orange",
},
"ERREUR": {
"fr": "Erreur technique",
"en": "Technical error",
"icon": "",
"color": "red",
},
}
def get_status_message(local_status: str, lang: str = "fr") -> str:
"""
Obtient le message utilisateur pour un statut
Args:
local_status: Statut local
lang: Langue (fr, en)
Returns:
Message formaté
"""
status_info = STATUS_MESSAGES.get(local_status, {})
icon = status_info.get("icon", "")
message = status_info.get(lang, local_status)
return f"{icon} {message}"
__all__ = ["_preparer_lignes_document", "normaliser_type_doc"]

View file

@ -0,0 +1,165 @@
from typing import Dict, Any
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
UNIVERSIGN_TO_LOCAL: Dict[str, str] = {
"draft": "EN_ATTENTE",
"ready": "EN_ATTENTE",
"started": "EN_COURS",
"completed": "SIGNE",
"closed": "SIGNE",
"refused": "REFUSE",
"expired": "EXPIRE",
"canceled": "REFUSE",
"failed": "ERREUR",
}
LOCAL_TO_SAGE_STATUS: Dict[str, int] = {
"EN_ATTENTE": 0,
"EN_COURS": 1,
"SIGNE": 2,
"REFUSE": 3,
"EXPIRE": 4,
"ERREUR": 5,
}
STATUS_ACTIONS: Dict[str, Dict[str, Any]] = {
"SIGNE": {
"update_sage_status": True,
"trigger_workflow": True,
"send_notification": True,
"archive_document": True,
"update_sage_field": "CB_DateSignature",
},
"REFUSE": {
"update_sage_status": True,
"trigger_workflow": False,
"send_notification": True,
"archive_document": False,
"alert_sales": True,
},
"EXPIRE": {
"update_sage_status": True,
"trigger_workflow": False,
"send_notification": True,
"archive_document": False,
"schedule_reminder": True,
},
"ERREUR": {
"update_sage_status": False,
"trigger_workflow": False,
"send_notification": False,
"log_error": True,
"retry_sync": True,
},
}
ALLOWED_TRANSITIONS: Dict[str, list] = {
"EN_ATTENTE": ["EN_COURS", "ERREUR"],
"EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"],
"SIGNE": [],
"REFUSE": [],
"EXPIRE": [],
"ERREUR": ["EN_ATTENTE", "EN_COURS"],
}
STATUS_PRIORITY: Dict[str, int] = {
"ERREUR": 0,
"EN_ATTENTE": 1,
"EN_COURS": 2,
"EXPIRE": 3,
"REFUSE": 4,
"SIGNE": 5,
}
STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
"EN_ATTENTE": {
"fr": "Document en attente d'envoi",
"en": "Document pending",
"icon": "",
"color": "gray",
},
"EN_COURS": {
"fr": "En attente de signature",
"en": "Awaiting signature",
"icon": "✍️",
"color": "blue",
},
"SIGNE": {
"fr": "Signé avec succès",
"en": "Successfully signed",
"icon": "",
"color": "green",
},
"REFUSE": {
"fr": "Signature refusée",
"en": "Signature refused",
"icon": "",
"color": "red",
},
"EXPIRE": {
"fr": "Délai de signature expiré",
"en": "Signature expired",
"icon": "",
"color": "orange",
},
"ERREUR": {
"fr": "Erreur technique",
"en": "Technical error",
"icon": "",
"color": "red",
},
}
def map_universign_to_local(universign_status: str) -> str:
"""Convertit un statut Universign en statut local avec fallback robuste."""
normalized = universign_status.lower().strip()
mapped = UNIVERSIGN_TO_LOCAL.get(normalized)
if not mapped:
logger.warning(
f"Statut Universign inconnu: '{universign_status}', mapping vers ERREUR"
)
return "ERREUR"
return mapped
def get_sage_status_code(local_status: str) -> int:
"""Obtient le code numérique pour Sage."""
return LOCAL_TO_SAGE_STATUS.get(local_status, 5)
def is_transition_allowed(from_status: str, to_status: str) -> bool:
"""Vérifie si une transition de statut est valide."""
if from_status == to_status:
return True
return to_status in ALLOWED_TRANSITIONS.get(from_status, [])
def get_status_actions(local_status: str) -> Dict[str, Any]:
"""Obtient les actions à exécuter pour un statut."""
return STATUS_ACTIONS.get(local_status, {})
def is_final_status(local_status: str) -> bool:
"""Détermine si le statut est final."""
return local_status in ["SIGNE", "REFUSE", "EXPIRE"]
def resolve_status_conflict(status_a: str, status_b: str) -> str:
"""Résout un conflit entre deux statuts (prend le plus prioritaire)."""
priority_a = STATUS_PRIORITY.get(status_a, 0)
priority_b = STATUS_PRIORITY.get(status_b, 0)
return status_a if priority_a >= priority_b else status_b
def get_status_message(local_status: str, lang: str = "fr") -> str:
"""Obtient le message utilisateur pour un statut."""
status_info = STATUS_MESSAGES.get(local_status, {})
icon = status_info.get("icon", "")
message = status_info.get(lang, local_status)
return f"{icon} {message}"