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