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,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,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
|
+
}
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|