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.
Files changed (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. 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,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,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Hello {{ full_name }},</p>
5
+ <div>{{ body }}</div>
6
+ </body>
7
+ </html>
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Use the link below to reset your {{ app_name }} password:</p>
5
+ <p><a href="{{ reset_link }}">Reset password</a></p>
6
+ </body>
7
+ </html>
@@ -0,0 +1,7 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Use the link below to verify your {{ app_name }} email address:</p>
5
+ <p><a href="{{ verify_link }}">Verify email</a></p>
6
+ </body>
7
+ </html>
@@ -0,0 +1,6 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body>
4
+ <p>Welcome {{ full_name }} to {{ app_name }}.</p>
5
+ </body>
6
+ </html>
@@ -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