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,181 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+
4
+ from sqlalchemy import and_, func, select
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy.orm import selectinload
7
+
8
+ from app.db.models import Permission, Role, User
9
+ from app.helper.pagination_helper import apply_pagination
10
+ from app.helper.search import text_search_filter
11
+ from app.repositories.base import BaseRepository
12
+ from app.helper.sorting import sort_expressions
13
+
14
+ from .schema import Permission as PermissionSchema
15
+
16
+
17
+ class UserRepository(BaseRepository[User]):
18
+ def __init__(self, session: AsyncSession):
19
+ super().__init__(User, session)
20
+
21
+ async def get_by_email(self, email: str) -> Optional[User]:
22
+ return await self.get_by_field("email", email.lower())
23
+
24
+ async def get_by_username(self, username: str) -> Optional[User]:
25
+ return await self.get_by_field("username", username.lower())
26
+
27
+ async def get_with_permissions(
28
+ self,
29
+ user_id: UUID,
30
+ ) -> Optional[User]:
31
+ q = (
32
+ select(User)
33
+ .where(
34
+ User.id == user_id,
35
+ User.is_deleted == False,
36
+ User.is_active == True,
37
+ )
38
+ .options(
39
+ selectinload(User.roles).selectinload(Role.permissions),
40
+ selectinload(User.extra_permissions),
41
+ )
42
+ )
43
+ result = await self.session.execute(q)
44
+ return result.scalar_one_or_none()
45
+
46
+ async def get_all_with_permissions(
47
+ self,
48
+ page: int,
49
+ page_size: int,
50
+ pagination: bool = True,
51
+ search: str | None = None,
52
+ sort_by: str | None = None,
53
+ sort_order: str | None = None,
54
+ ) -> tuple[list[User], int]:
55
+ order = sort_expressions(User, sort_by, sort_order, (User.created_at.desc(),))
56
+ q = (
57
+ select(User)
58
+ .where(
59
+ User.is_deleted == False,
60
+ User.is_active == True,
61
+ )
62
+ .options(
63
+ selectinload(User.roles).selectinload(Role.permissions),
64
+ selectinload(User.extra_permissions),
65
+ )
66
+ .order_by(*order)
67
+ )
68
+ search_filter = text_search_filter(
69
+ User,
70
+ search,
71
+ [User.email, User.username, User.full_name],
72
+ )
73
+ if search_filter is not None:
74
+ q = q.where(search_filter)
75
+ count_q = select(func.count()).select_from(q.subquery())
76
+ total = (await self.session.execute(count_q)).scalar_one()
77
+
78
+ q = apply_pagination(q, page, page_size, pagination)
79
+ result = await self.session.execute(q)
80
+ return list(result.scalars().all()), total
81
+
82
+ async def exists_for_other_user(
83
+ self, field: str, value: str, user_id: UUID
84
+ ) -> bool:
85
+ q = select(User).where(
86
+ and_(
87
+ getattr(User, field) == value,
88
+ User.id != user_id,
89
+ User.is_deleted == False,
90
+ )
91
+ )
92
+ result = await self.session.execute(q)
93
+ return result.scalar_one_or_none() is not None
94
+
95
+ def collect_permissions(self, user: User) -> list[PermissionSchema]:
96
+ permissions: dict[UUID, PermissionSchema] = {}
97
+ for permission in self.collect_role_permissions(user):
98
+ permissions[permission.id] = permission
99
+ for permission in self.collect_extra_permissions(user):
100
+ permissions[permission.id] = permission
101
+ role = self.get_role(user)
102
+ if user.is_superuser or (role and role.name.upper() == "SUPER ADMIN"):
103
+ permissions[UUID("00000000-0000-0000-0000-000000000000")] = (
104
+ PermissionSchema(
105
+ id=UUID("00000000-0000-0000-0000-000000000000"),
106
+ code="*",
107
+ )
108
+ )
109
+ return sorted(
110
+ permissions.values(),
111
+ key=lambda permission: str(permission.id),
112
+ )
113
+
114
+ def get_role(self, user: User) -> Role | None:
115
+ for role in user.roles:
116
+ if not role.is_deleted and role.is_active:
117
+ return role
118
+ return None
119
+
120
+ def collect_role_permissions(self, user: User) -> list[PermissionSchema]:
121
+ role = self.get_role(user)
122
+ if not role:
123
+ return []
124
+
125
+ permissions = [
126
+ PermissionSchema(
127
+ id=permission.id,
128
+ code=permission.code,
129
+ )
130
+ for permission in role.permissions
131
+ if not permission.is_deleted and permission.is_active
132
+ ]
133
+ return sorted(
134
+ permissions,
135
+ key=lambda permission: str(permission.id),
136
+ )
137
+
138
+ def collect_extra_permissions(self, user: User) -> list[PermissionSchema]:
139
+ permissions = [
140
+ PermissionSchema(
141
+ id=permission.id,
142
+ code=permission.code,
143
+ )
144
+ for permission in user.extra_permissions
145
+ if not permission.is_deleted and permission.is_active
146
+ ]
147
+ return sorted(
148
+ permissions,
149
+ key=lambda permission: str(permission.id),
150
+ )
151
+
152
+ def collect_role_name(self, user: User) -> str | None:
153
+ role = self.get_role(user)
154
+ return role.name if role else None
155
+
156
+ async def get_active_role(self, role_id: UUID) -> Role | None:
157
+ result = await self.session.execute(
158
+ select(Role)
159
+ .where(
160
+ Role.id == role_id,
161
+ Role.is_deleted == False,
162
+ Role.is_active == True,
163
+ )
164
+ .options(selectinload(Role.permissions))
165
+ )
166
+ return result.scalar_one_or_none()
167
+
168
+ async def list_active_permissions_by_ids(
169
+ self,
170
+ permission_ids: list[UUID],
171
+ ) -> list[Permission]:
172
+ if not permission_ids:
173
+ return []
174
+ result = await self.session.execute(
175
+ select(Permission).where(
176
+ Permission.id.in_(permission_ids),
177
+ Permission.is_deleted == False,
178
+ Permission.is_active == True,
179
+ )
180
+ )
181
+ return list(result.scalars().all())
@@ -0,0 +1,146 @@
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.users.schema import UserCreate, UserUpdate
7
+ from app.api.v1.users.service import UserService
8
+ from app.api.v1.schema import DeleteNoteRequest
9
+ from app.core.exceptions import ForbiddenException
10
+ from app.core.dependencies import (
11
+ get_async_db,
12
+ get_current_user_payload,
13
+ require_any_permission,
14
+ )
15
+ from app.helper.pagination_helper import PaginationParams
16
+ from app.core.responses import paginated_response, success_response
17
+
18
+ router = APIRouter()
19
+
20
+ _read = Depends(require_any_permission("admin:write", "users:read"))
21
+ _create = Depends(require_any_permission("admin:write", "users:create"))
22
+ _update = Depends(require_any_permission("admin:write", "users:update"))
23
+ _soft_delete = Depends(require_any_permission("admin:write", "users:soft_delete"))
24
+ _hard_delete = Depends(require_any_permission("admin:write", "users:hard_delete"))
25
+
26
+
27
+ @router.get("", dependencies=[_read], summary="List users")
28
+ async def list_users(
29
+ pagination: PaginationParams = Depends(),
30
+ db: AsyncSession = Depends(get_async_db),
31
+ ):
32
+ svc = UserService(db)
33
+ data, total = await svc.list_users(
34
+ pagination.page,
35
+ pagination.page_size,
36
+ pagination.pagination,
37
+ search=pagination.search,
38
+ sort_by=pagination.sort_by,
39
+ sort_order=pagination.sort_order,
40
+ )
41
+ items = [item.model_dump() for item in data]
42
+ if not pagination.pagination:
43
+ return success_response(data=items)
44
+ return paginated_response(
45
+ items,
46
+ total,
47
+ pagination.page,
48
+ pagination.page_size,
49
+ )
50
+
51
+
52
+ @router.get("/deleted", dependencies=[_read], summary="List soft-deleted users")
53
+ async def list_deleted_users(
54
+ pagination: PaginationParams = Depends(),
55
+ db: AsyncSession = Depends(get_async_db),
56
+ ):
57
+ svc = UserService(db)
58
+ data, total = await svc.list_deleted_users(
59
+ pagination.page,
60
+ pagination.page_size,
61
+ pagination.pagination,
62
+ search=pagination.search,
63
+ sort_by=pagination.sort_by,
64
+ sort_order=pagination.sort_order,
65
+ )
66
+ items = [item.model_dump() for item in data]
67
+ if not pagination.pagination:
68
+ return success_response(data=items)
69
+ return paginated_response(items, total, pagination.page, pagination.page_size)
70
+
71
+
72
+ @router.post(
73
+ "",
74
+ dependencies=[_create],
75
+ status_code=status.HTTP_201_CREATED,
76
+ summary="Create a user",
77
+ )
78
+ async def create_user(
79
+ body: UserCreate,
80
+ db: AsyncSession = Depends(get_async_db),
81
+ payload: dict = Depends(get_current_user_payload),
82
+ ):
83
+ svc = UserService(db)
84
+ user = await svc.create_user(body, payload.get("role"))
85
+ return success_response(data=user.model_dump(), message="User created")
86
+
87
+
88
+ @router.get("/{user_id}", dependencies=[_read], summary="Get a user")
89
+ async def get_user(
90
+ user_id: UUID,
91
+ db: AsyncSession = Depends(get_async_db),
92
+ ):
93
+ svc = UserService(db)
94
+ user = await svc.get_user(
95
+ user_id,
96
+ )
97
+ return success_response(data=user.model_dump())
98
+
99
+
100
+ @router.patch("/{user_id}", dependencies=[_update], summary="Update a user")
101
+ async def update_user(
102
+ user_id: UUID,
103
+ body: UserUpdate,
104
+ db: AsyncSession = Depends(get_async_db),
105
+ payload: dict = Depends(get_current_user_payload),
106
+ ):
107
+ if str(user_id) == payload.get("sub"):
108
+ raise ForbiddenException("You cannot update your own user account.")
109
+
110
+ svc = UserService(db)
111
+ user = await svc.update_user(
112
+ user_id,
113
+ body,
114
+ current_role_name=payload.get("role"),
115
+ )
116
+ return success_response(data=user.model_dump(), message="User updated")
117
+
118
+
119
+ @router.delete(
120
+ "/{user_id}",
121
+ dependencies=[_soft_delete],
122
+ summary="Soft delete a user",
123
+ )
124
+ async def soft_delete_user(
125
+ user_id: UUID,
126
+ body: DeleteNoteRequest,
127
+ db: AsyncSession = Depends(get_async_db),
128
+ ):
129
+ svc = UserService(db)
130
+ await svc.soft_delete_user(user_id, body.note)
131
+ return success_response(message="User soft deleted")
132
+
133
+
134
+ @router.delete(
135
+ "/{user_id}/hard",
136
+ dependencies=[_hard_delete],
137
+ summary="Hard delete a user",
138
+ )
139
+ async def hard_delete_user(
140
+ user_id: UUID,
141
+ body: DeleteNoteRequest,
142
+ db: AsyncSession = Depends(get_async_db),
143
+ ):
144
+ svc = UserService(db)
145
+ await svc.hard_delete_user(user_id, body.note)
146
+ return success_response(message="User hard deleted")
@@ -0,0 +1,112 @@
1
+ import re
2
+ from typing import List, Optional
3
+ from uuid import UUID
4
+
5
+ from pydantic import EmailStr, Field, field_validator
6
+
7
+ from app.utils.casing import CamelModel
8
+
9
+
10
+ class UserBase(CamelModel):
11
+ email: EmailStr
12
+ username: str = Field(..., min_length=3, max_length=50)
13
+ full_name: str = Field(..., min_length=1, max_length=255)
14
+ is_active: bool = True
15
+ is_superuser: bool = False
16
+ is_verified: bool = False
17
+ role_id: UUID
18
+ extra_permission_ids: List[UUID] = Field(default_factory=list)
19
+
20
+ @field_validator("username")
21
+ @classmethod
22
+ def validate_username(cls, value: str) -> str:
23
+ if not re.match(r"^[a-zA-Z0-9_]+$", value):
24
+ raise ValueError(
25
+ "Username may only contain letters, digits, and underscores"
26
+ )
27
+ return value.lower()
28
+
29
+
30
+ class UserCreate(UserBase):
31
+ password: str = Field(..., min_length=8, max_length=128)
32
+
33
+ @field_validator("password")
34
+ @classmethod
35
+ def validate_password_strength(cls, value: str) -> str:
36
+ errors = []
37
+ if not re.search(r"[A-Z]", value):
38
+ errors.append("at least one uppercase letter")
39
+ if not re.search(r"[a-z]", value):
40
+ errors.append("at least one lowercase letter")
41
+ if not re.search(r"\d", value):
42
+ errors.append("at least one digit")
43
+ if not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", value):
44
+ errors.append("at least one special character")
45
+ if errors:
46
+ raise ValueError(f"Password must contain {', '.join(errors)}")
47
+ return value
48
+
49
+
50
+ class UserUpdate(CamelModel):
51
+ email: Optional[EmailStr] = None
52
+ username: Optional[str] = Field(default=None, min_length=3, max_length=50)
53
+ full_name: Optional[str] = Field(default=None, min_length=1, max_length=255)
54
+ # password: Optional[str] = Field(default=None, min_length=8, max_length=128)
55
+ is_active: Optional[bool] = None
56
+ is_superuser: Optional[bool] = None
57
+ is_verified: Optional[bool] = None
58
+ role_id: Optional[UUID] = None
59
+ extra_permission_ids: Optional[List[UUID]] = None
60
+
61
+ @field_validator("username")
62
+ @classmethod
63
+ def validate_username(cls, value: Optional[str]) -> Optional[str]:
64
+ if value is None:
65
+ return value
66
+ if not re.match(r"^[a-zA-Z0-9_]+$", value):
67
+ raise ValueError(
68
+ "Username may only contain letters, digits, and underscores"
69
+ )
70
+ return value.lower()
71
+
72
+ # @field_validator("password")
73
+ # @classmethod
74
+ # def validate_password_strength(cls, value: Optional[str]) -> Optional[str]:
75
+ # if value is None:
76
+ # return value
77
+ # errors = []
78
+ # if not re.search(r"[A-Z]", value):
79
+ # errors.append("at least one uppercase letter")
80
+ # if not re.search(r"[a-z]", value):
81
+ # errors.append("at least one lowercase letter")
82
+ # if not re.search(r"\d", value):
83
+ # errors.append("at least one digit")
84
+ # if not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", value):
85
+ # errors.append("at least one special character")
86
+ # if errors:
87
+ # raise ValueError(f"Password must contain {', '.join(errors)}")
88
+ # return value
89
+
90
+
91
+ class Permission(CamelModel):
92
+ id: UUID
93
+ code: str
94
+
95
+
96
+ class UserResponse(CamelModel):
97
+ id: str
98
+ email: str
99
+ username: str
100
+ full_name: str
101
+ is_active: bool
102
+ is_superuser: bool
103
+ is_verified: bool
104
+ last_login_at: Optional[str] = None
105
+ is_deleted: bool = False
106
+ deleted_at: Optional[str] = None
107
+ deletion_note: Optional[str] = None
108
+ role_id: Optional[str] = None
109
+ role: Optional[str] = None
110
+ role_permissions: List[Permission] = Field(default_factory=list)
111
+ extra_permissions: List[Permission] = Field(default_factory=list)
112
+ permissions: List[Permission] = Field(default_factory=list)