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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- 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 @@
|
|
|
1
|
+
|