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,86 @@
|
|
|
1
|
+
from sqlalchemy import create_engine, text
|
|
2
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
3
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
4
|
+
|
|
5
|
+
from app.core.config import settings
|
|
6
|
+
from app.core.logging import get_logger
|
|
7
|
+
from app.db.base import Base
|
|
8
|
+
from app.db.models import *
|
|
9
|
+
from app.db.schemas.names import ALL_SCHEMAS
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
async_engine = create_async_engine(
|
|
14
|
+
settings.postgres_async_url,
|
|
15
|
+
echo=settings.database_debug,
|
|
16
|
+
pool_size=settings.database_pool_size,
|
|
17
|
+
max_overflow=settings.database_max_overflow,
|
|
18
|
+
pool_timeout=settings.database_pool_timeout,
|
|
19
|
+
pool_pre_ping=True,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
23
|
+
bind=async_engine,
|
|
24
|
+
class_=AsyncSession,
|
|
25
|
+
autoflush=False,
|
|
26
|
+
autocommit=False,
|
|
27
|
+
expire_on_commit=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
sync_engine = create_engine(
|
|
31
|
+
settings.postgres_url,
|
|
32
|
+
echo=settings.database_debug,
|
|
33
|
+
pool_size=settings.database_pool_size,
|
|
34
|
+
max_overflow=settings.database_max_overflow,
|
|
35
|
+
pool_timeout=settings.database_pool_timeout,
|
|
36
|
+
pool_pre_ping=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
SyncSessionLocal: sessionmaker[Session] = sessionmaker(
|
|
40
|
+
bind=sync_engine,
|
|
41
|
+
class_=Session,
|
|
42
|
+
autoflush=False,
|
|
43
|
+
autocommit=False,
|
|
44
|
+
expire_on_commit=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
engine = sync_engine
|
|
48
|
+
SessionLocal = SyncSessionLocal
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_db_and_tables() -> None:
|
|
52
|
+
with sync_engine.begin() as connection:
|
|
53
|
+
for schema in ALL_SCHEMAS:
|
|
54
|
+
connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}"))
|
|
55
|
+
Base.metadata.create_all(bind=connection)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_session():
|
|
59
|
+
db = SessionLocal()
|
|
60
|
+
try:
|
|
61
|
+
yield db
|
|
62
|
+
finally:
|
|
63
|
+
db.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def check_db_health() -> dict:
|
|
67
|
+
try:
|
|
68
|
+
async with AsyncSessionLocal() as session:
|
|
69
|
+
await session.execute(text("SELECT 1"))
|
|
70
|
+
return {"status": "healthy", "database": "connected"}
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.error("Database health check failed", error=str(exc))
|
|
73
|
+
return {"status": "unhealthy", "database": "disconnected", "error": str(exc)}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def connect_db() -> None:
|
|
77
|
+
result = await check_db_health()
|
|
78
|
+
if result["status"] != "healthy":
|
|
79
|
+
raise RuntimeError("Database connection failed on startup")
|
|
80
|
+
logger.info("Database connected successfully")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def disconnect_db() -> None:
|
|
84
|
+
await async_engine.dispose()
|
|
85
|
+
sync_engine.dispose()
|
|
86
|
+
logger.info("Database connections closed")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from fastapi import Query
|
|
2
|
+
|
|
3
|
+
from app.core.config import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_pagination_offset(page: int, page_size: int) -> int:
|
|
7
|
+
return (page - 1) * page_size
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_pagination(query, page: int, page_size: int, enabled: bool = True):
|
|
11
|
+
if not enabled:
|
|
12
|
+
return query
|
|
13
|
+
return query.offset(get_pagination_offset(page, page_size)).limit(page_size)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PaginationParams:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
page: int = Query(default=1, ge=1),
|
|
20
|
+
page_size: int = Query(
|
|
21
|
+
default=settings.default_page_size,
|
|
22
|
+
ge=1,
|
|
23
|
+
le=settings.max_page_size,
|
|
24
|
+
alias="pageSize",
|
|
25
|
+
),
|
|
26
|
+
pagination: bool = Query(default=True),
|
|
27
|
+
search: str | None = Query(default=None),
|
|
28
|
+
sort_by: str | None = Query(default=None, alias="sortBy"),
|
|
29
|
+
sort_order: str | None = Query(default="asc", alias="sortOrder"),
|
|
30
|
+
):
|
|
31
|
+
self.page = page
|
|
32
|
+
self.page_size = page_size
|
|
33
|
+
self.pagination = pagination
|
|
34
|
+
self.search = search
|
|
35
|
+
self.sort_by = sort_by
|
|
36
|
+
self.sort_order = sort_order
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def offset(self) -> int:
|
|
40
|
+
return get_pagination_offset(self.page, self.page_size)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def limit(self) -> int:
|
|
44
|
+
return self.page_size
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from sqlalchemy import Text, and_, or_
|
|
2
|
+
from sqlalchemy.sql.sqltypes import String
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
DEFAULT_EXCLUDED_FIELDS = {
|
|
6
|
+
"hashed_password",
|
|
7
|
+
"request_sql",
|
|
8
|
+
"request_params_json",
|
|
9
|
+
"response_json",
|
|
10
|
+
"raw_response_json",
|
|
11
|
+
"coverages_json",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text_search_filter(model, search: str | None, columns=None, name_columns=None):
|
|
16
|
+
query = (search or "").strip()
|
|
17
|
+
if not query:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
fields = columns or [
|
|
21
|
+
column
|
|
22
|
+
for column in model.__table__.columns
|
|
23
|
+
if isinstance(column.type, (String, Text))
|
|
24
|
+
and column.key not in DEFAULT_EXCLUDED_FIELDS
|
|
25
|
+
]
|
|
26
|
+
expressions = []
|
|
27
|
+
if fields:
|
|
28
|
+
like = f"%{query}%"
|
|
29
|
+
expressions.extend(field.ilike(like) for field in fields)
|
|
30
|
+
|
|
31
|
+
if name_columns:
|
|
32
|
+
first_name_column, last_name_column = name_columns
|
|
33
|
+
parts = query.split(maxsplit=1)
|
|
34
|
+
if len(parts) == 2:
|
|
35
|
+
expressions.extend(
|
|
36
|
+
[
|
|
37
|
+
and_(
|
|
38
|
+
first_name_column.ilike(f"%{parts[0]}%"),
|
|
39
|
+
last_name_column.ilike(f"%{parts[1]}%"),
|
|
40
|
+
),
|
|
41
|
+
and_(
|
|
42
|
+
first_name_column.ilike(f"%{parts[1]}%"),
|
|
43
|
+
last_name_column.ilike(f"%{parts[0]}%"),
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not expressions:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
return or_(*expressions)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi import HTTPException
|
|
5
|
+
from sqlalchemy import inspect
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_snake_case(value: str) -> str:
|
|
9
|
+
value = value.strip()
|
|
10
|
+
value = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value)
|
|
11
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sort_expressions(
|
|
15
|
+
model: Any,
|
|
16
|
+
sort_by: str | None,
|
|
17
|
+
sort_order: str | None,
|
|
18
|
+
default_order: tuple[Any, ...] = (),
|
|
19
|
+
) -> tuple[Any, ...]:
|
|
20
|
+
if not sort_by:
|
|
21
|
+
return default_order
|
|
22
|
+
|
|
23
|
+
field = to_snake_case(sort_by)
|
|
24
|
+
columns = inspect(model).columns
|
|
25
|
+
if field not in columns:
|
|
26
|
+
raise HTTPException(status_code=422, detail=f"Invalid sortBy '{sort_by}'")
|
|
27
|
+
|
|
28
|
+
column = columns[field]
|
|
29
|
+
direction = _normalize_sort_order(sort_order)
|
|
30
|
+
return (column.desc() if direction == "desc" else column.asc(),)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sort_items(
|
|
34
|
+
items: list[Any], sort_by: str | None, sort_order: str | None
|
|
35
|
+
) -> list[Any]:
|
|
36
|
+
if not sort_by:
|
|
37
|
+
return items
|
|
38
|
+
|
|
39
|
+
field = to_snake_case(sort_by)
|
|
40
|
+
direction = _normalize_sort_order(sort_order)
|
|
41
|
+
|
|
42
|
+
if items:
|
|
43
|
+
first = items[0]
|
|
44
|
+
if isinstance(first, dict):
|
|
45
|
+
if sort_by not in first and field not in first:
|
|
46
|
+
raise HTTPException(
|
|
47
|
+
status_code=422,
|
|
48
|
+
detail=f"Invalid sortBy '{sort_by}'",
|
|
49
|
+
)
|
|
50
|
+
elif not hasattr(first, field):
|
|
51
|
+
raise HTTPException(status_code=422, detail=f"Invalid sortBy '{sort_by}'")
|
|
52
|
+
|
|
53
|
+
return sorted(
|
|
54
|
+
items,
|
|
55
|
+
key=lambda item: _item_value(item, sort_by, field),
|
|
56
|
+
reverse=direction == "desc",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_sort_order(sort_order: str | None) -> str:
|
|
61
|
+
value = (sort_order or "asc").lower()
|
|
62
|
+
if value not in {"asc", "desc"}:
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=422,
|
|
65
|
+
detail="sortOrder must be either 'asc' or 'desc'",
|
|
66
|
+
)
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _item_value(item, sort_by: str, field: str):
|
|
71
|
+
if isinstance(item, dict):
|
|
72
|
+
value = item.get(sort_by)
|
|
73
|
+
if value is None:
|
|
74
|
+
value = item.get(field)
|
|
75
|
+
else:
|
|
76
|
+
value = getattr(item, field, None)
|
|
77
|
+
return (value is None, value)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.responses import JSONResponse
|
|
5
|
+
from slowapi.errors import RateLimitExceeded
|
|
6
|
+
|
|
7
|
+
from app.api.v1.api import register_api_routes
|
|
8
|
+
from app.core.config import settings
|
|
9
|
+
from app.core.exceptions import register_exception_handlers
|
|
10
|
+
from app.core.logging import configure_logging, get_logger
|
|
11
|
+
from app.core.middleware import limiter, register_middleware
|
|
12
|
+
from app.core.responses import error_response
|
|
13
|
+
from app.db.session import connect_db, disconnect_db
|
|
14
|
+
|
|
15
|
+
configure_logging()
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def lifespan(app: FastAPI):
|
|
21
|
+
logger.info("Starting application", name=settings.APP_NAME, env=settings.APP_ENV)
|
|
22
|
+
await connect_db()
|
|
23
|
+
yield
|
|
24
|
+
await disconnect_db()
|
|
25
|
+
logger.info("Application shutdown complete")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
app = FastAPI(
|
|
29
|
+
title=settings.APP_NAME,
|
|
30
|
+
version=settings.APP_VERSION,
|
|
31
|
+
description="FastAPI boilerplate with JWT authentication, RBAC, and audit logs.",
|
|
32
|
+
docs_url="/docs" if not settings.is_production else None,
|
|
33
|
+
redoc_url="/redoc" if not settings.is_production else None,
|
|
34
|
+
openapi_url="/openapi.json" if not settings.is_production else None,
|
|
35
|
+
lifespan=lifespan,
|
|
36
|
+
swagger_ui_parameters={"docExpansion": "none"},
|
|
37
|
+
)
|
|
38
|
+
app.state.limiter = limiter
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.exception_handler(RateLimitExceeded)
|
|
42
|
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
|
|
43
|
+
return JSONResponse(
|
|
44
|
+
status_code=429,
|
|
45
|
+
content=error_response(
|
|
46
|
+
message="Too many requests. Please slow down.",
|
|
47
|
+
error_code="RATE_LIMIT_EXCEEDED",
|
|
48
|
+
request_id=getattr(request.state, "request_id", None),
|
|
49
|
+
).model_dump(by_alias=True),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
register_middleware(app)
|
|
54
|
+
register_exception_handlers(app)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/", tags=["Root"])
|
|
58
|
+
async def root():
|
|
59
|
+
return {
|
|
60
|
+
"message": f"Welcome to {settings.APP_NAME}",
|
|
61
|
+
"version": settings.APP_VERSION,
|
|
62
|
+
"docs": None if settings.is_production else "/docs",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
register_api_routes(app)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Select, func, select
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from app.db.base import Base
|
|
9
|
+
from app.helper.search import text_search_filter
|
|
10
|
+
from app.helper.pagination_helper import apply_pagination
|
|
11
|
+
from app.helper.sorting import sort_expressions
|
|
12
|
+
|
|
13
|
+
ModelT = TypeVar("ModelT", bound=Base)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseRepository(Generic[ModelT]):
|
|
17
|
+
def __init__(self, model: Type[ModelT], session: AsyncSession):
|
|
18
|
+
self.model = model
|
|
19
|
+
self.session = session
|
|
20
|
+
|
|
21
|
+
def _active_query(self) -> Select:
|
|
22
|
+
q = select(self.model)
|
|
23
|
+
if hasattr(self.model, "is_deleted"):
|
|
24
|
+
q = q.where(self.model.is_deleted == False)
|
|
25
|
+
return q
|
|
26
|
+
|
|
27
|
+
async def create(self, data: Dict[str, Any]) -> ModelT:
|
|
28
|
+
instance = self.model(**data)
|
|
29
|
+
self.session.add(instance)
|
|
30
|
+
await self.session.flush()
|
|
31
|
+
await self.session.refresh(instance)
|
|
32
|
+
return instance
|
|
33
|
+
|
|
34
|
+
async def get_by_id(
|
|
35
|
+
self, id: UUID, include_deleted: bool = False
|
|
36
|
+
) -> Optional[ModelT]:
|
|
37
|
+
q = select(self.model).where(self.model.id == id)
|
|
38
|
+
if not include_deleted and hasattr(self.model, "is_deleted"):
|
|
39
|
+
q = q.where(self.model.is_deleted == False)
|
|
40
|
+
result = await self.session.execute(q)
|
|
41
|
+
return result.scalar_one_or_none()
|
|
42
|
+
|
|
43
|
+
async def get_by_field(self, field: str, value: Any) -> Optional[ModelT]:
|
|
44
|
+
q = self._active_query().where(getattr(self.model, field) == value)
|
|
45
|
+
result = await self.session.execute(q)
|
|
46
|
+
return result.scalar_one_or_none()
|
|
47
|
+
|
|
48
|
+
async def get_all(
|
|
49
|
+
self,
|
|
50
|
+
page: int = 1,
|
|
51
|
+
page_size: int = 20,
|
|
52
|
+
pagination: bool = True,
|
|
53
|
+
filters: Optional[List] = None,
|
|
54
|
+
search: str | None = None,
|
|
55
|
+
search_columns: Optional[List] = None,
|
|
56
|
+
order_by=None,
|
|
57
|
+
sort_by: str | None = None,
|
|
58
|
+
sort_order: str | None = None,
|
|
59
|
+
) -> Tuple[List[ModelT], int]:
|
|
60
|
+
q = self._active_query()
|
|
61
|
+
if filters:
|
|
62
|
+
q = q.where(*filters)
|
|
63
|
+
search_filter = text_search_filter(self.model, search, search_columns)
|
|
64
|
+
if search_filter is not None:
|
|
65
|
+
q = q.where(search_filter)
|
|
66
|
+
if isinstance(order_by, (list, tuple)):
|
|
67
|
+
default_order = tuple(order_by)
|
|
68
|
+
else:
|
|
69
|
+
default_order = (order_by,) if order_by is not None else ()
|
|
70
|
+
if not default_order and hasattr(self.model, "created_at"):
|
|
71
|
+
default_order = (self.model.created_at.desc(),)
|
|
72
|
+
order = sort_expressions(self.model, sort_by, sort_order, default_order)
|
|
73
|
+
if order:
|
|
74
|
+
q = q.order_by(*order)
|
|
75
|
+
|
|
76
|
+
count_q = select(func.count()).select_from(q.subquery())
|
|
77
|
+
total = (await self.session.execute(count_q)).scalar_one()
|
|
78
|
+
|
|
79
|
+
q = apply_pagination(q, page, page_size, pagination)
|
|
80
|
+
|
|
81
|
+
result = await self.session.execute(q)
|
|
82
|
+
return list(result.scalars().all()), total
|
|
83
|
+
|
|
84
|
+
async def get_deleted(
|
|
85
|
+
self,
|
|
86
|
+
page: int = 1,
|
|
87
|
+
page_size: int = 20,
|
|
88
|
+
pagination: bool = True,
|
|
89
|
+
filters: Optional[List] = None,
|
|
90
|
+
search: str | None = None,
|
|
91
|
+
search_columns: Optional[List] = None,
|
|
92
|
+
order_by=None,
|
|
93
|
+
sort_by: str | None = None,
|
|
94
|
+
sort_order: str | None = None,
|
|
95
|
+
) -> Tuple[List[ModelT], int]:
|
|
96
|
+
if not hasattr(self.model, "is_deleted"):
|
|
97
|
+
return [], 0
|
|
98
|
+
|
|
99
|
+
q = select(self.model).where(self.model.is_deleted == True)
|
|
100
|
+
if filters:
|
|
101
|
+
q = q.where(*filters)
|
|
102
|
+
search_filter = text_search_filter(self.model, search, search_columns)
|
|
103
|
+
if search_filter is not None:
|
|
104
|
+
q = q.where(search_filter)
|
|
105
|
+
if isinstance(order_by, (list, tuple)):
|
|
106
|
+
default_order = tuple(order_by)
|
|
107
|
+
else:
|
|
108
|
+
default_order = (order_by,) if order_by is not None else ()
|
|
109
|
+
if not default_order and hasattr(self.model, "deleted_at"):
|
|
110
|
+
default_order = (self.model.deleted_at.desc(),)
|
|
111
|
+
order = sort_expressions(self.model, sort_by, sort_order, default_order)
|
|
112
|
+
if order:
|
|
113
|
+
q = q.order_by(*order)
|
|
114
|
+
|
|
115
|
+
count_q = select(func.count()).select_from(q.subquery())
|
|
116
|
+
total = (await self.session.execute(count_q)).scalar_one()
|
|
117
|
+
|
|
118
|
+
q = apply_pagination(q, page, page_size, pagination)
|
|
119
|
+
|
|
120
|
+
result = await self.session.execute(q)
|
|
121
|
+
return list(result.scalars().all()), total
|
|
122
|
+
|
|
123
|
+
async def update(self, instance: ModelT, data: Dict[str, Any]) -> ModelT:
|
|
124
|
+
for key, value in data.items():
|
|
125
|
+
if hasattr(instance, key):
|
|
126
|
+
setattr(instance, key, value)
|
|
127
|
+
self.session.add(instance)
|
|
128
|
+
await self.session.flush()
|
|
129
|
+
await self.session.refresh(instance)
|
|
130
|
+
return instance
|
|
131
|
+
|
|
132
|
+
async def soft_delete(self, instance: ModelT, note: str) -> ModelT:
|
|
133
|
+
if not hasattr(instance, "soft_delete"):
|
|
134
|
+
raise AttributeError(f"{self.model.__name__} does not support soft delete")
|
|
135
|
+
instance.soft_delete(note)
|
|
136
|
+
self.session.add(instance)
|
|
137
|
+
await self.session.flush()
|
|
138
|
+
return instance
|
|
139
|
+
|
|
140
|
+
async def restore(self, instance: ModelT) -> ModelT:
|
|
141
|
+
if not hasattr(instance, "restore"):
|
|
142
|
+
raise AttributeError(f"{self.model.__name__} does not support restore")
|
|
143
|
+
instance.restore()
|
|
144
|
+
self.session.add(instance)
|
|
145
|
+
await self.session.flush()
|
|
146
|
+
return instance
|
|
147
|
+
|
|
148
|
+
async def hard_delete(self, instance: ModelT, note: str) -> None:
|
|
149
|
+
if hasattr(instance, "deletion_note"):
|
|
150
|
+
instance.deletion_note = note
|
|
151
|
+
self.session.add(instance)
|
|
152
|
+
await self.session.flush()
|
|
153
|
+
await self.session.delete(instance)
|
|
154
|
+
await self.session.flush()
|
|
155
|
+
|
|
156
|
+
async def exists(self, field: str, value: Any) -> bool:
|
|
157
|
+
q = (
|
|
158
|
+
select(func.count())
|
|
159
|
+
.select_from(self.model)
|
|
160
|
+
.where(getattr(self.model, field) == value)
|
|
161
|
+
)
|
|
162
|
+
count = (await self.session.execute(q)).scalar_one()
|
|
163
|
+
return count > 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SyncBaseRepository(Generic[ModelT]):
|
|
167
|
+
def __init__(self, model: Type[ModelT], session: Session):
|
|
168
|
+
self.model = model
|
|
169
|
+
self.session = session
|
|
170
|
+
|
|
171
|
+
def _active_query(self) -> Select:
|
|
172
|
+
q = select(self.model)
|
|
173
|
+
if hasattr(self.model, "is_deleted"):
|
|
174
|
+
q = q.where(self.model.is_deleted == False)
|
|
175
|
+
return q
|
|
176
|
+
|
|
177
|
+
def create(self, data: Dict[str, Any]) -> ModelT:
|
|
178
|
+
instance = self.model(**data)
|
|
179
|
+
self.session.add(instance)
|
|
180
|
+
self.session.flush()
|
|
181
|
+
self.session.refresh(instance)
|
|
182
|
+
return instance
|
|
183
|
+
|
|
184
|
+
def get_by_id(self, id: UUID, include_deleted: bool = False) -> Optional[ModelT]:
|
|
185
|
+
q = select(self.model).where(self.model.id == id)
|
|
186
|
+
if not include_deleted and hasattr(self.model, "is_deleted"):
|
|
187
|
+
q = q.where(self.model.is_deleted == False)
|
|
188
|
+
return self.session.execute(q).scalar_one_or_none()
|
|
189
|
+
|
|
190
|
+
def get_by_field(self, field: str, value: Any) -> Optional[ModelT]:
|
|
191
|
+
q = self._active_query().where(getattr(self.model, field) == value)
|
|
192
|
+
return self.session.execute(q).scalar_one_or_none()
|
|
193
|
+
|
|
194
|
+
def get_all(
|
|
195
|
+
self,
|
|
196
|
+
page: int = 1,
|
|
197
|
+
page_size: int = 20,
|
|
198
|
+
pagination: bool = True,
|
|
199
|
+
filters: Optional[List] = None,
|
|
200
|
+
search: str | None = None,
|
|
201
|
+
search_columns: Optional[List] = None,
|
|
202
|
+
order_by=None,
|
|
203
|
+
sort_by: str | None = None,
|
|
204
|
+
sort_order: str | None = None,
|
|
205
|
+
) -> Tuple[List[ModelT], int]:
|
|
206
|
+
q = self._active_query()
|
|
207
|
+
if filters:
|
|
208
|
+
q = q.where(*filters)
|
|
209
|
+
search_filter = text_search_filter(self.model, search, search_columns)
|
|
210
|
+
if search_filter is not None:
|
|
211
|
+
q = q.where(search_filter)
|
|
212
|
+
if isinstance(order_by, (list, tuple)):
|
|
213
|
+
default_order = tuple(order_by)
|
|
214
|
+
else:
|
|
215
|
+
default_order = (order_by,) if order_by is not None else ()
|
|
216
|
+
if not default_order and hasattr(self.model, "created_at"):
|
|
217
|
+
default_order = (self.model.created_at.desc(),)
|
|
218
|
+
order = sort_expressions(self.model, sort_by, sort_order, default_order)
|
|
219
|
+
if order:
|
|
220
|
+
q = q.order_by(*order)
|
|
221
|
+
|
|
222
|
+
total = self.session.execute(
|
|
223
|
+
select(func.count()).select_from(q.subquery())
|
|
224
|
+
).scalar_one()
|
|
225
|
+
|
|
226
|
+
q = apply_pagination(q, page, page_size, pagination)
|
|
227
|
+
|
|
228
|
+
return list(self.session.execute(q).scalars().all()), total
|
|
229
|
+
|
|
230
|
+
def get_all_for_model(
|
|
231
|
+
self,
|
|
232
|
+
model: Type[ModelT],
|
|
233
|
+
page: int = 1,
|
|
234
|
+
page_size: int = 20,
|
|
235
|
+
pagination: bool = True,
|
|
236
|
+
filters: Optional[List] = None,
|
|
237
|
+
search: str | None = None,
|
|
238
|
+
search_columns: Optional[List] = None,
|
|
239
|
+
order_by=None,
|
|
240
|
+
sort_by: str | None = None,
|
|
241
|
+
sort_order: str | None = None,
|
|
242
|
+
) -> Tuple[List[ModelT], int]:
|
|
243
|
+
q = select(model)
|
|
244
|
+
if hasattr(model, "is_deleted"):
|
|
245
|
+
q = q.where(model.is_deleted == False)
|
|
246
|
+
if filters:
|
|
247
|
+
q = q.where(*filters)
|
|
248
|
+
search_filter = text_search_filter(model, search, search_columns)
|
|
249
|
+
if search_filter is not None:
|
|
250
|
+
q = q.where(search_filter)
|
|
251
|
+
if isinstance(order_by, (list, tuple)):
|
|
252
|
+
default_order = tuple(order_by)
|
|
253
|
+
else:
|
|
254
|
+
default_order = (order_by,) if order_by is not None else ()
|
|
255
|
+
if not default_order and hasattr(model, "created_at"):
|
|
256
|
+
default_order = (model.created_at.desc(),)
|
|
257
|
+
order = sort_expressions(model, sort_by, sort_order, default_order)
|
|
258
|
+
if order:
|
|
259
|
+
q = q.order_by(*order)
|
|
260
|
+
|
|
261
|
+
total = self.session.execute(
|
|
262
|
+
select(func.count()).select_from(q.subquery())
|
|
263
|
+
).scalar_one()
|
|
264
|
+
|
|
265
|
+
q = apply_pagination(q, page, page_size, pagination)
|
|
266
|
+
|
|
267
|
+
return list(self.session.execute(q).scalars().all()), total
|
|
268
|
+
|
|
269
|
+
def get_deleted(
|
|
270
|
+
self,
|
|
271
|
+
page: int = 1,
|
|
272
|
+
page_size: int = 20,
|
|
273
|
+
pagination: bool = True,
|
|
274
|
+
filters: Optional[List] = None,
|
|
275
|
+
search: str | None = None,
|
|
276
|
+
search_columns: Optional[List] = None,
|
|
277
|
+
order_by=None,
|
|
278
|
+
sort_by: str | None = None,
|
|
279
|
+
sort_order: str | None = None,
|
|
280
|
+
) -> Tuple[List[ModelT], int]:
|
|
281
|
+
if not hasattr(self.model, "is_deleted"):
|
|
282
|
+
return [], 0
|
|
283
|
+
|
|
284
|
+
q = select(self.model).where(self.model.is_deleted == True)
|
|
285
|
+
if filters:
|
|
286
|
+
q = q.where(*filters)
|
|
287
|
+
search_filter = text_search_filter(self.model, search, search_columns)
|
|
288
|
+
if search_filter is not None:
|
|
289
|
+
q = q.where(search_filter)
|
|
290
|
+
if isinstance(order_by, (list, tuple)):
|
|
291
|
+
default_order = tuple(order_by)
|
|
292
|
+
else:
|
|
293
|
+
default_order = (order_by,) if order_by is not None else ()
|
|
294
|
+
if not default_order and hasattr(self.model, "deleted_at"):
|
|
295
|
+
default_order = (self.model.deleted_at.desc(),)
|
|
296
|
+
order = sort_expressions(self.model, sort_by, sort_order, default_order)
|
|
297
|
+
if order:
|
|
298
|
+
q = q.order_by(*order)
|
|
299
|
+
|
|
300
|
+
total = self.session.execute(
|
|
301
|
+
select(func.count()).select_from(q.subquery())
|
|
302
|
+
).scalar_one()
|
|
303
|
+
|
|
304
|
+
q = apply_pagination(q, page, page_size, pagination)
|
|
305
|
+
|
|
306
|
+
return list(self.session.execute(q).scalars().all()), total
|
|
307
|
+
|
|
308
|
+
def update(self, instance: ModelT, data: Dict[str, Any]) -> ModelT:
|
|
309
|
+
for key, value in data.items():
|
|
310
|
+
if hasattr(instance, key):
|
|
311
|
+
setattr(instance, key, value)
|
|
312
|
+
self.session.add(instance)
|
|
313
|
+
self.session.flush()
|
|
314
|
+
self.session.refresh(instance)
|
|
315
|
+
return instance
|
|
316
|
+
|
|
317
|
+
def soft_delete(self, instance: ModelT, note: str) -> ModelT:
|
|
318
|
+
if not hasattr(instance, "soft_delete"):
|
|
319
|
+
raise AttributeError(f"{self.model.__name__} does not support soft delete")
|
|
320
|
+
instance.soft_delete(note)
|
|
321
|
+
self.session.add(instance)
|
|
322
|
+
self.session.flush()
|
|
323
|
+
return instance
|
|
324
|
+
|
|
325
|
+
def restore(self, instance: ModelT) -> ModelT:
|
|
326
|
+
if not hasattr(instance, "restore"):
|
|
327
|
+
raise AttributeError(f"{self.model.__name__} does not support restore")
|
|
328
|
+
instance.restore()
|
|
329
|
+
self.session.add(instance)
|
|
330
|
+
self.session.flush()
|
|
331
|
+
return instance
|
|
332
|
+
|
|
333
|
+
def hard_delete(self, instance: ModelT, note: str) -> None:
|
|
334
|
+
if hasattr(instance, "deletion_note"):
|
|
335
|
+
instance.deletion_note = note
|
|
336
|
+
self.session.add(instance)
|
|
337
|
+
self.session.flush()
|
|
338
|
+
self.session.delete(instance)
|
|
339
|
+
self.session.flush()
|
|
340
|
+
|
|
341
|
+
def exists(self, field: str, value: Any) -> bool:
|
|
342
|
+
count = self.session.execute(
|
|
343
|
+
select(func.count())
|
|
344
|
+
.select_from(self.model)
|
|
345
|
+
.where(getattr(self.model, field) == value)
|
|
346
|
+
).scalar_one()
|
|
347
|
+
return count > 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|