nanopy-bank 1.0.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. nanopy_bank/__init__.py +20 -0
  2. nanopy_bank/api/__init__.py +10 -0
  3. nanopy_bank/api/server.py +242 -0
  4. nanopy_bank/app.py +282 -0
  5. nanopy_bank/cli.py +152 -0
  6. nanopy_bank/core/__init__.py +85 -0
  7. nanopy_bank/core/audit.py +404 -0
  8. nanopy_bank/core/auth.py +306 -0
  9. nanopy_bank/core/bank.py +407 -0
  10. nanopy_bank/core/beneficiary.py +258 -0
  11. nanopy_bank/core/branch.py +319 -0
  12. nanopy_bank/core/fees.py +243 -0
  13. nanopy_bank/core/holding.py +416 -0
  14. nanopy_bank/core/models.py +308 -0
  15. nanopy_bank/core/products.py +300 -0
  16. nanopy_bank/data/__init__.py +31 -0
  17. nanopy_bank/data/demo.py +846 -0
  18. nanopy_bank/documents/__init__.py +11 -0
  19. nanopy_bank/documents/statement.py +304 -0
  20. nanopy_bank/sepa/__init__.py +10 -0
  21. nanopy_bank/sepa/sepa.py +452 -0
  22. nanopy_bank/storage/__init__.py +11 -0
  23. nanopy_bank/storage/json_storage.py +127 -0
  24. nanopy_bank/storage/sqlite_storage.py +326 -0
  25. nanopy_bank/ui/__init__.py +14 -0
  26. nanopy_bank/ui/pages/__init__.py +33 -0
  27. nanopy_bank/ui/pages/accounts.py +85 -0
  28. nanopy_bank/ui/pages/advisor.py +140 -0
  29. nanopy_bank/ui/pages/audit.py +73 -0
  30. nanopy_bank/ui/pages/beneficiaries.py +115 -0
  31. nanopy_bank/ui/pages/branches.py +64 -0
  32. nanopy_bank/ui/pages/cards.py +36 -0
  33. nanopy_bank/ui/pages/common.py +18 -0
  34. nanopy_bank/ui/pages/dashboard.py +100 -0
  35. nanopy_bank/ui/pages/fees.py +60 -0
  36. nanopy_bank/ui/pages/holding.py +943 -0
  37. nanopy_bank/ui/pages/loans.py +105 -0
  38. nanopy_bank/ui/pages/login.py +174 -0
  39. nanopy_bank/ui/pages/sepa.py +118 -0
  40. nanopy_bank/ui/pages/settings.py +48 -0
  41. nanopy_bank/ui/pages/transfers.py +94 -0
  42. nanopy_bank/ui/pages.py +16 -0
  43. nanopy_bank-1.0.8.dist-info/METADATA +72 -0
  44. nanopy_bank-1.0.8.dist-info/RECORD +47 -0
  45. nanopy_bank-1.0.8.dist-info/WHEEL +5 -0
  46. nanopy_bank-1.0.8.dist-info/entry_points.txt +2 -0
  47. nanopy_bank-1.0.8.dist-info/top_level.txt +1 -0
