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,373 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ from typing import Dict, Optional
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from app.api.v1.auth.repository import (
8
+ EmailVerificationTokenRepository,
9
+ PasswordResetTokenRepository,
10
+ RevokedTokenRepository,
11
+ UserRepository,
12
+ UserSessionRepository,
13
+ )
14
+ from app.api.v1.auth.schema import (
15
+ ChangePasswordRequest,
16
+ LoginRequest,
17
+ PasswordResetConfirmRequest,
18
+ PasswordResetRequest,
19
+ RegisterRequest,
20
+ SessionResponse,
21
+ TokenResponse,
22
+ UserResponse,
23
+ )
24
+ from app.core.config import settings
25
+ from app.core.exceptions import (
26
+ ConflictException,
27
+ NotFoundException,
28
+ UnauthorizedException,
29
+ ValidationException,
30
+ )
31
+ from app.core.logging import get_logger
32
+ from app.core.security import (
33
+ create_access_token,
34
+ create_refresh_token,
35
+ decode_token,
36
+ generate_secure_token,
37
+ hash_opaque_token,
38
+ hash_password,
39
+ verify_password,
40
+ )
41
+ from app.services.audit import AuditService
42
+ from app.services.email import EmailService
43
+ from app.services.notification import NotificationService
44
+
45
+ logger = get_logger(__name__)
46
+
47
+
48
+ class AuthService:
49
+ def __init__(self, session: AsyncSession):
50
+ self.session = session
51
+ self.repo = UserRepository(session)
52
+ self.revoked_tokens = RevokedTokenRepository(session)
53
+ self.email_tokens = EmailVerificationTokenRepository(session)
54
+ self.password_tokens = PasswordResetTokenRepository(session)
55
+ self.sessions = UserSessionRepository(session)
56
+ self.audit = AuditService(session)
57
+ self.notifications = NotificationService(session)
58
+ self.email = EmailService()
59
+
60
+ async def register(self, data: RegisterRequest, request_meta: Dict) -> UserResponse:
61
+ if await self.repo.exists("email", data.email.lower()):
62
+ raise ConflictException("An account with this email already exists")
63
+ if await self.repo.exists("username", data.username.lower()):
64
+ raise ConflictException("Username is already taken")
65
+
66
+ user = await self.repo.create(
67
+ {
68
+ "email": data.email.lower(),
69
+ "username": data.username.lower(),
70
+ "full_name": data.full_name,
71
+ "hashed_password": hash_password(data.password),
72
+ }
73
+ )
74
+ await self.audit.log(
75
+ action="user.registered",
76
+ resource="users",
77
+ resource_id=str(user.id),
78
+ new_values={"email": user.email, "username": user.username},
79
+ **request_meta,
80
+ )
81
+ await self.notifications.send(
82
+ user_id=user.id,
83
+ title="Welcome!",
84
+ body=f"Hi {user.full_name}, your account was created successfully.",
85
+ notification_type="system",
86
+ )
87
+ # Email verification is currently disabled.
88
+ # await self.send_verification_email(user.email)
89
+
90
+ logger.info("User registered", user_id=str(user.id), email=user.email)
91
+ return self._user_response(user)
92
+
93
+ async def login(self, data: LoginRequest, request_meta: Dict) -> TokenResponse:
94
+ user = await self.repo.get_by_email(data.email)
95
+ if not user or not verify_password(data.password, user.hashed_password):
96
+ raise UnauthorizedException("Invalid email or password")
97
+ if not user.is_active:
98
+ raise UnauthorizedException("Account is deactivated")
99
+
100
+ login_at = datetime.now(timezone.utc)
101
+ user.last_login_at = login_at
102
+ self.session.add(user)
103
+
104
+ access_token, _ = create_access_token(
105
+ str(user.id),
106
+ {
107
+ "is_superuser": user.is_superuser,
108
+ },
109
+ )
110
+ refresh_token, refresh_expire = create_refresh_token(str(user.id))
111
+ refresh_payload = decode_token(refresh_token, expected_type="refresh")
112
+ await self.sessions.create(
113
+ {
114
+ "user_id": user.id,
115
+ "refresh_jti": refresh_payload["jti"],
116
+ "user_agent": request_meta.get("user_agent"),
117
+ "ip_address": request_meta.get("ip_address"),
118
+ "expires_at": refresh_expire,
119
+ "last_used_at": login_at,
120
+ }
121
+ )
122
+
123
+ await self.audit.log(
124
+ action="user.login",
125
+ resource="users",
126
+ resource_id=str(user.id),
127
+ **request_meta,
128
+ )
129
+ return TokenResponse(
130
+ access_token=access_token,
131
+ refresh_token=refresh_token,
132
+ expires_in=int(settings.access_token_expire_minutes * 60),
133
+ user=self._user_response(user),
134
+ )
135
+
136
+ async def refresh(
137
+ self, refresh_token: str, request_meta: Dict | None = None
138
+ ) -> Dict:
139
+ payload = decode_token(refresh_token, expected_type="refresh")
140
+ refresh_jti = payload.get("jti")
141
+ if not refresh_jti:
142
+ raise UnauthorizedException("Invalid token payload")
143
+ if await self.revoked_tokens.is_revoked(refresh_jti):
144
+ raise UnauthorizedException("Token has been revoked")
145
+
146
+ session = await self.sessions.get_active_by_refresh_jti(refresh_jti)
147
+ if not session:
148
+ replayed = await self.sessions.get_by_previous_refresh_jti(refresh_jti)
149
+ if replayed:
150
+ await self.sessions.revoke_session(replayed)
151
+ raise UnauthorizedException("Refresh session is invalid or expired")
152
+
153
+ user = await self.repo.get_active(payload.get("sub"))
154
+ if not user or not user.is_active:
155
+ raise UnauthorizedException("User not found or deactivated")
156
+
157
+ access_token, _ = create_access_token(
158
+ str(user.id),
159
+ {
160
+ "is_superuser": user.is_superuser,
161
+ },
162
+ )
163
+ new_refresh_token, refresh_expire = create_refresh_token(str(user.id))
164
+ new_refresh_payload = decode_token(new_refresh_token, expected_type="refresh")
165
+
166
+ await self._revoke_payload(payload)
167
+ session.previous_refresh_jti = refresh_jti
168
+ session.refresh_jti = new_refresh_payload["jti"]
169
+ session.expires_at = refresh_expire
170
+ session.last_used_at = datetime.now(timezone.utc)
171
+ if request_meta:
172
+ session.ip_address = request_meta.get("ip_address")
173
+ session.user_agent = request_meta.get("user_agent")
174
+ self.session.add(session)
175
+ await self.session.flush()
176
+
177
+ return {
178
+ "access_token": access_token,
179
+ "refresh_token": new_refresh_token,
180
+ "token_type": "bearer",
181
+ "expires_in": int(settings.access_token_expire_minutes * 60),
182
+ }
183
+
184
+ async def logout(
185
+ self,
186
+ access_token: Optional[str],
187
+ refresh_token: str,
188
+ request_meta: Dict,
189
+ ) -> None:
190
+ refresh_payload = decode_token(refresh_token, expected_type="refresh")
191
+ user_id = refresh_payload.get("sub")
192
+ if not user_id:
193
+ raise UnauthorizedException("Invalid token payload")
194
+
195
+ access_payload = None
196
+ if access_token:
197
+ try:
198
+ access_payload = decode_token(access_token, expected_type="access")
199
+ except UnauthorizedException:
200
+ access_payload = None
201
+
202
+ if access_payload:
203
+ if access_payload.get("sub") != user_id:
204
+ raise UnauthorizedException("Token subject mismatch")
205
+ await self._revoke_payload(access_payload)
206
+
207
+ await self._revoke_payload(refresh_payload)
208
+ session = await self.sessions.get_active_by_refresh_jti(refresh_payload["jti"])
209
+ if session:
210
+ await self.sessions.revoke_session(session)
211
+
212
+ await self.audit.log(
213
+ action="user.logout",
214
+ resource="users",
215
+ resource_id=str(user_id),
216
+ **request_meta,
217
+ )
218
+
219
+ async def send_verification_email(self, email: str) -> None:
220
+ user = await self.repo.get_by_email(email)
221
+ if not user:
222
+ return
223
+ token = generate_secure_token()
224
+ await self.email_tokens.create(
225
+ {
226
+ "user_id": user.id,
227
+ "token": hash_opaque_token(token),
228
+ "expires_at": datetime.now(timezone.utc) + timedelta(hours=24),
229
+ }
230
+ )
231
+ try:
232
+ await self.email.send_email_verification(
233
+ user.email,
234
+ f"{settings.frontend_url.rstrip('/')}/verify-email?token={token}",
235
+ )
236
+ except Exception as exc:
237
+ logger.warning("Verification email could not be sent", error=str(exc))
238
+
239
+ async def verify_email(self, token: str, request_meta: Dict) -> None:
240
+ token_row = await self.email_tokens.get_valid(token)
241
+ if not token_row:
242
+ raise ValidationException("Invalid or expired verification token")
243
+ user = await self.repo.get_by_id(token_row.user_id)
244
+ if not user:
245
+ raise NotFoundException("User not found")
246
+ user.is_verified = True
247
+ token_row.used_at = datetime.now(timezone.utc)
248
+ self.session.add_all([user, token_row])
249
+ await self.audit.log(
250
+ action="user.email_verified",
251
+ resource="users",
252
+ resource_id=str(user.id),
253
+ **request_meta,
254
+ )
255
+
256
+ async def request_password_reset(self, data: PasswordResetRequest) -> None:
257
+ user = await self.repo.get_by_email(data.email)
258
+ if not user:
259
+ return
260
+ token = generate_secure_token()
261
+ await self.password_tokens.create(
262
+ {
263
+ "user_id": user.id,
264
+ "token": hash_opaque_token(token),
265
+ "expires_at": datetime.now(timezone.utc)
266
+ + timedelta(minutes=settings.password_reset_token_expire_minutes),
267
+ }
268
+ )
269
+ try:
270
+ await self.email.send_password_reset_email(
271
+ user.email,
272
+ f"{settings.frontend_url.rstrip('/')}/reset-password?token={token}",
273
+ )
274
+ except Exception as exc:
275
+ logger.warning("Password reset email could not be sent", error=str(exc))
276
+
277
+ async def confirm_password_reset(
278
+ self, data: PasswordResetConfirmRequest, request_meta: Dict
279
+ ) -> None:
280
+ token_row = await self.password_tokens.get_valid(data.token)
281
+ if not token_row:
282
+ raise ValidationException("Invalid or expired reset token")
283
+ user = await self.repo.get_by_id(token_row.user_id)
284
+ if not user:
285
+ raise NotFoundException("User not found")
286
+ user.hashed_password = hash_password(data.new_password)
287
+ token_row.used_at = datetime.now(timezone.utc)
288
+ self.session.add_all([user, token_row])
289
+ await self.audit.log(
290
+ action="user.password_reset",
291
+ resource="users",
292
+ resource_id=str(user.id),
293
+ **request_meta,
294
+ )
295
+
296
+ async def change_password(
297
+ self, user_id: str, data: ChangePasswordRequest, request_meta: Dict
298
+ ) -> None:
299
+ user = await self.repo.get_by_id(UUID(str(user_id)))
300
+ if not user or not verify_password(data.current_password, user.hashed_password):
301
+ raise UnauthorizedException("Current password is incorrect")
302
+ user.hashed_password = hash_password(data.new_password)
303
+ self.session.add(user)
304
+ await self.audit.log(
305
+ action="user.password_changed",
306
+ resource="users",
307
+ resource_id=str(user.id),
308
+ **request_meta,
309
+ )
310
+
311
+ async def list_sessions(self, user_id: str) -> list[SessionResponse]:
312
+ sessions = await self.sessions.list_active_for_user(user_id)
313
+ return [
314
+ SessionResponse(
315
+ id=str(item.id),
316
+ user_agent=item.user_agent,
317
+ ip_address=item.ip_address,
318
+ created_at=item.created_at.isoformat(),
319
+ last_used_at=(
320
+ item.last_used_at.isoformat() if item.last_used_at else None
321
+ ),
322
+ expires_at=item.expires_at.isoformat(),
323
+ )
324
+ for item in sessions
325
+ ]
326
+
327
+ async def revoke_session(
328
+ self, user_id: str, session_id: str, request_meta: Dict
329
+ ) -> None:
330
+ session = await self.sessions.get_by_id(UUID(str(session_id)))
331
+ if not session or str(session.user_id) != str(user_id):
332
+ raise NotFoundException("Session not found")
333
+ await self.sessions.revoke_session(session)
334
+ await self.revoked_tokens.revoke(
335
+ session.refresh_jti, "refresh", session.expires_at
336
+ )
337
+ await self.audit.log(
338
+ action="user.session_revoked",
339
+ resource="user_sessions",
340
+ resource_id=str(session.id),
341
+ user_id=user_id,
342
+ **request_meta,
343
+ )
344
+
345
+ async def _revoke_payload(self, payload: Dict) -> None:
346
+ jti = payload.get("jti")
347
+ expires_at = self._payload_expires_at(payload)
348
+ token_type = payload.get("type")
349
+ if not jti or not expires_at or not token_type:
350
+ raise UnauthorizedException("Invalid token payload")
351
+ await self.revoked_tokens.revoke(jti, token_type, expires_at)
352
+
353
+ def _payload_expires_at(self, payload: Dict) -> datetime | None:
354
+ exp = payload.get("exp")
355
+ if isinstance(exp, (int, float)):
356
+ return datetime.fromtimestamp(exp, tz=timezone.utc)
357
+ if isinstance(exp, datetime):
358
+ return exp
359
+ return None
360
+
361
+ def _user_response(self, user) -> UserResponse:
362
+ return UserResponse(
363
+ id=str(user.id),
364
+ email=user.email,
365
+ username=user.username,
366
+ full_name=user.full_name,
367
+ is_active=user.is_active,
368
+ is_superuser=user.is_superuser,
369
+ is_verified=user.is_verified,
370
+ last_login_at=(
371
+ user.last_login_at.isoformat() if user.last_login_at else None
372
+ ),
373
+ )
@@ -0,0 +1,3 @@
1
+ from app.api.v1.health.router import router
2
+
3
+ __all__ = ["router"]
@@ -0,0 +1,23 @@
1
+ from fastapi import APIRouter, Response, status
2
+
3
+ from app.api.v1.health.service import HealthService
4
+
5
+ router = APIRouter()
6
+
7
+
8
+ @router.get("/health")
9
+ async def health_check():
10
+ return await HealthService().status()
11
+
12
+
13
+ @router.get("/health/live")
14
+ async def liveness_check():
15
+ return HealthService().liveness()
16
+
17
+
18
+ @router.get("/health/ready")
19
+ async def readiness_check(response: Response):
20
+ result = await HealthService().readiness()
21
+ if result["status"] != "healthy":
22
+ response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
23
+ return result
@@ -0,0 +1,5 @@
1
+ from app.utils.casing import CamelModel
2
+
3
+
4
+ class HealthResponse(CamelModel):
5
+ status: str
@@ -0,0 +1,25 @@
1
+ from app.core.config import settings
2
+ from app.db.session import check_db_health
3
+
4
+
5
+ class HealthService:
6
+ @staticmethod
7
+ def liveness() -> dict:
8
+ return {"status": "healthy"}
9
+
10
+ async def readiness(self) -> dict:
11
+ database = await check_db_health()
12
+ return {
13
+ "status": "healthy" if database["status"] == "healthy" else "unhealthy",
14
+ "database": database,
15
+ }
16
+
17
+ async def status(self) -> dict:
18
+ database = await check_db_health()
19
+ return {
20
+ "status": "healthy" if database["status"] == "healthy" else "degraded",
21
+ "app": settings.APP_NAME,
22
+ "version": settings.APP_VERSION,
23
+ "environment": settings.APP_ENV,
24
+ "database": database,
25
+ }
@@ -0,0 +1,7 @@
1
+ from pydantic import Field
2
+
3
+ from app.utils.casing import CamelModel
4
+
5
+
6
+ class DeleteNoteRequest(CamelModel):
7
+ note: str = Field(..., min_length=3, max_length=1000)
@@ -0,0 +1,69 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy import and_, func, select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.db.models import User
7
+ from app.helper.pagination_helper import apply_pagination
8
+ from app.helper.search import text_search_filter
9
+ from app.helper.sorting import sort_expressions
10
+ from app.repositories.base import BaseRepository
11
+
12
+
13
+ class UserRepository(BaseRepository[User]):
14
+ def __init__(self, session: AsyncSession):
15
+ super().__init__(User, session)
16
+
17
+ async def get_active(self, user_id: UUID):
18
+ result = await self.session.execute(
19
+ select(User).where(
20
+ User.id == user_id,
21
+ User.is_deleted == False,
22
+ User.is_active == True,
23
+ )
24
+ )
25
+ return result.scalar_one_or_none()
26
+
27
+ async def get_all(
28
+ self,
29
+ page: int,
30
+ page_size: int,
31
+ pagination: bool = True,
32
+ search: str | None = None,
33
+ sort_by: str | None = None,
34
+ sort_order: str | None = None,
35
+ ):
36
+ query = select(User).where(
37
+ User.is_deleted == False, User.is_active == True
38
+ )
39
+ search_filter = text_search_filter(
40
+ User, search, [User.email, User.username, User.full_name]
41
+ )
42
+ if search_filter is not None:
43
+ query = query.where(search_filter)
44
+ total = (
45
+ await self.session.execute(
46
+ select(func.count()).select_from(query.subquery())
47
+ )
48
+ ).scalar_one()
49
+ query = query.order_by(
50
+ *sort_expressions(User, sort_by, sort_order, (User.created_at.desc(),))
51
+ )
52
+ result = await self.session.execute(
53
+ apply_pagination(query, page, page_size, pagination)
54
+ )
55
+ return list(result.scalars().all()), total
56
+
57
+ async def exists_for_other_user(
58
+ self, field: str, value: str, user_id: UUID
59
+ ) -> bool:
60
+ result = await self.session.execute(
61
+ select(User).where(
62
+ and_(
63
+ getattr(User, field) == value,
64
+ User.id != user_id,
65
+ User.is_deleted == False,
66
+ )
67
+ )
68
+ )
69
+ return result.scalar_one_or_none() is not None
@@ -0,0 +1,94 @@
1
+ from uuid import UUID
2
+
3
+ from fastapi import APIRouter, Depends, status
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.api.v1.schema import DeleteNoteRequest
7
+ from app.api.v1.users.schema import UserCreate, UserUpdate
8
+ from app.api.v1.users.service import UserService
9
+ from app.core.dependencies import get_async_db, require_superuser
10
+ from app.core.responses import paginated_response, success_response
11
+ from app.helper.pagination_helper import PaginationParams
12
+
13
+ router = APIRouter(dependencies=[Depends(require_superuser)])
14
+
15
+
16
+ @router.get("")
17
+ async def list_users(
18
+ pagination: PaginationParams = Depends(),
19
+ db: AsyncSession = Depends(get_async_db),
20
+ ):
21
+ data, total = await UserService(db).list_users(
22
+ pagination.page,
23
+ pagination.page_size,
24
+ pagination.pagination,
25
+ search=pagination.search,
26
+ sort_by=pagination.sort_by,
27
+ sort_order=pagination.sort_order,
28
+ )
29
+ items = [item.model_dump() for item in data]
30
+ if not pagination.pagination:
31
+ return success_response(data=items)
32
+ return paginated_response(items, total, pagination.page, pagination.page_size)
33
+
34
+
35
+ @router.get("/deleted")
36
+ async def list_deleted_users(
37
+ pagination: PaginationParams = Depends(),
38
+ db: AsyncSession = Depends(get_async_db),
39
+ ):
40
+ data, total = await UserService(db).list_deleted_users(
41
+ pagination.page,
42
+ pagination.page_size,
43
+ pagination.pagination,
44
+ search=pagination.search,
45
+ sort_by=pagination.sort_by,
46
+ sort_order=pagination.sort_order,
47
+ )
48
+ items = [item.model_dump() for item in data]
49
+ if not pagination.pagination:
50
+ return success_response(data=items)
51
+ return paginated_response(items, total, pagination.page, pagination.page_size)
52
+
53
+
54
+ @router.post("", status_code=status.HTTP_201_CREATED)
55
+ async def create_user(
56
+ body: UserCreate, db: AsyncSession = Depends(get_async_db)
57
+ ):
58
+ user = await UserService(db).create_user(body)
59
+ return success_response(data=user.model_dump(), message="User created")
60
+
61
+
62
+ @router.get("/{user_id}")
63
+ async def get_user(user_id: UUID, db: AsyncSession = Depends(get_async_db)):
64
+ user = await UserService(db).get_user(user_id)
65
+ return success_response(data=user.model_dump())
66
+
67
+
68
+ @router.patch("/{user_id}")
69
+ async def update_user(
70
+ user_id: UUID, body: UserUpdate, db: AsyncSession = Depends(get_async_db)
71
+ ):
72
+ user = await UserService(db).update_user(user_id, body)
73
+ return success_response(data=user.model_dump(), message="User updated")
74
+
75
+
76
+ @router.delete("/{user_id}")
77
+ async def soft_delete_user(
78
+ user_id: UUID,
79
+ body: DeleteNoteRequest,
80
+ db: AsyncSession = Depends(get_async_db),
81
+ ):
82
+ await UserService(db).soft_delete_user(user_id, body.note)
83
+ return success_response(message="User soft deleted")
84
+
85
+
86
+ @router.delete("/{user_id}/hard")
87
+ async def hard_delete_user(
88
+ user_id: UUID,
89
+ body: DeleteNoteRequest,
90
+ db: AsyncSession = Depends(get_async_db),
91
+ ):
92
+ await UserService(db).hard_delete_user(user_id, body.note)
93
+ return success_response(message="User hard deleted")
94
+
@@ -0,0 +1,50 @@
1
+ import re
2
+ from typing import Optional
3
+
4
+ from pydantic import EmailStr, Field, field_validator
5
+
6
+ from app.utils.casing import CamelModel
7
+
8
+
9
+ class UserBase(CamelModel):
10
+ email: EmailStr
11
+ username: str = Field(..., min_length=3, max_length=50)
12
+ full_name: str = Field(..., min_length=1, max_length=255)
13
+ is_active: bool = True
14
+ is_superuser: bool = False
15
+ is_verified: bool = False
16
+
17
+ @field_validator("username")
18
+ @classmethod
19
+ def validate_username(cls, value: str) -> str:
20
+ if not re.match(r"^[a-zA-Z0-9_]+$", value):
21
+ raise ValueError("Username may only contain letters, digits, and underscores")
22
+ return value.lower()
23
+
24
+
25
+ class UserCreate(UserBase):
26
+ password: str = Field(..., min_length=8, max_length=128)
27
+
28
+
29
+ class UserUpdate(CamelModel):
30
+ email: Optional[EmailStr] = None
31
+ username: Optional[str] = Field(default=None, min_length=3, max_length=50)
32
+ full_name: Optional[str] = Field(default=None, min_length=1, max_length=255)
33
+ is_active: Optional[bool] = None
34
+ is_superuser: Optional[bool] = None
35
+ is_verified: Optional[bool] = None
36
+
37
+
38
+ class UserResponse(CamelModel):
39
+ id: str
40
+ email: str
41
+ username: str
42
+ full_name: str
43
+ is_active: bool
44
+ is_superuser: bool
45
+ is_verified: bool
46
+ last_login_at: Optional[str] = None
47
+ is_deleted: bool = False
48
+ deleted_at: Optional[str] = None
49
+ deletion_note: Optional[str] = None
50
+