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,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,80 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy import func, select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import selectinload
6
+
7
+ from app.db.models.permission import Permission, Role
8
+ from app.db.models.user import User
9
+ from app.repositories.base import BaseRepository
10
+
11
+
12
+ class PermissionRepository(BaseRepository[Permission]):
13
+ def __init__(self, session: AsyncSession):
14
+ super().__init__(Permission, session)
15
+
16
+ async def get_active(
17
+ self,
18
+ page: int,
19
+ page_size: int,
20
+ pagination: bool = True,
21
+ search: str | None = None,
22
+ sort_by: str | None = None,
23
+ sort_order: str | None = None,
24
+ active_only: bool = True,
25
+ ) -> tuple[list[Permission], int]:
26
+ filters = []
27
+ if active_only:
28
+ filters.append(Permission.is_active == True)
29
+ return await self.get_all(
30
+ page=page,
31
+ page_size=page_size,
32
+ pagination=pagination,
33
+ filters=filters,
34
+ search=search,
35
+ search_columns=[Permission.code, Permission.name, Permission.description],
36
+ sort_by=sort_by,
37
+ sort_order=sort_order,
38
+ )
39
+
40
+ async def get_active_by_id(self, permission_id: UUID) -> Permission | None:
41
+ q = select(Permission).where(
42
+ Permission.id == permission_id,
43
+ Permission.is_deleted == False,
44
+ Permission.is_active == True,
45
+ )
46
+ result = await self.session.execute(q)
47
+ return result.scalar_one_or_none()
48
+
49
+ async def exists_for_other_permission(
50
+ self,
51
+ field: str,
52
+ value: str,
53
+ permission_id: UUID,
54
+ ) -> bool:
55
+ q = (
56
+ select(func.count())
57
+ .select_from(Permission)
58
+ .where(
59
+ getattr(Permission, field) == value,
60
+ Permission.id != permission_id,
61
+ )
62
+ )
63
+ return (await self.session.execute(q)).scalar_one() > 0
64
+
65
+
66
+ class UserAssignmentRepository(BaseRepository[User]):
67
+ def __init__(self, session: AsyncSession):
68
+ super().__init__(User, session)
69
+
70
+ async def get_user(self, user_id: UUID) -> User | None:
71
+ q = (
72
+ select(User)
73
+ .where(User.id == user_id, User.is_deleted == False)
74
+ .options(
75
+ selectinload(User.roles).selectinload(Role.permissions),
76
+ selectinload(User.extra_permissions),
77
+ )
78
+ )
79
+ result = await self.session.execute(q)
80
+ return result.scalar_one_or_none()
@@ -0,0 +1,151 @@
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.permissions.schema import (
7
+ AssignPermissionRequest,
8
+ PermissionCreate,
9
+ PermissionUpdate,
10
+ )
11
+ from app.api.v1.schema import DeleteNoteRequest
12
+ from app.api.v1.permissions.service import PermissionService
13
+ from app.core.dependencies import get_async_db, require_any_permission
14
+ from app.helper.pagination_helper import PaginationParams
15
+ from app.core.responses import paginated_response, success_response
16
+
17
+ router = APIRouter()
18
+
19
+ _permission_read = Depends(require_any_permission("admin:write", "permissions:read"))
20
+ _permission_create = Depends(require_any_permission("admin:write", "permissions:create"))
21
+ _permission_update = Depends(require_any_permission("admin:write", "permissions:update"))
22
+ _permission_soft_delete = Depends(
23
+ require_any_permission("admin:write", "permissions:soft_delete")
24
+ )
25
+ _permission_hard_delete = Depends(
26
+ require_any_permission("admin:write", "permissions:hard_delete")
27
+ )
28
+
29
+
30
+ @router.get("", dependencies=[_permission_read])
31
+ async def list_permissions(
32
+ pagination: PaginationParams = Depends(),
33
+ active_only: bool = Query(False, alias="activeOnly"),
34
+ db: AsyncSession = Depends(get_async_db),
35
+ ):
36
+ svc = PermissionService(db)
37
+ data, total = await svc.list_permissions(
38
+ pagination.page,
39
+ pagination.page_size,
40
+ pagination.pagination,
41
+ pagination.search,
42
+ pagination.sort_by,
43
+ pagination.sort_order,
44
+ active_only,
45
+ )
46
+ items = [item.model_dump() for item in data]
47
+ if not pagination.pagination:
48
+ return success_response(data=items)
49
+ return paginated_response(
50
+ items,
51
+ total,
52
+ pagination.page,
53
+ pagination.page_size,
54
+ )
55
+
56
+
57
+ @router.get(
58
+ "/deleted",
59
+ dependencies=[_permission_read],
60
+ summary="List soft-deleted permissions",
61
+ )
62
+ async def list_deleted_permissions(
63
+ pagination: PaginationParams = Depends(),
64
+ db: AsyncSession = Depends(get_async_db),
65
+ ):
66
+ svc = PermissionService(db)
67
+ data, total = await svc.list_deleted_permissions(
68
+ pagination.page,
69
+ pagination.page_size,
70
+ pagination.pagination,
71
+ pagination.search,
72
+ pagination.sort_by,
73
+ pagination.sort_order,
74
+ )
75
+ items = [item.model_dump() for item in data]
76
+ if not pagination.pagination:
77
+ return success_response(data=items)
78
+ return paginated_response(items, total, pagination.page, pagination.page_size)
79
+
80
+
81
+ @router.post("", dependencies=[_permission_create])
82
+ async def create_permission(
83
+ body: PermissionCreate, db: AsyncSession = Depends(get_async_db)
84
+ ):
85
+ svc = PermissionService(db)
86
+ permission = await svc.create_permission(body)
87
+ return success_response(data=permission.model_dump(), message="Permission created")
88
+
89
+
90
+ @router.post(
91
+ "/assign-permission",
92
+ dependencies=[_permission_update],
93
+ summary="Assign extra permission to a user",
94
+ )
95
+ async def assign_permission(
96
+ body: AssignPermissionRequest, db: AsyncSession = Depends(get_async_db)
97
+ ):
98
+ svc = PermissionService(db)
99
+ await svc.assign_permission(body.user_id, body.permission_id)
100
+ return success_response(message="Permission assigned to user")
101
+
102
+
103
+ @router.get("/{permission_id}", dependencies=[_permission_read])
104
+ async def get_permission(
105
+ permission_id: UUID,
106
+ db: AsyncSession = Depends(get_async_db),
107
+ ):
108
+ svc = PermissionService(db)
109
+ permission = await svc.get_permission(permission_id)
110
+ return success_response(data=permission.model_dump())
111
+
112
+
113
+ @router.patch("/{permission_id}", dependencies=[_permission_update])
114
+ async def update_permission(
115
+ permission_id: UUID,
116
+ body: PermissionUpdate,
117
+ db: AsyncSession = Depends(get_async_db),
118
+ ):
119
+ svc = PermissionService(db)
120
+ permission = await svc.update_permission(permission_id, body)
121
+ return success_response(data=permission.model_dump(), message="Permission updated")
122
+
123
+
124
+ @router.delete(
125
+ "/{permission_id}",
126
+ dependencies=[_permission_soft_delete],
127
+ summary="Soft delete a permission",
128
+ )
129
+ async def soft_delete_permission(
130
+ permission_id: UUID,
131
+ body: DeleteNoteRequest,
132
+ db: AsyncSession = Depends(get_async_db),
133
+ ):
134
+ svc = PermissionService(db)
135
+ await svc.soft_delete_permission(permission_id, body.note)
136
+ return success_response(message="Permission soft deleted")
137
+
138
+
139
+ @router.delete(
140
+ "/{permission_id}/hard",
141
+ dependencies=[_permission_hard_delete],
142
+ summary="Hard delete a permission",
143
+ )
144
+ async def hard_delete_permission(
145
+ permission_id: UUID,
146
+ body: DeleteNoteRequest,
147
+ db: AsyncSession = Depends(get_async_db),
148
+ ):
149
+ svc = PermissionService(db)
150
+ await svc.hard_delete_permission(permission_id, body.note)
151
+ return success_response(message="Permission hard deleted")
@@ -0,0 +1,40 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+
4
+ from pydantic import Field
5
+
6
+ from app.utils.casing import CamelModel
7
+
8
+
9
+ class PermissionCreate(CamelModel):
10
+ code: str = Field(..., pattern=r"^[a-z_*]+:[a-z_*]+$", examples=["users:read"])
11
+ name: str
12
+ description: Optional[str] = None
13
+ is_active: bool = True
14
+
15
+
16
+ class PermissionUpdate(CamelModel):
17
+ code: Optional[str] = Field(
18
+ default=None,
19
+ pattern=r"^[a-z_*]+:[a-z_*]+$",
20
+ examples=["users:read"],
21
+ )
22
+ name: Optional[str] = None
23
+ description: Optional[str] = None
24
+ is_active: Optional[bool] = None
25
+
26
+
27
+ class PermissionResponse(CamelModel):
28
+ id: str
29
+ code: str
30
+ name: str
31
+ description: Optional[str] = None
32
+ is_active: bool
33
+ is_deleted: bool = False
34
+ deleted_at: Optional[str] = None
35
+ deletion_note: Optional[str] = None
36
+
37
+
38
+ class AssignPermissionRequest(CamelModel):
39
+ user_id: UUID
40
+ permission_id: UUID
@@ -0,0 +1,156 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+
5
+ from app.api.v1.permissions.repository import (
6
+ PermissionRepository,
7
+ UserAssignmentRepository,
8
+ )
9
+ from app.api.v1.permissions.schema import (
10
+ PermissionCreate,
11
+ PermissionResponse,
12
+ PermissionUpdate,
13
+ )
14
+ from app.core.exceptions import (
15
+ ConflictException,
16
+ NotFoundException,
17
+ ValidationException,
18
+ )
19
+ from app.db.models.permission import Permission
20
+
21
+
22
+ class PermissionService:
23
+ def __init__(self, session: AsyncSession):
24
+ self.session = session
25
+ self.permissions = PermissionRepository(session)
26
+ self.assignments = UserAssignmentRepository(session)
27
+
28
+ async def list_permissions(
29
+ self,
30
+ page: int,
31
+ page_size: int,
32
+ pagination: bool = True,
33
+ search: str | None = None,
34
+ sort_by: str | None = None,
35
+ sort_order: str | None = None,
36
+ active_only: bool = True,
37
+ ) -> tuple[list[PermissionResponse], int]:
38
+ items, total = await self.permissions.get_active(
39
+ page,
40
+ page_size,
41
+ pagination,
42
+ search,
43
+ sort_by,
44
+ sort_order,
45
+ active_only,
46
+ )
47
+ return [self._permission_response(item) for item in items], total
48
+
49
+ async def list_deleted_permissions(
50
+ self,
51
+ page: int,
52
+ page_size: int,
53
+ pagination: bool = True,
54
+ search: str | None = None,
55
+ sort_by: str | None = None,
56
+ sort_order: str | None = None,
57
+ ) -> tuple[list[PermissionResponse], int]:
58
+ items, total = await self.permissions.get_deleted(
59
+ page=page,
60
+ page_size=page_size,
61
+ pagination=pagination,
62
+ search=search,
63
+ search_columns=[Permission.code, Permission.name, Permission.description],
64
+ sort_by=sort_by,
65
+ sort_order=sort_order,
66
+ )
67
+ return [self._permission_response(item) for item in items], total
68
+
69
+ async def create_permission(self, data: PermissionCreate) -> PermissionResponse:
70
+ if await self.permissions.exists("code", data.code):
71
+ raise ConflictException(f"Permission '{data.code}' already exists")
72
+ permission = await self.permissions.create(data.model_dump())
73
+ return self._permission_response(permission)
74
+
75
+ async def get_permission(self, permission_id: UUID) -> PermissionResponse:
76
+ permission = await self.permissions.get_by_id(permission_id)
77
+ if not permission:
78
+ raise NotFoundException("Permission", permission_id)
79
+ return self._permission_response(permission)
80
+
81
+ async def update_permission(
82
+ self,
83
+ permission_id: UUID,
84
+ data: PermissionUpdate,
85
+ ) -> PermissionResponse:
86
+ permission = await self.permissions.get_by_id(permission_id)
87
+ if not permission:
88
+ raise NotFoundException("Permission", permission_id)
89
+
90
+ values = data.model_dump(exclude_unset=True)
91
+ if "code" in values and await self.permissions.exists_for_other_permission(
92
+ "code",
93
+ values["code"],
94
+ permission_id,
95
+ ):
96
+ raise ConflictException(f"Permission '{values['code']}' already exists")
97
+
98
+ permission = await self.permissions.update(permission, values)
99
+ return self._permission_response(permission)
100
+
101
+ async def assign_permission(self, user_id: UUID, permission_id: UUID) -> None:
102
+ user = await self.assignments.get_user(user_id)
103
+ if not user:
104
+ raise NotFoundException("User", user_id)
105
+ permission = await self.permissions.get_active_by_id(permission_id)
106
+ if not permission:
107
+ raise NotFoundException("Permission", permission_id)
108
+ if self._permission_exists_in_user_role(user, permission):
109
+ raise ValidationException(
110
+ f"Permission '{permission.code}' is already assigned to role"
111
+ )
112
+ if permission not in user.extra_permissions:
113
+ user.extra_permissions.append(permission)
114
+ await self.session.flush()
115
+
116
+ async def soft_delete_permission(self, permission_id: UUID, note: str) -> None:
117
+ permission = await self.permissions.get_by_id(permission_id)
118
+ if not permission:
119
+ raise NotFoundException("Permission", permission_id)
120
+ await self.permissions.soft_delete(permission, note)
121
+
122
+ async def hard_delete_permission(self, permission_id: UUID, note: str) -> None:
123
+ permission = await self.permissions.get_by_id(
124
+ permission_id,
125
+ include_deleted=True,
126
+ )
127
+ if not permission:
128
+ raise NotFoundException("Permission", permission_id)
129
+ await self.permissions.hard_delete(permission, note)
130
+
131
+ def _permission_response(self, permission) -> PermissionResponse:
132
+ return PermissionResponse(
133
+ id=str(permission.id),
134
+ code=permission.code,
135
+ name=permission.name,
136
+ description=permission.description,
137
+ is_active=permission.is_active,
138
+ is_deleted=permission.is_deleted,
139
+ deleted_at=(
140
+ permission.deleted_at.isoformat() if permission.deleted_at else None
141
+ ),
142
+ deletion_note=permission.deletion_note,
143
+ )
144
+
145
+ def _permission_exists_in_user_role(self, user, permission) -> bool:
146
+ for role in user.roles:
147
+ if role.is_deleted or not role.is_active:
148
+ continue
149
+ if any(
150
+ role_permission.id == permission.id
151
+ and not role_permission.is_deleted
152
+ and role_permission.is_active
153
+ for role_permission in role.permissions
154
+ ):
155
+ return True
156
+ return False
@@ -0,0 +1,161 @@
1
+ from uuid import UUID
2
+
3
+ from sqlalchemy import func, select
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy.orm import selectinload
6
+
7
+ from app.db.models.permission import Role
8
+ from app.db.models.user import User
9
+ from app.repositories.base import BaseRepository
10
+
11
+
12
+ class RoleRepository(BaseRepository[Role]):
13
+ def __init__(self, session: AsyncSession):
14
+ super().__init__(Role, session)
15
+
16
+ async def get_active(
17
+ self,
18
+ page: int,
19
+ page_size: int,
20
+ pagination: bool = True,
21
+ search: str | None = None,
22
+ sort_by: str | None = None,
23
+ sort_order: str | None = None,
24
+ active_only: bool = True,
25
+ ) -> tuple[list[Role], int]:
26
+ filters = []
27
+ if active_only:
28
+ filters.append(Role.is_active == True)
29
+ return await self.get_all(
30
+ page=page,
31
+ page_size=page_size,
32
+ pagination=pagination,
33
+ filters=filters,
34
+ search=search,
35
+ search_columns=[Role.name, Role.description],
36
+ sort_by=sort_by,
37
+ sort_order=sort_order,
38
+ )
39
+
40
+ async def get_with_permissions(self, role_id: UUID) -> Role | None:
41
+ q = (
42
+ select(Role)
43
+ .where(Role.id == role_id, Role.is_deleted == False)
44
+ .options(selectinload(Role.permissions))
45
+ )
46
+ result = await self.session.execute(q)
47
+ return result.scalar_one_or_none()
48
+
49
+ async def get_by_name_with_permissions(self, name: str) -> Role | None:
50
+ q = (
51
+ select(Role)
52
+ .where(
53
+ Role.name == name,
54
+ Role.is_deleted == False,
55
+ Role.is_active == True,
56
+ )
57
+ .options(selectinload(Role.permissions))
58
+ )
59
+ result = await self.session.execute(q)
60
+ return result.scalar_one_or_none()
61
+
62
+ async def list_active_with_permissions(self) -> list[Role]:
63
+ q = (
64
+ select(Role)
65
+ .where(Role.is_deleted == False, Role.is_active == True)
66
+ .options(selectinload(Role.permissions))
67
+ .order_by(Role.name)
68
+ )
69
+ result = await self.session.execute(q)
70
+ return list(result.scalars().all())
71
+
72
+ async def is_descendant(self, role_id: UUID, ancestor_role_id: UUID) -> bool:
73
+ if role_id == ancestor_role_id:
74
+ return False
75
+ roles = await self.list_active_with_permissions()
76
+ by_id = {role.id: role for role in roles}
77
+ current = by_id.get(role_id)
78
+ visited = set()
79
+ while current and current.parent_role_id:
80
+ if current.parent_role_id == ancestor_role_id:
81
+ return True
82
+ if current.parent_role_id in visited:
83
+ return False
84
+ visited.add(current.parent_role_id)
85
+ current = by_id.get(current.parent_role_id)
86
+ return False
87
+
88
+ async def would_create_cycle(
89
+ self,
90
+ role_id: UUID,
91
+ parent_role_id: UUID | None,
92
+ ) -> bool:
93
+ if parent_role_id is None:
94
+ return False
95
+ if role_id == parent_role_id:
96
+ return True
97
+ return await self.is_descendant(parent_role_id, role_id)
98
+
99
+ async def has_active_children(self, role_id: UUID) -> bool:
100
+ q = (
101
+ select(func.count())
102
+ .select_from(Role)
103
+ .where(
104
+ Role.parent_role_id == role_id,
105
+ Role.is_deleted == False,
106
+ Role.is_active == True,
107
+ )
108
+ )
109
+ return (await self.session.execute(q)).scalar_one() > 0
110
+
111
+ async def exists_for_other_role(
112
+ self,
113
+ field: str,
114
+ value: str,
115
+ role_id: UUID,
116
+ ) -> bool:
117
+ q = (
118
+ select(func.count())
119
+ .select_from(Role)
120
+ .where(
121
+ getattr(Role, field) == value,
122
+ Role.id != role_id,
123
+ )
124
+ )
125
+ return (await self.session.execute(q)).scalar_one() > 0
126
+
127
+
128
+ class RoleAssignmentRepository(BaseRepository[User]):
129
+ def __init__(self, session: AsyncSession):
130
+ super().__init__(User, session)
131
+
132
+ async def get_user(self, user_id: UUID) -> User | None:
133
+ q = (
134
+ select(User)
135
+ .where(User.id == user_id, User.is_deleted == False)
136
+ .options(
137
+ selectinload(User.roles).selectinload(Role.permissions),
138
+ selectinload(User.extra_permissions),
139
+ )
140
+ )
141
+ result = await self.session.execute(q)
142
+ return result.scalar_one_or_none()
143
+
144
+ async def get_role(self, role_id: UUID) -> Role | None:
145
+ q = (
146
+ select(Role)
147
+ .where(
148
+ Role.id == role_id,
149
+ Role.is_deleted == False,
150
+ Role.is_active == True,
151
+ )
152
+ .options(selectinload(Role.permissions))
153
+ )
154
+ result = await self.session.execute(q)
155
+ return result.scalar_one_or_none()
156
+
157
+ def get_role_from_user(self, user: User) -> Role | None:
158
+ for role in user.roles:
159
+ if not role.is_deleted and role.is_active:
160
+ return role
161
+ return None