nanopy_bank/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ """
2
+ NanoPy Bank CLI
3
+ """
4
+
5
+ import click
6
+ import sys
7
+ import os
8
+
9
+
10
+ @click.group()
11
+ @click.version_option(version="1.0.0")
12
+ def main():
13
+ """NanoPy Bank - Online Banking System"""
14
+ pass
15
+
16
+
17
+ @main.command()
18
+ @click.option("--port", "-p", default=8501, help="Port to run on")
19
+ @click.option("--host", "-h", default="localhost", help="Host to bind to")
20
+ def serve(port: int, host: str):
21
+ """Start the banking UI (Streamlit)"""
22
+ import subprocess
23
+
24
+ app_path = os.path.join(os.path.dirname(__file__), "app.py")
25
+ click.echo(f"Starting NanoPy Bank on http://{host}:{port}")
26
+
27
+ subprocess.run([
28
+ sys.executable, "-m", "streamlit", "run", app_path,
29
+ "--server.port", str(port),
30
+ "--server.address", host,
31
+ "--theme.base", "dark"
32
+ ])
33
+
34
+
35
+ @main.command()
36
+ @click.option("--iban", "-i", required=True, help="Account IBAN")
37
+ @click.option("--output", "-o", default="statement.xml", help="Output file")
38
+ def export_statement(iban: str, output: str):
39
+ """Export bank statement as SEPA XML (camt.053)"""
40
+ from .core import get_bank
41
+ from .sepa import SEPAGenerator
42
+ from datetime import datetime, timedelta
43
+
44
+ bank = get_bank()
45
+ account = bank.get_account(iban)
46
+
47
+ if not account:
48
+ click.echo(f"Account {iban} not found", err=True)
49
+ return
50
+
51
+ transactions = bank.get_account_transactions(iban, limit=100)
52
+
53
+ generator = SEPAGenerator(
54
+ initiator_name="NanoPy Bank",
55
+ initiator_iban=iban,
56
+ initiator_bic=account.bic
57
+ )
58
+
59
+ xml_content = generator.generate_statement(
60
+ iban=iban,
61
+ transactions=transactions,
62
+ opening_balance=account.balance,
63
+ closing_balance=account.balance,
64
+ from_date=datetime.now() - timedelta(days=30),
65
+ to_date=datetime.now()
66
+ )
67
+
68
+ with open(output, "w", encoding="utf-8") as f:
69
+ f.write(xml_content)
70
+
71
+ click.echo(f"Statement exported to {output}")
72
+
73
+
74
+ @main.command()
75
+ @click.option("--iban", "-i", required=True, help="Source Account IBAN")
76
+ def export_sepa(iban: str):
77
+ """Export SEPA Credit Transfer XML (pain.001)"""
78
+ click.echo("Use the web UI for SEPA exports: nanopy-bank serve")
79
+
80
+
81
+ @main.command()
82
+ def stats():
83
+ """Show bank statistics"""
84
+ from .core import get_bank
85
+
86
+ bank = get_bank()
87
+ stats = bank.get_stats()
88
+
89
+ click.echo("\n=== NanoPy Bank Statistics ===\n")
90
+ click.echo(f"Customers: {stats['total_customers']}")
91
+ click.echo(f"Accounts: {stats['total_accounts']}")
92
+ click.echo(f"Transactions: {stats['total_transactions']}")
93
+ click.echo(f"Total Balance: {stats['total_balance']} EUR")
94
+ click.echo(f"Today's Txs: {stats['transactions_today']}")
95
+ click.echo()
96
+
97
+
98
+ @main.command()
99
+ def demo():
100
+ """Create demo data"""
101
+ from decimal import Decimal
102
+ from .core import get_bank, AccountType, TransactionType
103
+
104
+ bank = get_bank()
105
+
106
+ click.echo("Creating demo customer and account...")
107
+
108
+ # Create customer
109
+ customer = bank.create_customer(
110
+ first_name="Marie",
111
+ last_name="Dupont",
112
+ email="marie.dupont@email.com",
113
+ phone="+33612345678",
114
+ address="45 Avenue des Champs-Élysées",
115
+ city="Paris",
116
+ postal_code="75008",
117
+ country="FR"
118
+ )
119
+
120
+ # Create checking account
121
+ account = bank.create_account(
122
+ customer_id=customer.customer_id,
123
+ account_type=AccountType.CHECKING,
124
+ initial_balance=Decimal("2500.00"),
125
+ account_name="Compte Courant"
126
+ )
127
+
128
+ # Create savings account
129
+ savings = bank.create_account(
130
+ customer_id=customer.customer_id,
131
+ account_type=AccountType.SAVINGS,
132
+ initial_balance=Decimal("15000.00"),
133
+ account_name="Livret A"
134
+ )
135
+
136
+ # Add demo transactions
137
+ bank.credit(account.iban, Decimal("3200.00"), "Salaire Décembre", "ACME SARL", "", TransactionType.SEPA_CREDIT)
138
+ bank.debit(account.iban, Decimal("850.00"), "Loyer Janvier", "IMMO PARIS", "", TransactionType.SEPA_DEBIT)
139
+ bank.debit(account.iban, Decimal("127.50"), "EDF Electricité", "EDF", "", TransactionType.SEPA_DEBIT)
140
+ bank.debit(account.iban, Decimal("45.90"), "Carrefour Market", "CARREFOUR", "", TransactionType.CARD_PAYMENT)
141
+ bank.debit(account.iban, Decimal("12.99"), "Spotify Premium", "SPOTIFY", "", TransactionType.CARD_PAYMENT)
142
+ bank.debit(account.iban, Decimal("60.00"), "Retrait DAB", "", "", TransactionType.ATM_WITHDRAWAL)
143
+
144
+ click.echo(f"\nDemo created!")
145
+ click.echo(f"Customer: {customer.full_name} ({customer.customer_id})")
146
+ click.echo(f"Checking: {account.format_iban()}")
147
+ click.echo(f"Savings: {savings.format_iban()}")
148
+ click.echo(f"\nRun 'nanopy-bank serve' to access the UI")
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -0,0 +1,85 @@
1
+ """
2
+ Core banking module
3
+ """
4
+
5
+ from .models import (
6
+ Account, Transaction, Customer, Card,
7
+ TransactionType, AccountType, AccountStatus, Currency,
8
+ CardType, CardStatus
9
+ )
10
+ from .bank import Bank, get_bank
11
+ from .beneficiary import (
12
+ Beneficiary, StandingOrder, SEPAMandate,
13
+ OrderFrequency, OrderStatus, MandateType, MandateStatus
14
+ )
15
+ from .products import (
16
+ Loan, Insurance, SavingsProduct,
17
+ LoanType, LoanStatus, InsuranceType, SavingsType
18
+ )
19
+ from .fees import Fee, InterestRate, AppliedFee, FeeType, RateType
20
+ from .branch import Branch, Employee, ATM, BranchType, EmployeeRole, EmployeeStatus
21
+ from .audit import (
22
+ AuditLog, SecurityEvent, ComplianceCheck, AuditLogger,
23
+ AuditAction, SecurityEventType, RiskLevel, EventStatus
24
+ )
25
+
26
+ __all__ = [
27
+ # Models
28
+ "Account",
29
+ "Transaction",
30
+ "Customer",
31
+ "Card",
32
+ "TransactionType",
33
+ "AccountType",
34
+ "AccountStatus",
35
+ "Currency",
36
+ "CardType",
37
+ "CardStatus",
38
+
39
+ # Bank
40
+ "Bank",
41
+ "get_bank",
42
+
43
+ # Beneficiary
44
+ "Beneficiary",
45
+ "StandingOrder",
46
+ "SEPAMandate",
47
+ "OrderFrequency",
48
+ "OrderStatus",
49
+ "MandateType",
50
+ "MandateStatus",
51
+
52
+ # Products
53
+ "Loan",
54
+ "Insurance",
55
+ "SavingsProduct",
56
+ "LoanType",
57
+ "LoanStatus",
58
+ "InsuranceType",
59
+ "SavingsType",
60
+
61
+ # Fees
62
+ "Fee",
63
+ "InterestRate",
64
+ "AppliedFee",
65
+ "FeeType",
66
+ "RateType",
67
+
68
+ # Branch
69
+ "Branch",
70
+ "Employee",
71
+ "ATM",
72
+ "BranchType",
73
+ "EmployeeRole",
74
+ "EmployeeStatus",
75
+
76
+ # Audit
77
+ "AuditLog",
78
+ "SecurityEvent",
79
+ "ComplianceCheck",
80
+ "AuditLogger",
81
+ "AuditAction",
82
+ "SecurityEventType",
83
+ "RiskLevel",
84
+ "EventStatus",
85
+ ]
@@ -0,0 +1,404 @@
1
+ """
2
+ Audit and Security Models - Logging, Events, Compliance
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from decimal import Decimal
8
+ from enum import Enum
9
+ from typing import Optional, Dict, Any
10
+ import uuid
11
+ import hashlib
12
+ import json
13
+
14
+
15
+ class AuditAction(Enum):
16
+ """Types of auditable actions"""
17
+ # Account actions
18
+ ACCOUNT_CREATE = "account_create"
19
+ ACCOUNT_UPDATE = "account_update"
20
+ ACCOUNT_CLOSE = "account_close"
21
+ ACCOUNT_FREEZE = "account_freeze"
22
+ ACCOUNT_UNFREEZE = "account_unfreeze"
23
+
24
+ # Transaction actions
25
+ TRANSACTION_CREATE = "transaction_create"
26
+ TRANSACTION_APPROVE = "transaction_approve"
27
+ TRANSACTION_REJECT = "transaction_reject"
28
+ TRANSACTION_REVERSE = "transaction_reverse"
29
+
30
+ # Customer actions
31
+ CUSTOMER_CREATE = "customer_create"
32
+ CUSTOMER_UPDATE = "customer_update"
33
+ CUSTOMER_KYC_UPDATE = "customer_kyc_update"
34
+ CUSTOMER_BLOCK = "customer_block"
35
+
36
+ # Card actions
37
+ CARD_CREATE = "card_create"
38
+ CARD_ACTIVATE = "card_activate"
39
+ CARD_BLOCK = "card_block"
40
+ CARD_REPLACE = "card_replace"
41
+
42
+ # Loan actions
43
+ LOAN_CREATE = "loan_create"
44
+ LOAN_APPROVE = "loan_approve"
45
+ LOAN_REJECT = "loan_reject"
46
+ LOAN_PAYMENT = "loan_payment"
47
+
48
+ # Admin actions
49
+ ADMIN_LOGIN = "admin_login"
50
+ ADMIN_LOGOUT = "admin_logout"
51
+ SETTINGS_CHANGE = "settings_change"
52
+ FEE_UPDATE = "fee_update"
53
+ RATE_UPDATE = "rate_update"
54
+
55
+ # Security actions
56
+ PASSWORD_CHANGE = "password_change"
57
+ PIN_CHANGE = "pin_change"
58
+ LIMIT_CHANGE = "limit_change"
59
+
60
+
61
+ class SecurityEventType(Enum):
62
+ """Types of security events"""
63
+ # Authentication
64
+ LOGIN_SUCCESS = "login_success"
65
+ LOGIN_FAILURE = "login_failure"
66
+ LOGIN_BLOCKED = "login_blocked"
67
+ MFA_SUCCESS = "mfa_success"
68
+ MFA_FAILURE = "mfa_failure"
69
+ SESSION_EXPIRED = "session_expired"
70
+
71
+ # Suspicious activity
72
+ UNUSUAL_LOCATION = "unusual_location"
73
+ UNUSUAL_TIME = "unusual_time"
74
+ UNUSUAL_AMOUNT = "unusual_amount"
75
+ VELOCITY_CHECK = "velocity_check"
76
+ FRAUD_DETECTED = "fraud_detected"
77
+
78
+ # Card events
79
+ CARD_STOLEN = "card_stolen"
80
+ CARD_CLONED = "card_cloned"
81
+ CARD_MISUSE = "card_misuse"
82
+ PIN_ATTEMPTS = "pin_attempts"
83
+
84
+ # Account events
85
+ ACCOUNT_TAKEOVER = "account_takeover"
86
+ BENEFICIARY_CHANGE = "beneficiary_change"
87
+ CONTACT_CHANGE = "contact_change"
88
+ DEVICE_CHANGE = "device_change"
89
+
90
+ # Compliance
91
+ AML_ALERT = "aml_alert"
92
+ SANCTION_CHECK = "sanction_check"
93
+ PEP_CHECK = "pep_check"
94
+ LARGE_TRANSACTION = "large_transaction"
95
+
96
+
97
+ class RiskLevel(Enum):
98
+ """Risk levels for security events"""
99
+ LOW = "low"
100
+ MEDIUM = "medium"
101
+ HIGH = "high"
102
+ CRITICAL = "critical"
103
+
104
+
105
+ class EventStatus(Enum):
106
+ """Status of security events"""
107
+ NEW = "new"
108
+ INVESTIGATING = "investigating"
109
+ CONFIRMED = "confirmed"
110
+ FALSE_POSITIVE = "false_positive"
111
+ RESOLVED = "resolved"
112
+
113
+
114
+ @dataclass
115
+ class AuditLog:
116
+ """
117
+ Audit trail for all banking operations
118
+ """
119
+ log_id: str = field(default_factory=lambda: str(uuid.uuid4()))
120
+
121
+ # Action details
122
+ action: AuditAction = AuditAction.ACCOUNT_CREATE
123
+ description: str = ""
124
+
125
+ # Who
126
+ actor_type: str = "user" # user, employee, system, api
127
+ actor_id: str = ""
128
+ actor_name: str = ""
129
+ actor_ip: str = ""
130
+ actor_device: str = ""
131
+
132
+ # What
133
+ entity_type: str = "" # account, transaction, customer, card, loan
134
+ entity_id: str = ""
135
+ entity_name: str = ""
136
+
137
+ # Changes
138
+ old_values: Dict[str, Any] = field(default_factory=dict)
139
+ new_values: Dict[str, Any] = field(default_factory=dict)
140
+ metadata: Dict[str, Any] = field(default_factory=dict)
141
+
142
+ # When
143
+ timestamp: datetime = field(default_factory=datetime.now)
144
+
145
+ # Result
146
+ success: bool = True
147
+ error_message: str = ""
148
+
149
+ # Compliance
150
+ requires_review: bool = False
151
+ reviewed_by: str = ""
152
+ reviewed_at: Optional[datetime] = None
153
+
154
+ def __post_init__(self):
155
+ # Generate hash for integrity
156
+ self._hash = self._compute_hash()
157
+
158
+ def _compute_hash(self) -> str:
159
+ """Compute hash of log entry for integrity verification"""
160
+ data = f"{self.log_id}|{self.action.value}|{self.actor_id}|{self.entity_id}|{self.timestamp.isoformat()}"
161
+ return hashlib.sha256(data.encode()).hexdigest()[:16]
162
+
163
+ def verify_integrity(self) -> bool:
164
+ """Verify log entry hasn't been tampered with"""
165
+ return self._hash == self._compute_hash()
166
+
167
+ def to_dict(self) -> dict:
168
+ return {
169
+ "log_id": self.log_id,
170
+ "action": self.action.value,
171
+ "description": self.description,
172
+ "actor_type": self.actor_type,
173
+ "actor_id": self.actor_id,
174
+ "actor_name": self.actor_name,
175
+ "actor_ip": self.actor_ip,
176
+ "actor_device": self.actor_device,
177
+ "entity_type": self.entity_type,
178
+ "entity_id": self.entity_id,
179
+ "entity_name": self.entity_name,
180
+ "old_values": self.old_values,
181
+ "new_values": self.new_values,
182
+ "metadata": self.metadata,
183
+ "timestamp": self.timestamp.isoformat(),
184
+ "success": self.success,
185
+ "error_message": self.error_message,
186
+ "requires_review": self.requires_review,
187
+ "reviewed_by": self.reviewed_by,
188
+ "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
189
+ "hash": self._hash,
190
+ }
191
+
192
+
193
+ @dataclass
194
+ class SecurityEvent:
195
+ """
196
+ Security event / alert
197
+ """
198
+ event_id: str = field(default_factory=lambda: f"SEC{uuid.uuid4().hex[:10].upper()}")
199
+
200
+ # Event details
201
+ event_type: SecurityEventType = SecurityEventType.LOGIN_FAILURE
202
+ risk_level: RiskLevel = RiskLevel.LOW
203
+ status: EventStatus = EventStatus.NEW
204
+
205
+ # Description
206
+ title: str = ""
207
+ description: str = ""
208
+ recommendation: str = ""
209
+
210
+ # Who is affected
211
+ customer_id: Optional[str] = None
212
+ account_iban: Optional[str] = None
213
+ card_id: Optional[str] = None
214
+ employee_id: Optional[str] = None
215
+
216
+ # Context
217
+ ip_address: str = ""
218
+ user_agent: str = ""
219
+ device_id: str = ""
220
+ location: str = ""
221
+ country: str = ""
222
+
223
+ # Transaction related
224
+ transaction_id: Optional[str] = None
225
+ amount: Optional[Decimal] = None
226
+
227
+ # Timing
228
+ detected_at: datetime = field(default_factory=datetime.now)
229
+ resolved_at: Optional[datetime] = None
230
+
231
+ # Investigation
232
+ assigned_to: str = ""
233
+ notes: str = ""
234
+ resolution: str = ""
235
+
236
+ # Related events
237
+ related_events: list = field(default_factory=list)
238
+
239
+ # Metadata
240
+ raw_data: Dict[str, Any] = field(default_factory=dict)
241
+ created_at: datetime = field(default_factory=datetime.now)
242
+
243
+ def escalate(self, new_level: RiskLevel, reason: str):
244
+ """Escalate event to higher risk level"""
245
+ self.risk_level = new_level
246
+ self.notes += f"\n[Escalated] {datetime.now().isoformat()}: {reason}"
247
+
248
+ def resolve(self, resolution: str, resolved_by: str):
249
+ """Mark event as resolved"""
250
+ self.status = EventStatus.RESOLVED
251
+ self.resolution = resolution
252
+ self.resolved_at = datetime.now()
253
+ self.notes += f"\n[Resolved] {datetime.now().isoformat()} by {resolved_by}: {resolution}"
254
+
255
+ def to_dict(self) -> dict:
256
+ return {
257
+ "event_id": self.event_id,
258
+ "event_type": self.event_type.value,
259
+ "risk_level": self.risk_level.value,
260
+ "status": self.status.value,
261
+ "title": self.title,
262
+ "description": self.description,
263
+ "recommendation": self.recommendation,
264
+ "customer_id": self.customer_id,
265
+ "account_iban": self.account_iban,
266
+ "card_id": self.card_id,
267
+ "employee_id": self.employee_id,
268
+ "ip_address": self.ip_address,
269
+ "user_agent": self.user_agent,
270
+ "device_id": self.device_id,
271
+ "location": self.location,
272
+ "country": self.country,
273
+ "transaction_id": self.transaction_id,
274
+ "amount": str(self.amount) if self.amount else None,
275
+ "detected_at": self.detected_at.isoformat(),
276
+ "resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
277
+ "assigned_to": self.assigned_to,
278
+ "notes": self.notes,
279
+ "resolution": self.resolution,
280
+ "related_events": self.related_events,
281
+ "created_at": self.created_at.isoformat(),
282
+ }
283
+
284
+
285
+ @dataclass
286
+ class ComplianceCheck:
287
+ """
288
+ Compliance verification record (KYC, AML, etc.)
289
+ """
290
+ check_id: str = field(default_factory=lambda: f"CHK{uuid.uuid4().hex[:10].upper()}")
291
+
292
+ # Check type
293
+ check_type: str = "" # kyc, aml, sanction, pep, fatca
294
+ customer_id: str = ""
295
+
296
+ # Result
297
+ passed: bool = True
298
+ score: int = 0 # Risk score 0-100
299
+ findings: list = field(default_factory=list)
300
+
301
+ # Documents
302
+ documents_verified: list = field(default_factory=list)
303
+ documents_missing: list = field(default_factory=list)
304
+
305
+ # External checks
306
+ external_provider: str = ""
307
+ external_reference: str = ""
308
+ external_response: Dict[str, Any] = field(default_factory=dict)
309
+
310
+ # Status
311
+ status: str = "pending" # pending, passed, failed, review
312
+ requires_review: bool = False
313
+ reviewed_by: str = ""
314
+ reviewed_at: Optional[datetime] = None
315
+ review_notes: str = ""
316
+
317
+ # Validity
318
+ valid_until: Optional[datetime] = None
319
+ next_review: Optional[datetime] = None
320
+
321
+ # Metadata
322
+ created_at: datetime = field(default_factory=datetime.now)
323
+ updated_at: datetime = field(default_factory=datetime.now)
324
+
325
+ def to_dict(self) -> dict:
326
+ return {
327
+ "check_id": self.check_id,
328
+ "check_type": self.check_type,
329
+ "customer_id": self.customer_id,
330
+ "passed": self.passed,
331
+ "score": self.score,
332
+ "findings": self.findings,
333
+ "documents_verified": self.documents_verified,
334
+ "documents_missing": self.documents_missing,
335
+ "external_provider": self.external_provider,
336
+ "external_reference": self.external_reference,
337
+ "status": self.status,
338
+ "requires_review": self.requires_review,
339
+ "reviewed_by": self.reviewed_by,
340
+ "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None,
341
+ "review_notes": self.review_notes,
342
+ "valid_until": self.valid_until.isoformat() if self.valid_until else None,
343
+ "next_review": self.next_review.isoformat() if self.next_review else None,
344
+ "created_at": self.created_at.isoformat(),
345
+ "updated_at": self.updated_at.isoformat(),
346
+ }
347
+
348
+
349
+ class AuditLogger:
350
+ """
351
+ Helper class to create audit logs easily
352
+ """
353
+
354
+ def __init__(self):
355
+ self.logs: list[AuditLog] = []
356
+
357
+ def log(
358
+ self,
359
+ action: AuditAction,
360
+ actor_id: str,
361
+ entity_type: str,
362
+ entity_id: str,
363
+ description: str = "",
364
+ old_values: dict = None,
365
+ new_values: dict = None,
366
+ **kwargs
367
+ ) -> AuditLog:
368
+ """Create and store an audit log entry"""
369
+ log = AuditLog(
370
+ action=action,
371
+ description=description or f"{action.value} on {entity_type} {entity_id}",
372
+ actor_id=actor_id,
373
+ entity_type=entity_type,
374
+ entity_id=entity_id,
375
+ old_values=old_values or {},
376
+ new_values=new_values or {},
377
+ **kwargs
378
+ )
379
+ self.logs.append(log)
380
+ return log
381
+
382
+ def get_logs(
383
+ self,
384
+ entity_id: str = None,
385
+ actor_id: str = None,
386
+ action: AuditAction = None,
387
+ from_date: datetime = None,
388
+ to_date: datetime = None
389
+ ) -> list[AuditLog]:
390
+ """Query audit logs with filters"""
391
+ results = self.logs
392
+
393
+ if entity_id:
394
+ results = [l for l in results if l.entity_id == entity_id]
395
+ if actor_id:
396
+ results = [l for l in results if l.actor_id == actor_id]
397
+ if action:
398
+ results = [l for l in results if l.action == action]
399
+ if from_date:
400
+ results = [l for l in results if l.timestamp >= from_date]
401
+ if to_date:
402
+ results = [l for l in results if l.timestamp <= to_date]
403
+
404
+ return sorted(results, key=lambda x: x.timestamp, reverse=True)