fastapi-forge-cli 0.1.0__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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import secrets
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Dict, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from jose import JWTError, jwt
|
|
9
|
+
from passlib.context import CryptContext
|
|
10
|
+
|
|
11
|
+
from app.core.config import settings
|
|
12
|
+
from app.core.exceptions import UnauthorizedException
|
|
13
|
+
|
|
14
|
+
# ── Password hashing (bcrypt, cost=12) ──────────────────────────
|
|
15
|
+
pwd_context = CryptContext(
|
|
16
|
+
schemes=["bcrypt"],
|
|
17
|
+
deprecated="auto",
|
|
18
|
+
bcrypt__rounds=12, # work factor — increase for more security
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_password(plain: str) -> str:
|
|
23
|
+
"""Hash a plain-text password using bcrypt."""
|
|
24
|
+
return pwd_context.hash(plain)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
28
|
+
"""Verify a plain-text password against its bcrypt hash."""
|
|
29
|
+
return pwd_context.verify(plain, hashed)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── JWT helpers ──────────────────────────────────────────────────
|
|
33
|
+
def _utcnow() -> datetime:
|
|
34
|
+
return datetime.now(timezone.utc)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_access_token(
|
|
38
|
+
subject: Any,
|
|
39
|
+
extra_claims: Optional[Dict] = None,
|
|
40
|
+
) -> Tuple[str, datetime]:
|
|
41
|
+
expire = _utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
|
42
|
+
payload = {
|
|
43
|
+
"sub": str(subject),
|
|
44
|
+
"exp": expire,
|
|
45
|
+
"iat": _utcnow(),
|
|
46
|
+
"jti": str(uuid.uuid4()),
|
|
47
|
+
"type": "access",
|
|
48
|
+
**(extra_claims or {}),
|
|
49
|
+
}
|
|
50
|
+
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
51
|
+
return token, expire
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_refresh_token(subject: Any) -> Tuple[str, datetime]:
|
|
55
|
+
expire = _utcnow() + timedelta(days=settings.refresh_token_expire_days)
|
|
56
|
+
payload = {
|
|
57
|
+
"sub": str(subject),
|
|
58
|
+
"exp": expire,
|
|
59
|
+
"iat": _utcnow(),
|
|
60
|
+
"jti": str(uuid.uuid4()),
|
|
61
|
+
"type": "refresh",
|
|
62
|
+
}
|
|
63
|
+
token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
64
|
+
return token, expire
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_password_reset_token(email: str) -> str:
|
|
68
|
+
expire = _utcnow() + timedelta(minutes=settings.password_reset_token_expire_minutes)
|
|
69
|
+
payload = {"sub": email, "exp": expire, "type": "password_reset"}
|
|
70
|
+
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.algorithm)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def decode_token(token: str, expected_type: str = "access") -> Dict:
|
|
74
|
+
"""
|
|
75
|
+
Decode and validate a JWT. Raises UnauthorizedException on failure.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
payload = jwt.decode(
|
|
79
|
+
token, settings.SECRET_KEY, algorithms=[settings.algorithm]
|
|
80
|
+
)
|
|
81
|
+
except JWTError as exc:
|
|
82
|
+
raise UnauthorizedException(f"Invalid or expired token: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
if payload.get("type") != expected_type:
|
|
85
|
+
raise UnauthorizedException(f"Expected token type '{expected_type}'")
|
|
86
|
+
|
|
87
|
+
return payload
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Webhook signature verification ──────────────────────────────
|
|
91
|
+
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Verify an HMAC-SHA256 webhook signature.
|
|
94
|
+
Expected header format: 'sha256=<hex_digest>'
|
|
95
|
+
"""
|
|
96
|
+
expected = (
|
|
97
|
+
"sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
98
|
+
)
|
|
99
|
+
return hmac.compare_digest(expected, signature)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Secure random helpers ────────────────────────────────────────
|
|
103
|
+
def generate_secure_token(nbytes: int = 32) -> str:
|
|
104
|
+
"""Generate a URL-safe random token (for email verification, API keys, etc.)."""
|
|
105
|
+
return secrets.token_urlsafe(nbytes)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def hash_opaque_token(token: str) -> str:
|
|
109
|
+
"""Return a one-way digest suitable for storing an opaque token."""
|
|
110
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def generate_api_key() -> str:
|
|
114
|
+
"""Generate a prefixed API key."""
|
|
115
|
+
return f"sk_{secrets.token_urlsafe(40)}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Database package."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from app.db.models.user import User
|
|
2
|
+
from app.db.models.permission import (
|
|
3
|
+
Permission,
|
|
4
|
+
Role,
|
|
5
|
+
role_permission_table,
|
|
6
|
+
user_permission_table,
|
|
7
|
+
user_role_table,
|
|
8
|
+
)
|
|
9
|
+
from app.db.models.audit_log import AuditLog
|
|
10
|
+
from app.db.models.auth_token import (
|
|
11
|
+
EmailVerificationToken,
|
|
12
|
+
PasswordResetToken,
|
|
13
|
+
UserSession,
|
|
14
|
+
)
|
|
15
|
+
from app.db.models.notification import Notification
|
|
16
|
+
from app.db.models.revoked_token import RevokedToken
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
|
|
5
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.names import APP_SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditLog(Base):
|
|
13
|
+
__tablename__ = "audit_logs"
|
|
14
|
+
|
|
15
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
16
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
17
|
+
)
|
|
18
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
19
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ── Who ───────────────────────────────────────────────────
|
|
23
|
+
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
24
|
+
UUID(as_uuid=True),
|
|
25
|
+
ForeignKey("auth.users.id", ondelete="SET NULL"),
|
|
26
|
+
nullable=True,
|
|
27
|
+
index=True,
|
|
28
|
+
)
|
|
29
|
+
user_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
30
|
+
|
|
31
|
+
# ── What ──────────────────────────────────────────────────
|
|
32
|
+
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
|
33
|
+
resource: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
|
34
|
+
resource_id: Mapped[str | None] = mapped_column(
|
|
35
|
+
String(255), nullable=True, index=True
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# ── Payload ───────────────────────────────────────────────
|
|
39
|
+
old_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
40
|
+
new_values: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
41
|
+
log_metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
42
|
+
|
|
43
|
+
# ── Context ───────────────────────────────────────────────
|
|
44
|
+
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
|
45
|
+
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
46
|
+
request_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
47
|
+
|
|
48
|
+
# ── Relationship ──────────────────────────────────────────
|
|
49
|
+
user: Mapped["User"] = relationship("User", back_populates="audit_logs")
|
|
50
|
+
|
|
51
|
+
__table_args__ = (
|
|
52
|
+
Index("ix_audit_resource_action", "resource", "action"),
|
|
53
|
+
Index("ix_audit_user_resource", "user_id", "resource"),
|
|
54
|
+
{"schema": APP_SCHEMA},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"<AuditLog action={self.action} resource={self.resource} id={self.resource_id}>"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
|
|
5
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EmailVerificationToken(Base):
|
|
13
|
+
__tablename__ = "email_verification_tokens"
|
|
14
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
15
|
+
|
|
16
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
17
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
18
|
+
)
|
|
19
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
20
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
21
|
+
)
|
|
22
|
+
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
23
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
24
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
25
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
26
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PasswordResetToken(Base):
|
|
31
|
+
__tablename__ = "password_reset_tokens"
|
|
32
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
33
|
+
|
|
34
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
35
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
36
|
+
)
|
|
37
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
38
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
39
|
+
)
|
|
40
|
+
token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
|
41
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
42
|
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
43
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
44
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UserSession(Base):
|
|
49
|
+
__tablename__ = "user_sessions"
|
|
50
|
+
__table_args__ = (
|
|
51
|
+
Index("ix_user_sessions_user_active", "user_id", "revoked_at"),
|
|
52
|
+
{"schema": AUTH_SCHEMA},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
56
|
+
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
57
|
+
)
|
|
58
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
59
|
+
UUID(as_uuid=True), ForeignKey("auth.users.id", ondelete="CASCADE"), index=True
|
|
60
|
+
)
|
|
61
|
+
refresh_jti: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
62
|
+
previous_refresh_jti: Mapped[str | None] = mapped_column(String(64), index=True)
|
|
63
|
+
user_agent: Mapped[str | None] = mapped_column(Text)
|
|
64
|
+
ip_address: Mapped[str | None] = mapped_column(String(45))
|
|
65
|
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
66
|
+
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
67
|
+
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
68
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
69
|
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
user = relationship("User", back_populates="sessions")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
|
5
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.db.schemas.common import BaseModelMixin
|
|
10
|
+
from app.db.schemas.names import APP_SCHEMA
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Notification(BaseModelMixin, Base):
|
|
14
|
+
__tablename__ = "notifications"
|
|
15
|
+
__table_args__ = {"schema": APP_SCHEMA}
|
|
16
|
+
|
|
17
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
18
|
+
UUID(as_uuid=True),
|
|
19
|
+
ForeignKey("auth.users.id", ondelete="CASCADE"),
|
|
20
|
+
nullable=False,
|
|
21
|
+
index=True,
|
|
22
|
+
)
|
|
23
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
24
|
+
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
25
|
+
notification_type: Mapped[str] = mapped_column(
|
|
26
|
+
String(100), nullable=False, index=True
|
|
27
|
+
) # e.g. "info", "warning", "success", "error", "system"
|
|
28
|
+
|
|
29
|
+
# Channel: "in_app" | "email" | "push" | "webhook"
|
|
30
|
+
channel: Mapped[str] = mapped_column(String(50), default="in_app", nullable=False)
|
|
31
|
+
|
|
32
|
+
is_read: Mapped[bool] = mapped_column(
|
|
33
|
+
Boolean, default=False, nullable=False, index=True
|
|
34
|
+
)
|
|
35
|
+
read_at: Mapped[datetime | None] = mapped_column(
|
|
36
|
+
DateTime(timezone=True), nullable=True
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Arbitrary extra payload (deep link, action URL, entity ref, etc.)
|
|
40
|
+
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
41
|
+
|
|
42
|
+
user: Mapped["User"] = relationship("User", back_populates="notifications")
|
|
43
|
+
|
|
44
|
+
def mark_read(self) -> None:
|
|
45
|
+
self.is_read = True
|
|
46
|
+
self.read_at = datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
def __repr__(self) -> str:
|
|
49
|
+
return f"<Notification user={self.user_id} type={self.notification_type} read={self.is_read}>"
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Permission layer:
|
|
3
|
+
- Permission - a fine-grained code like "users:read", "reports:export"
|
|
4
|
+
- Role - a named collection of permissions
|
|
5
|
+
- user_roles - M2M: users can belong to many roles
|
|
6
|
+
- role_permissions - M2M: roles hold many permissions
|
|
7
|
+
- user_permissions - M2M: users can have extra individual permissions
|
|
8
|
+
|
|
9
|
+
At login time the auth service merges role permissions + individual
|
|
10
|
+
permissions into a flat list that is embedded in the JWT.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from sqlalchemy import (
|
|
16
|
+
Boolean,
|
|
17
|
+
Column,
|
|
18
|
+
ForeignKey,
|
|
19
|
+
String,
|
|
20
|
+
Table,
|
|
21
|
+
Text,
|
|
22
|
+
UniqueConstraint,
|
|
23
|
+
)
|
|
24
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
25
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
26
|
+
|
|
27
|
+
from app.db.base import Base
|
|
28
|
+
from app.db.schemas.common import BaseModelMixin
|
|
29
|
+
from app.db.schemas.names import RBAC_SCHEMA
|
|
30
|
+
|
|
31
|
+
user_role_table = Table(
|
|
32
|
+
"user_roles",
|
|
33
|
+
Base.metadata,
|
|
34
|
+
Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
|
35
|
+
Column(
|
|
36
|
+
"user_id",
|
|
37
|
+
UUID(as_uuid=True),
|
|
38
|
+
ForeignKey("auth.users.id", ondelete="CASCADE"),
|
|
39
|
+
nullable=False,
|
|
40
|
+
),
|
|
41
|
+
Column(
|
|
42
|
+
"role_id",
|
|
43
|
+
UUID(as_uuid=True),
|
|
44
|
+
ForeignKey("rbac.roles.id", ondelete="CASCADE"),
|
|
45
|
+
nullable=False,
|
|
46
|
+
),
|
|
47
|
+
UniqueConstraint("user_id", "role_id", name="uq_user_roles_user_id_role_id"),
|
|
48
|
+
schema=RBAC_SCHEMA,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
role_permission_table = Table(
|
|
52
|
+
"role_permissions",
|
|
53
|
+
Base.metadata,
|
|
54
|
+
Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
|
55
|
+
Column(
|
|
56
|
+
"role_id",
|
|
57
|
+
UUID(as_uuid=True),
|
|
58
|
+
ForeignKey("rbac.roles.id", ondelete="CASCADE"),
|
|
59
|
+
nullable=False,
|
|
60
|
+
),
|
|
61
|
+
Column(
|
|
62
|
+
"permission_id",
|
|
63
|
+
UUID(as_uuid=True),
|
|
64
|
+
ForeignKey("rbac.permissions.id", ondelete="CASCADE"),
|
|
65
|
+
nullable=False,
|
|
66
|
+
),
|
|
67
|
+
UniqueConstraint(
|
|
68
|
+
"role_id",
|
|
69
|
+
"permission_id",
|
|
70
|
+
name="uq_role_permissions_role_id_permission_id",
|
|
71
|
+
),
|
|
72
|
+
schema=RBAC_SCHEMA,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
user_permission_table = Table(
|
|
76
|
+
"user_permissions",
|
|
77
|
+
Base.metadata,
|
|
78
|
+
Column("id", UUID(as_uuid=True), primary_key=True, default=uuid.uuid4),
|
|
79
|
+
Column(
|
|
80
|
+
"user_id",
|
|
81
|
+
UUID(as_uuid=True),
|
|
82
|
+
ForeignKey("auth.users.id", ondelete="CASCADE"),
|
|
83
|
+
nullable=False,
|
|
84
|
+
),
|
|
85
|
+
Column(
|
|
86
|
+
"permission_id",
|
|
87
|
+
UUID(as_uuid=True),
|
|
88
|
+
ForeignKey("rbac.permissions.id", ondelete="CASCADE"),
|
|
89
|
+
nullable=False,
|
|
90
|
+
),
|
|
91
|
+
UniqueConstraint(
|
|
92
|
+
"user_id",
|
|
93
|
+
"permission_id",
|
|
94
|
+
name="uq_user_permissions_user_id_permission_id",
|
|
95
|
+
),
|
|
96
|
+
schema=RBAC_SCHEMA,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Permission(BaseModelMixin, Base):
|
|
101
|
+
"""
|
|
102
|
+
A single capability code. Convention: '<resource>:<action>'.
|
|
103
|
+
Examples: 'users:read', 'invoices:write', 'admin:*'.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
__tablename__ = "permissions"
|
|
107
|
+
__table_args__ = {"schema": RBAC_SCHEMA}
|
|
108
|
+
|
|
109
|
+
code: Mapped[str] = mapped_column(
|
|
110
|
+
String(100), unique=True, nullable=False, index=True
|
|
111
|
+
)
|
|
112
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
113
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
114
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
115
|
+
|
|
116
|
+
roles: Mapped[list["Role"]] = relationship(
|
|
117
|
+
"Role",
|
|
118
|
+
secondary=role_permission_table,
|
|
119
|
+
back_populates="permissions",
|
|
120
|
+
)
|
|
121
|
+
users: Mapped[list["User"]] = relationship(
|
|
122
|
+
"User",
|
|
123
|
+
secondary=user_permission_table,
|
|
124
|
+
back_populates="extra_permissions",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
return f"<Permission code={self.code}>"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Role(BaseModelMixin, Base):
|
|
132
|
+
"""
|
|
133
|
+
A named set of permissions. Assign roles to users to grant their bundled
|
|
134
|
+
permissions. Individual permissions can still be added via user_permissions.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
__tablename__ = "roles"
|
|
138
|
+
__table_args__ = {"schema": RBAC_SCHEMA}
|
|
139
|
+
|
|
140
|
+
name: Mapped[str] = mapped_column(
|
|
141
|
+
String(100), unique=True, nullable=False, index=True
|
|
142
|
+
)
|
|
143
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
144
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
145
|
+
parent_role_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
146
|
+
UUID(as_uuid=True),
|
|
147
|
+
ForeignKey("rbac.roles.id", ondelete="SET NULL"),
|
|
148
|
+
nullable=True,
|
|
149
|
+
index=True,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
permissions: Mapped[list[Permission]] = relationship(
|
|
153
|
+
"Permission",
|
|
154
|
+
secondary=role_permission_table,
|
|
155
|
+
back_populates="roles",
|
|
156
|
+
lazy="selectin",
|
|
157
|
+
)
|
|
158
|
+
parent: Mapped["Role | None"] = relationship(
|
|
159
|
+
"Role",
|
|
160
|
+
remote_side="Role.id",
|
|
161
|
+
back_populates="children",
|
|
162
|
+
)
|
|
163
|
+
children: Mapped[list["Role"]] = relationship(
|
|
164
|
+
"Role",
|
|
165
|
+
back_populates="parent",
|
|
166
|
+
)
|
|
167
|
+
users: Mapped[list["User"]] = relationship(
|
|
168
|
+
"User",
|
|
169
|
+
secondary=user_role_table,
|
|
170
|
+
back_populates="roles",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
return f"<Role name={self.name}>"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DateTime, String
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
5
|
+
|
|
6
|
+
from app.db.base import Base
|
|
7
|
+
from app.db.schemas.common import BaseModelMixin
|
|
8
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RevokedToken(BaseModelMixin, Base):
|
|
12
|
+
__tablename__ = "revoked_tokens"
|
|
13
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
14
|
+
|
|
15
|
+
jti: Mapped[str] = mapped_column(
|
|
16
|
+
String(64), unique=True, nullable=False, index=True
|
|
17
|
+
)
|
|
18
|
+
token_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
19
|
+
expires_at: Mapped[datetime] = mapped_column(
|
|
20
|
+
DateTime(timezone=True), nullable=False
|
|
21
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Boolean, DateTime, String
|
|
4
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
5
|
+
|
|
6
|
+
from app.db.base import Base
|
|
7
|
+
from app.db.schemas.common import BaseModelMixin
|
|
8
|
+
from app.db.schemas.names import AUTH_SCHEMA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class User(BaseModelMixin, Base):
|
|
12
|
+
__tablename__ = "users"
|
|
13
|
+
__table_args__ = {"schema": AUTH_SCHEMA}
|
|
14
|
+
|
|
15
|
+
email: Mapped[str] = mapped_column(
|
|
16
|
+
String(255), unique=True, nullable=False, index=True
|
|
17
|
+
)
|
|
18
|
+
username: Mapped[str] = mapped_column(
|
|
19
|
+
String(100), unique=True, nullable=False, index=True
|
|
20
|
+
)
|
|
21
|
+
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
22
|
+
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
23
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
24
|
+
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
25
|
+
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
26
|
+
last_login_at: Mapped[datetime | None] = mapped_column(
|
|
27
|
+
DateTime(timezone=True), nullable=True
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
roles: Mapped[list["Role"]] = relationship(
|
|
31
|
+
"Role",
|
|
32
|
+
secondary="rbac.user_roles",
|
|
33
|
+
back_populates="users",
|
|
34
|
+
lazy="selectin",
|
|
35
|
+
)
|
|
36
|
+
extra_permissions: Mapped[list["Permission"]] = relationship(
|
|
37
|
+
"Permission",
|
|
38
|
+
secondary="rbac.user_permissions",
|
|
39
|
+
back_populates="users",
|
|
40
|
+
lazy="selectin",
|
|
41
|
+
)
|
|
42
|
+
notifications: Mapped[list["Notification"]] = relationship(
|
|
43
|
+
"Notification", back_populates="user", lazy="dynamic"
|
|
44
|
+
)
|
|
45
|
+
audit_logs: Mapped[list["AuditLog"]] = relationship(
|
|
46
|
+
"AuditLog", back_populates="user", lazy="dynamic"
|
|
47
|
+
)
|
|
48
|
+
sessions: Mapped[list["UserSession"]] = relationship(
|
|
49
|
+
"UserSession", back_populates="user", lazy="dynamic"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"<User id={self.id} email={self.email}>"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, Text, func
|
|
5
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
6
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utcnow():
|
|
10
|
+
return datetime.now(timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UUIDMixin:
|
|
14
|
+
"""Provides a UUID primary key."""
|
|
15
|
+
|
|
16
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
17
|
+
UUID(as_uuid=True),
|
|
18
|
+
primary_key=True,
|
|
19
|
+
default=uuid.uuid4,
|
|
20
|
+
index=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TimestampMixin:
|
|
25
|
+
"""Adds created_at / updated_at columns with automatic server defaults."""
|
|
26
|
+
|
|
27
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
28
|
+
DateTime(timezone=True),
|
|
29
|
+
server_default=func.now(),
|
|
30
|
+
nullable=False,
|
|
31
|
+
)
|
|
32
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
33
|
+
DateTime(timezone=True),
|
|
34
|
+
server_default=func.now(),
|
|
35
|
+
onupdate=func.now(),
|
|
36
|
+
nullable=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SoftDeleteMixin:
|
|
41
|
+
"""
|
|
42
|
+
Provides soft-delete support.
|
|
43
|
+
- is_deleted=True → soft-deleted (row hidden from normal queries)
|
|
44
|
+
- deleted_at → timestamp of soft-deletion
|
|
45
|
+
Call .hard_delete() on the session to permanently remove the row.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
is_deleted: Mapped[bool] = mapped_column(
|
|
49
|
+
Boolean, default=False, nullable=False, index=True
|
|
50
|
+
)
|
|
51
|
+
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
52
|
+
DateTime(timezone=True), nullable=True
|
|
53
|
+
)
|
|
54
|
+
deletion_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
55
|
+
|
|
56
|
+
def soft_delete(self, note: str | None = None) -> None:
|
|
57
|
+
self.is_deleted = True
|
|
58
|
+
self.deleted_at = utcnow()
|
|
59
|
+
self.deletion_note = note
|
|
60
|
+
|
|
61
|
+
def restore(self) -> None:
|
|
62
|
+
self.is_deleted = False
|
|
63
|
+
self.deleted_at = None
|
|
64
|
+
self.deletion_note = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaseModelMixin(UUIDMixin, TimestampMixin, SoftDeleteMixin):
|
|
68
|
+
"""Convenience mixin that combines UUID PK, timestamps, and soft-delete."""
|
|
69
|
+
|
|
70
|
+
pass
|