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,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
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
5
|
+
|
|
6
|
+
from app.core.logging import get_logger
|
|
7
|
+
from app.db.models.audit_log import AuditLog
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditService:
|
|
13
|
+
def __init__(self, session: AsyncSession):
|
|
14
|
+
self.session = session
|
|
15
|
+
|
|
16
|
+
async def log(
|
|
17
|
+
self,
|
|
18
|
+
action: str,
|
|
19
|
+
resource: str,
|
|
20
|
+
resource_id: Optional[str] = None,
|
|
21
|
+
user_id: Optional[str | UUID] = None,
|
|
22
|
+
user_email: Optional[str] = None,
|
|
23
|
+
old_values: Optional[dict] = None,
|
|
24
|
+
new_values: Optional[dict] = None,
|
|
25
|
+
metadata: Optional[dict] = None,
|
|
26
|
+
ip_address: Optional[str] = None,
|
|
27
|
+
user_agent: Optional[str] = None,
|
|
28
|
+
request_id: Optional[str] = None,
|
|
29
|
+
) -> AuditLog:
|
|
30
|
+
"""
|
|
31
|
+
Append a single audit log entry. All arguments are optional except
|
|
32
|
+
action and resource so callers can provide as much or as little
|
|
33
|
+
context as they have.
|
|
34
|
+
"""
|
|
35
|
+
entry = AuditLog(
|
|
36
|
+
action=action,
|
|
37
|
+
resource=resource,
|
|
38
|
+
resource_id=resource_id,
|
|
39
|
+
user_id=UUID(str(user_id)) if user_id else None,
|
|
40
|
+
user_email=user_email,
|
|
41
|
+
old_values=old_values,
|
|
42
|
+
new_values=new_values,
|
|
43
|
+
log_metadata=metadata,
|
|
44
|
+
ip_address=ip_address,
|
|
45
|
+
user_agent=user_agent,
|
|
46
|
+
request_id=request_id,
|
|
47
|
+
)
|
|
48
|
+
self.session.add(entry)
|
|
49
|
+
await self.session.flush()
|
|
50
|
+
|
|
51
|
+
logger.debug(
|
|
52
|
+
"Audit log written",
|
|
53
|
+
action=action,
|
|
54
|
+
resource=resource,
|
|
55
|
+
resource_id=resource_id,
|
|
56
|
+
user_id=str(user_id) if user_id else None,
|
|
57
|
+
)
|
|
58
|
+
return entry
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
|
|
6
|
+
from pydantic import EmailStr
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from app.core.config import settings
|
|
10
|
+
from app.core.logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
# ── FastMail configuration ───────────────────────────────────────
|
|
15
|
+
_mail_config = ConnectionConfig(
|
|
16
|
+
MAIL_USERNAME=settings.mail_username,
|
|
17
|
+
MAIL_PASSWORD=settings.mail_password,
|
|
18
|
+
MAIL_FROM=settings.mail_from,
|
|
19
|
+
MAIL_PORT=settings.mail_port,
|
|
20
|
+
MAIL_SERVER=settings.mail_server,
|
|
21
|
+
MAIL_STARTTLS=settings.mail_starttls,
|
|
22
|
+
MAIL_SSL_TLS=settings.mail_ssl_tls,
|
|
23
|
+
MAIL_FROM_NAME=settings.mail_from_name,
|
|
24
|
+
TEMPLATE_FOLDER=Path(__file__).parent.parent / "templates" / "email",
|
|
25
|
+
USE_CREDENTIALS=True,
|
|
26
|
+
VALIDATE_CERTS=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_fast_mail = FastMail(_mail_config)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EmailService:
|
|
33
|
+
async def _send(
|
|
34
|
+
self,
|
|
35
|
+
recipients: List[EmailStr],
|
|
36
|
+
subject: str,
|
|
37
|
+
template_name: str,
|
|
38
|
+
template_body: Dict[str, Any],
|
|
39
|
+
) -> None:
|
|
40
|
+
message = MessageSchema(
|
|
41
|
+
subject=subject,
|
|
42
|
+
recipients=recipients,
|
|
43
|
+
template_body=template_body,
|
|
44
|
+
subtype=MessageType.html,
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
await _fast_mail.send_message(message, template_name=template_name)
|
|
48
|
+
logger.info("Email sent", subject=subject, recipients=recipients)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
logger.error("Email send failed", subject=subject, error=str(exc))
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
# ── Public send helpers ──────────────────────────────────────
|
|
54
|
+
async def send_welcome_email(self, to_email: str, full_name: str) -> None:
|
|
55
|
+
await self._send(
|
|
56
|
+
recipients=[to_email],
|
|
57
|
+
subject=f"Welcome to {settings.APP_NAME}!",
|
|
58
|
+
template_name="welcome.html",
|
|
59
|
+
template_body={"full_name": full_name, "app_name": settings.APP_NAME},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def send_password_reset_email(self, to_email: str, reset_link: str) -> None:
|
|
63
|
+
await self._send(
|
|
64
|
+
recipients=[to_email],
|
|
65
|
+
subject="Reset your password",
|
|
66
|
+
template_name="password_reset.html",
|
|
67
|
+
template_body={"reset_link": reset_link, "app_name": settings.APP_NAME},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def send_email_verification(self, to_email: str, verify_link: str) -> None:
|
|
71
|
+
await self._send(
|
|
72
|
+
recipients=[to_email],
|
|
73
|
+
subject="Verify your email address",
|
|
74
|
+
template_name="verify_email.html",
|
|
75
|
+
template_body={"verify_link": verify_link, "app_name": settings.APP_NAME},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
async def send_notification_email(
|
|
79
|
+
self,
|
|
80
|
+
to_user_id: UUID,
|
|
81
|
+
subject: str,
|
|
82
|
+
body: str,
|
|
83
|
+
session: AsyncSession,
|
|
84
|
+
) -> None:
|
|
85
|
+
from app.db.models.user import User
|
|
86
|
+
|
|
87
|
+
user = await session.get(User, to_user_id)
|
|
88
|
+
if not user:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"Cannot send notification email — user not found",
|
|
91
|
+
user_id=str(to_user_id),
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
await self._send(
|
|
95
|
+
recipients=[user.email],
|
|
96
|
+
subject=subject,
|
|
97
|
+
template_name="notification.html",
|
|
98
|
+
template_body={
|
|
99
|
+
"full_name": user.full_name,
|
|
100
|
+
"body": body,
|
|
101
|
+
"app_name": settings.APP_NAME,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async def send_generic(
|
|
106
|
+
self,
|
|
107
|
+
to_email: str,
|
|
108
|
+
subject: str,
|
|
109
|
+
body_html: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Send a one-off HTML email without a template file."""
|
|
112
|
+
message = MessageSchema(
|
|
113
|
+
subject=subject,
|
|
114
|
+
recipients=[to_email],
|
|
115
|
+
body=body_html,
|
|
116
|
+
subtype=MessageType.html,
|
|
117
|
+
)
|
|
118
|
+
await _fast_mail.send_message(message)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import update
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.core.logging import get_logger
|
|
9
|
+
from app.db.models.notification import Notification
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotificationService:
|
|
15
|
+
def __init__(self, session: AsyncSession):
|
|
16
|
+
self.session = session
|
|
17
|
+
|
|
18
|
+
async def send(
|
|
19
|
+
self,
|
|
20
|
+
user_id: UUID,
|
|
21
|
+
title: str,
|
|
22
|
+
body: str,
|
|
23
|
+
notification_type: str = "info",
|
|
24
|
+
channel: str = "in_app",
|
|
25
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
26
|
+
) -> Notification:
|
|
27
|
+
notification = Notification(
|
|
28
|
+
user_id=user_id,
|
|
29
|
+
title=title,
|
|
30
|
+
body=body,
|
|
31
|
+
notification_type=notification_type,
|
|
32
|
+
channel=channel,
|
|
33
|
+
payload=payload,
|
|
34
|
+
)
|
|
35
|
+
self.session.add(notification)
|
|
36
|
+
await self.session.flush()
|
|
37
|
+
|
|
38
|
+
logger.debug(
|
|
39
|
+
"Notification created",
|
|
40
|
+
user_id=str(user_id),
|
|
41
|
+
type=notification_type,
|
|
42
|
+
channel=channel,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ── Channel-specific dispatch ──────────────────────────
|
|
46
|
+
if channel == "email":
|
|
47
|
+
# Lazy import to avoid circular dependency
|
|
48
|
+
from app.services.email import EmailService
|
|
49
|
+
|
|
50
|
+
email_svc = EmailService()
|
|
51
|
+
# Fire-and-forget; failures are logged, not raised
|
|
52
|
+
try:
|
|
53
|
+
await email_svc.send_notification_email(
|
|
54
|
+
to_user_id=user_id,
|
|
55
|
+
subject=title,
|
|
56
|
+
body=body,
|
|
57
|
+
session=self.session,
|
|
58
|
+
)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.warning("Email dispatch failed", error=str(exc))
|
|
61
|
+
|
|
62
|
+
return notification
|
|
63
|
+
|
|
64
|
+
async def bulk_send(self, user_ids: List[UUID], **kwargs) -> List[Notification]:
|
|
65
|
+
"""Send the same notification to multiple users."""
|
|
66
|
+
results = []
|
|
67
|
+
for uid in user_ids:
|
|
68
|
+
n = await self.send(user_id=uid, **kwargs)
|
|
69
|
+
results.append(n)
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
async def mark_all_read(self, user_id: UUID) -> int:
|
|
73
|
+
result = await self.session.execute(
|
|
74
|
+
update(Notification)
|
|
75
|
+
.where(
|
|
76
|
+
Notification.user_id == user_id,
|
|
77
|
+
Notification.is_read == False,
|
|
78
|
+
Notification.is_deleted == False,
|
|
79
|
+
)
|
|
80
|
+
.values(is_read=True, read_at=datetime.now(timezone.utc))
|
|
81
|
+
)
|
|
82
|
+
return result.rowcount
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from fastapi.encoders import jsonable_encoder
|
|
5
|
+
from pydantic.alias_generators import to_camel
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CamelModel(BaseModel):
|
|
10
|
+
model_config = ConfigDict(
|
|
11
|
+
alias_generator=to_camel,
|
|
12
|
+
populate_by_name=True,
|
|
13
|
+
from_attributes=True,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def keys_to_camel(value: Any) -> Any:
|
|
18
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
19
|
+
return value
|
|
20
|
+
if isinstance(value, BaseModel):
|
|
21
|
+
return value.model_dump(by_alias=True)
|
|
22
|
+
if isinstance(value, Mapping):
|
|
23
|
+
return {to_camel(str(key)): keys_to_camel(item) for key, item in value.items()}
|
|
24
|
+
if isinstance(value, list):
|
|
25
|
+
return [keys_to_camel(item) for item in value]
|
|
26
|
+
if isinstance(value, tuple):
|
|
27
|
+
return tuple(keys_to_camel(item) for item in value)
|
|
28
|
+
encoded = jsonable_encoder(value)
|
|
29
|
+
if encoded is not value:
|
|
30
|
+
return keys_to_camel(encoded)
|
|
31
|
+
return value
|