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,169 @@
1
+ from uuid import UUID
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.api.v1.schema import DeleteNoteRequest
7
+ from app.api.v1.roles.schema import AssignRoleRequest, RoleCreate, RoleUpdate
8
+ from app.api.v1.roles.service import RoleService
9
+ from app.core.dependencies import (
10
+ get_async_db,
11
+ get_current_user_payload,
12
+ require_any_permission,
13
+ )
14
+ from app.helper.pagination_helper import PaginationParams
15
+ from app.core.responses import paginated_response, success_response
16
+
17
+ router = APIRouter()
18
+
19
+ _role_read = Depends(require_any_permission("admin:write", "roles:read"))
20
+ _role_create = Depends(require_any_permission("admin:write", "roles:create"))
21
+ _role_update = Depends(require_any_permission("admin:write", "roles:update"))
22
+ _role_soft_delete = Depends(require_any_permission("admin:write", "roles:soft_delete"))
23
+ _role_hard_delete = Depends(require_any_permission("admin:write", "roles:hard_delete"))
24
+
25
+
26
+ @router.get("", dependencies=[_role_read])
27
+ async def list_roles(
28
+ pagination: PaginationParams = Depends(),
29
+ db: AsyncSession = Depends(get_async_db),
30
+ active_only: bool = Query(False, alias="activeOnly"),
31
+ ):
32
+ svc = RoleService(db)
33
+ data, total = await svc.list_roles(
34
+ pagination.page,
35
+ pagination.page_size,
36
+ pagination.pagination,
37
+ pagination.search,
38
+ pagination.sort_by,
39
+ pagination.sort_order,
40
+ active_only,
41
+ )
42
+ items = [item.model_dump() for item in data]
43
+ if not pagination.pagination:
44
+ return success_response(data=items)
45
+ return paginated_response(
46
+ items,
47
+ total,
48
+ pagination.page,
49
+ pagination.page_size,
50
+ )
51
+
52
+
53
+ @router.get("/deleted", dependencies=[_role_read], summary="List soft-deleted roles")
54
+ async def list_deleted_roles(
55
+ pagination: PaginationParams = Depends(),
56
+ db: AsyncSession = Depends(get_async_db),
57
+ ):
58
+ svc = RoleService(db)
59
+ data, total = await svc.list_deleted_roles(
60
+ pagination.page,
61
+ pagination.page_size,
62
+ pagination.pagination,
63
+ pagination.search,
64
+ pagination.sort_by,
65
+ pagination.sort_order,
66
+ )
67
+ items = [item.model_dump() for item in data]
68
+ if not pagination.pagination:
69
+ return success_response(data=items)
70
+ return paginated_response(items, total, pagination.page, pagination.page_size)
71
+
72
+
73
+ @router.post("", dependencies=[_role_create])
74
+ async def create_role(
75
+ body: RoleCreate,
76
+ db: AsyncSession = Depends(get_async_db),
77
+ payload: dict = Depends(get_current_user_payload),
78
+ ):
79
+ svc = RoleService(db)
80
+ role = await svc.create_role(body, payload.get("role"))
81
+ return success_response(
82
+ data=role.model_dump(exclude_none=True),
83
+ message="Role created",
84
+ )
85
+
86
+
87
+ @router.post("/assign", dependencies=[_role_update], summary="Assign a role to a user")
88
+ async def assign_role(
89
+ body: AssignRoleRequest,
90
+ db: AsyncSession = Depends(get_async_db),
91
+ payload: dict = Depends(get_current_user_payload),
92
+ ):
93
+ svc = RoleService(db)
94
+ await svc.assign_role(body.user_id, body.role_id, payload.get("role"))
95
+ return success_response(message="Role assigned to user")
96
+
97
+
98
+ @router.get("/below", dependencies=[_role_read], summary="List roles below current role")
99
+ async def list_roles_below_current(
100
+ db: AsyncSession = Depends(get_async_db),
101
+ payload: dict = Depends(get_current_user_payload),
102
+ ):
103
+ svc = RoleService(db)
104
+ roles = await svc.list_roles_below_current(payload.get("role"))
105
+ return success_response(data=[role.model_dump(exclude_none=True) for role in roles])
106
+
107
+
108
+ @router.get("/tree", dependencies=[_role_read], summary="Get role tree below current role")
109
+ async def get_role_tree(
110
+ db: AsyncSession = Depends(get_async_db),
111
+ payload: dict = Depends(get_current_user_payload),
112
+ ):
113
+ svc = RoleService(db)
114
+ tree = await svc.role_tree_below_current(payload.get("role"))
115
+ return success_response(data=[node.model_dump(exclude_none=True) for node in tree])
116
+
117
+
118
+ @router.get("/{role_id}", dependencies=[_role_read], summary="Get a role")
119
+ async def get_role(role_id: UUID, db: AsyncSession = Depends(get_async_db)):
120
+ svc = RoleService(db)
121
+ role = await svc.get_role(role_id)
122
+ return success_response(data=role.model_dump(exclude_none=True))
123
+
124
+
125
+ @router.patch("/{role_id}", dependencies=[_role_update], summary="Update a role")
126
+ async def update_role(
127
+ role_id: UUID,
128
+ body: RoleUpdate,
129
+ db: AsyncSession = Depends(get_async_db),
130
+ payload: dict = Depends(get_current_user_payload),
131
+ ):
132
+ svc = RoleService(db)
133
+ role = await svc.update_role(role_id, body, payload.get("role"))
134
+ return success_response(
135
+ data=role.model_dump(exclude_none=True),
136
+ message="Role updated",
137
+ )
138
+
139
+
140
+ @router.delete(
141
+ "/{role_id}",
142
+ dependencies=[_role_soft_delete],
143
+ summary="Soft delete a role",
144
+ )
145
+ async def soft_delete_role(
146
+ role_id: UUID,
147
+ body: DeleteNoteRequest,
148
+ db: AsyncSession = Depends(get_async_db),
149
+ payload: dict = Depends(get_current_user_payload),
150
+ ):
151
+ svc = RoleService(db)
152
+ await svc.soft_delete_role(role_id, body.note, payload.get("role"))
153
+ return success_response(message="Role soft deleted")
154
+
155
+
156
+ @router.delete(
157
+ "/{role_id}/hard",
158
+ dependencies=[_role_hard_delete],
159
+ summary="Hard delete a role",
160
+ )
161
+ async def hard_delete_role(
162
+ role_id: UUID,
163
+ body: DeleteNoteRequest,
164
+ db: AsyncSession = Depends(get_async_db),
165
+ payload: dict = Depends(get_current_user_payload),
166
+ ):
167
+ svc = RoleService(db)
168
+ await svc.hard_delete_role(role_id, body.note, payload.get("role"))
169
+ return success_response(message="Role hard deleted")
@@ -0,0 +1,51 @@
1
+ from typing import List, Optional
2
+ from uuid import UUID
3
+
4
+ from pydantic import Field
5
+
6
+ from app.utils.casing import CamelModel
7
+
8
+
9
+ class RoleCreate(CamelModel):
10
+ name: str = Field(..., min_length=2, max_length=100)
11
+ description: Optional[str] = None
12
+ is_active: bool = True
13
+ parent_role_id: Optional[UUID] = None
14
+ permission_ids: List[UUID] = []
15
+
16
+
17
+ class RoleUpdate(CamelModel):
18
+ name: Optional[str] = Field(default=None, min_length=2, max_length=100)
19
+ description: Optional[str] = None
20
+ is_active: Optional[bool] = None
21
+ parent_role_id: Optional[UUID] = None
22
+ permission_ids: Optional[List[UUID]] = None
23
+
24
+
25
+ class Permission(CamelModel):
26
+ id: UUID
27
+ code: str
28
+
29
+
30
+ class RoleBase(CamelModel):
31
+ id: str
32
+ name: str
33
+ description: Optional[str] = None
34
+ is_active: bool
35
+ parent_role_id: Optional[str] = None
36
+ is_deleted: bool = False
37
+ deleted_at: Optional[str] = None
38
+ deletion_note: Optional[str] = None
39
+
40
+
41
+ class RoleResponse(RoleBase):
42
+ permissions: list[Permission]
43
+
44
+
45
+ class RoleTreeNode(RoleBase):
46
+ children: list["RoleTreeNode"] = Field(default_factory=list)
47
+
48
+
49
+ class AssignRoleRequest(CamelModel):
50
+ user_id: UUID
51
+ role_id: UUID
@@ -0,0 +1,319 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.api.v1.permissions.repository import PermissionRepository
6
+ from app.api.v1.roles.repository import RoleAssignmentRepository, RoleRepository
7
+ from app.core.exceptions import (
8
+ ConflictException,
9
+ ForbiddenException,
10
+ NotFoundException,
11
+ ValidationException,
12
+ )
13
+ from app.db.models.permission import Role
14
+
15
+ from .schema import (
16
+ RoleCreate,
17
+ RoleResponse,
18
+ RoleUpdate,
19
+ Permission as PermissionSchema,
20
+ RoleTreeNode,
21
+ RoleBase,
22
+ )
23
+
24
+ ROOT_ROLE_NAME = "SUPER ADMIN"
25
+
26
+
27
+ class RoleService:
28
+ def __init__(self, session: AsyncSession):
29
+ self.session = session
30
+ self.roles = RoleRepository(session)
31
+ self.assignments = RoleAssignmentRepository(session)
32
+ self.permissions = PermissionRepository(session)
33
+
34
+ async def list_roles(
35
+ self,
36
+ page: int,
37
+ page_size: int,
38
+ pagination: bool = True,
39
+ search: str | None = None,
40
+ sort_by: str | None = None,
41
+ sort_order: str | None = None,
42
+ active_only: bool = True,
43
+ ) -> tuple[list[RoleResponse], int]:
44
+ items, total = await self.roles.get_active(
45
+ page, page_size, pagination, search, sort_by, sort_order, active_only
46
+ )
47
+ return [self._role_response(item) for item in items], total
48
+
49
+ async def list_roles_below_current(self, current_role_name: str | None):
50
+ current_role = await self._current_role(current_role_name)
51
+ roles = await self.roles.list_active_with_permissions()
52
+ descendants = await self._descendant_roles(current_role, roles)
53
+ return [self._role_tree_response(role) for role in descendants]
54
+
55
+ async def role_tree_below_current(self, current_role_name: str | None):
56
+ current_role = await self._current_role(current_role_name)
57
+ roles = await self.roles.list_active_with_permissions()
58
+ descendants = await self._descendant_roles(current_role, roles)
59
+ allowed_ids = {role.id for role in descendants}
60
+ children_by_parent = {}
61
+ for role in descendants:
62
+ children_by_parent.setdefault(role.parent_role_id, []).append(role)
63
+
64
+ def build_node(role) -> RoleTreeNode:
65
+ response = self._role_tree_response(role)
66
+ return RoleTreeNode(
67
+ **response.model_dump(),
68
+ children=[
69
+ build_node(child)
70
+ for child in children_by_parent.get(role.id, [])
71
+ if child.id in allowed_ids
72
+ ],
73
+ )
74
+
75
+ roots = [
76
+ role
77
+ for role in descendants
78
+ if role.parent_role_id not in allowed_ids
79
+ or role.parent_role_id == current_role.id
80
+ ]
81
+ return [build_node(role) for role in roots]
82
+
83
+ async def list_deleted_roles(
84
+ self,
85
+ page: int,
86
+ page_size: int,
87
+ pagination: bool = True,
88
+ search: str | None = None,
89
+ sort_by: str | None = None,
90
+ sort_order: str | None = None,
91
+ ) -> tuple[list[RoleResponse], int]:
92
+ items, total = await self.roles.get_deleted(
93
+ page=page,
94
+ page_size=page_size,
95
+ pagination=pagination,
96
+ search=search,
97
+ search_columns=[Role.name, Role.description],
98
+ sort_by=sort_by,
99
+ sort_order=sort_order,
100
+ )
101
+ return [self._role_response(item) for item in items], total
102
+
103
+ async def create_role(
104
+ self,
105
+ data: RoleCreate,
106
+ current_role_name: str | None = None,
107
+ ) -> RoleResponse:
108
+ current_role = await self._current_role(current_role_name)
109
+ if await self.roles.exists("name", data.name):
110
+ raise ConflictException(f"Role '{data.name}' already exists")
111
+
112
+ permissions = await self._resolve_permissions(data.permission_ids)
113
+ parent = await self._resolve_parent_role(data.parent_role_id, current_role)
114
+
115
+ role = await self.roles.create(
116
+ {
117
+ "name": data.name,
118
+ "description": data.description,
119
+ "is_active": data.is_active,
120
+ "parent_role_id": parent.id,
121
+ }
122
+ )
123
+ role.permissions = permissions
124
+ await self.session.flush()
125
+ return self._role_response(role)
126
+
127
+ async def get_role(self, role_id: UUID) -> RoleResponse:
128
+ role = await self.roles.get_with_permissions(role_id)
129
+ if not role:
130
+ raise NotFoundException("Role", role_id)
131
+ return self._role_response(role)
132
+
133
+ async def update_role(
134
+ self,
135
+ role_id: UUID,
136
+ data: RoleUpdate,
137
+ current_role_name: str | None = None,
138
+ ) -> RoleResponse:
139
+ current_role = await self._current_role(current_role_name)
140
+ role = await self.roles.get_with_permissions(role_id)
141
+ if not role:
142
+ raise NotFoundException("Role", role_id)
143
+ await self._require_manage_role(current_role, role)
144
+
145
+ values = data.model_dump(exclude_unset=True)
146
+ if "name" in values and await self.roles.exists_for_other_role(
147
+ "name",
148
+ values["name"],
149
+ role_id,
150
+ ):
151
+ raise ConflictException(f"Role '{values['name']}' already exists")
152
+
153
+ permission_ids = values.pop("permission_ids", None)
154
+ if "parent_role_id" in values:
155
+ parent_role_id = values.pop("parent_role_id")
156
+ parent = await self._resolve_parent_role(parent_role_id, current_role)
157
+ if await self.roles.would_create_cycle(role_id, parent.id):
158
+ raise ValidationException("Role parent cannot create a cycle")
159
+ role.parent_role_id = parent.id
160
+ for key, value in values.items():
161
+ setattr(role, key, value)
162
+ if permission_ids is not None:
163
+ role.permissions = await self._resolve_permissions(permission_ids)
164
+
165
+ self.session.add(role)
166
+ await self.session.flush()
167
+ return self._role_response(role)
168
+
169
+ async def assign_role(
170
+ self,
171
+ user_id: UUID,
172
+ role_id: UUID,
173
+ current_role_name: str | None = None,
174
+ ) -> None:
175
+ current_role = await self._current_role(current_role_name)
176
+ user = await self.assignments.get_user(user_id)
177
+ if not user:
178
+ raise NotFoundException("User", user_id)
179
+ role = await self.assignments.get_role(role_id)
180
+ if not role:
181
+ raise NotFoundException("Role", role_id)
182
+ target_role = self.assignments.get_role_from_user(user)
183
+ if target_role:
184
+ await self._require_manage_role(current_role, target_role)
185
+ await self._require_manage_role(current_role, role)
186
+ duplicates = self._role_extra_permission_duplicates(
187
+ role,
188
+ user.extra_permissions,
189
+ )
190
+ if duplicates:
191
+ raise ValidationException(
192
+ "Permission(s) already assigned to role: "
193
+ + ", ".join(sorted(permission.code for permission in duplicates))
194
+ )
195
+ user.roles = [role]
196
+ await self.session.flush()
197
+
198
+ async def soft_delete_role(
199
+ self,
200
+ role_id: UUID,
201
+ note: str,
202
+ current_role_name: str | None = None,
203
+ ) -> None:
204
+ current_role = await self._current_role(current_role_name)
205
+ role = await self.roles.get_by_id(role_id)
206
+ if not role:
207
+ raise NotFoundException("Role", role_id)
208
+ await self._require_manage_role(current_role, role)
209
+ if await self.roles.has_active_children(role_id):
210
+ raise ValidationException("Role has active child roles")
211
+ await self.roles.soft_delete(role, note)
212
+
213
+ async def hard_delete_role(
214
+ self,
215
+ role_id: UUID,
216
+ note: str,
217
+ current_role_name: str | None = None,
218
+ ) -> None:
219
+ current_role = await self._current_role(current_role_name)
220
+ role = await self.roles.get_by_id(role_id, include_deleted=True)
221
+ if not role:
222
+ raise NotFoundException("Role", role_id)
223
+ await self._require_manage_role(current_role, role)
224
+ if await self.roles.has_active_children(role_id):
225
+ raise ValidationException("Role has active child roles")
226
+ await self.roles.hard_delete(role, note)
227
+
228
+ def _role_response(self, role) -> RoleResponse:
229
+ return RoleResponse(
230
+ id=str(role.id),
231
+ name=role.name,
232
+ description=role.description,
233
+ is_active=role.is_active,
234
+ parent_role_id=str(role.parent_role_id) if role.parent_role_id else None,
235
+ is_deleted=role.is_deleted,
236
+ deleted_at=role.deleted_at.isoformat() if role.deleted_at else None,
237
+ deletion_note=role.deletion_note,
238
+ permissions=[
239
+ PermissionSchema(id=permission.id, code=permission.code)
240
+ for permission in role.permissions
241
+ if not permission.is_deleted and permission.is_active
242
+ ],
243
+ )
244
+
245
+ def _role_tree_response(self, role) -> RoleBase:
246
+ return RoleBase(
247
+ id=str(role.id),
248
+ name=role.name,
249
+ description=role.description,
250
+ is_active=role.is_active,
251
+ parent_role_id=str(role.parent_role_id) if role.parent_role_id else None,
252
+ is_deleted=role.is_deleted,
253
+ deleted_at=role.deleted_at.isoformat() if role.deleted_at else None,
254
+ deletion_note=role.deletion_note,
255
+ )
256
+
257
+ def _role_extra_permission_duplicates(self, role, extra_permissions: list) -> list:
258
+ role_permission_ids = {
259
+ permission.id
260
+ for permission in role.permissions
261
+ if not permission.is_deleted and permission.is_active
262
+ }
263
+ return [
264
+ permission
265
+ for permission in extra_permissions
266
+ if permission.id in role_permission_ids
267
+ and not permission.is_deleted
268
+ and permission.is_active
269
+ ]
270
+
271
+ async def _resolve_permissions(self, permission_ids: list[UUID]) -> list:
272
+ permissions = []
273
+ for permission_id in list(dict.fromkeys(permission_ids)):
274
+ permission = await self.permissions.get_active_by_id(permission_id)
275
+ if not permission:
276
+ raise NotFoundException("Permission", permission_id)
277
+ permissions.append(permission)
278
+ return permissions
279
+
280
+ async def _current_role(self, role_name: str | None) -> Role:
281
+ if not role_name:
282
+ raise ForbiddenException("Current user role is required")
283
+ role = await self.roles.get_by_name_with_permissions(role_name)
284
+ if not role:
285
+ raise ForbiddenException("Current user role is invalid")
286
+ return role
287
+
288
+ async def _resolve_parent_role(
289
+ self,
290
+ parent_role_id: UUID | None,
291
+ current_role: Role,
292
+ ) -> Role:
293
+ if parent_role_id is None:
294
+ return current_role
295
+ parent = await self.roles.get_with_permissions(parent_role_id)
296
+ if not parent or not parent.is_active:
297
+ raise NotFoundException("Parent role", parent_role_id)
298
+ if parent.id != current_role.id:
299
+ await self._require_manage_role(current_role, parent)
300
+ return parent
301
+
302
+ async def _require_manage_role(self, current_role: Role, target_role: Role) -> None:
303
+ if current_role.name.upper() == ROOT_ROLE_NAME:
304
+ if target_role.id == current_role.id:
305
+ raise ForbiddenException("You cannot manage your own role")
306
+ return
307
+ if not await self.roles.is_descendant(target_role.id, current_role.id):
308
+ raise ForbiddenException("You cannot manage this role")
309
+
310
+ async def _descendant_roles(
311
+ self, current_role: Role, roles: list[Role]
312
+ ) -> list[Role]:
313
+ if current_role.name.upper() == ROOT_ROLE_NAME:
314
+ return [role for role in roles if role.id != current_role.id]
315
+ descendants = []
316
+ for role in roles:
317
+ if await self.roles.is_descendant(role.id, current_role.id):
318
+ descendants.append(role)
319
+ return descendants
@@ -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)