shaapi 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.
- shaapi/__init__.py +3 -0
- shaapi/cli.py +97 -0
- shaapi/generator.py +114 -0
- shaapi/template/.dockerignore +37 -0
- shaapi/template/.env.template +59 -0
- shaapi/template/.gitattributes +12 -0
- shaapi/template/.gitignore +170 -0
- shaapi/template/.gitlab-ci.yml +89 -0
- shaapi/template/Dockerfile +59 -0
- shaapi/template/LICENSE +21 -0
- shaapi/template/README.md +206 -0
- shaapi/template/backend/.gitignore +164 -0
- shaapi/template/backend/__init__.py +0 -0
- shaapi/template/backend/alembic/README +1 -0
- shaapi/template/backend/alembic/env.py +102 -0
- shaapi/template/backend/alembic/script.py.mako +26 -0
- shaapi/template/backend/alembic/versions/2026_06_08_1024-64524c63b666_initial.py +143 -0
- shaapi/template/backend/alembic.ini +117 -0
- shaapi/template/backend/app/__init__.py +55 -0
- shaapi/template/backend/app/admin/__init__.py +1 -0
- shaapi/template/backend/app/admin/api/v1/__init__.py +0 -0
- shaapi/template/backend/app/admin/api/v1/auth.py +59 -0
- shaapi/template/backend/app/admin/api/v1/casbin.py +218 -0
- shaapi/template/backend/app/admin/api/v1/login_log.py +63 -0
- shaapi/template/backend/app/admin/api/v1/opera_log.py +61 -0
- shaapi/template/backend/app/admin/api/v1/role.py +108 -0
- shaapi/template/backend/app/admin/api/v1/user.py +47 -0
- shaapi/template/backend/app/admin/schema/casbin_rule.py +45 -0
- shaapi/template/backend/app/admin/schema/login_log.py +36 -0
- shaapi/template/backend/app/admin/schema/opera_log.py +43 -0
- shaapi/template/backend/app/admin/schema/role.py +36 -0
- shaapi/template/backend/app/admin/schema/sso.py +37 -0
- shaapi/template/backend/app/admin/schema/token.py +74 -0
- shaapi/template/backend/app/admin/schema/user.py +93 -0
- shaapi/template/backend/app/admin/service/auth_service.py +233 -0
- shaapi/template/backend/app/admin/service/casbin_service.py +135 -0
- shaapi/template/backend/app/admin/service/login_log_service.py +62 -0
- shaapi/template/backend/app/admin/service/opera_log_service.py +31 -0
- shaapi/template/backend/app/admin/service/role_service.py +79 -0
- shaapi/template/backend/app/admin/service/secure_token_service.py +60 -0
- shaapi/template/backend/app/admin/service/user_service.py +153 -0
- shaapi/template/backend/app/api.py +11 -0
- shaapi/template/backend/common/__init__.py +0 -0
- shaapi/template/backend/common/cloud_storage/__init__.py +11 -0
- shaapi/template/backend/common/cloud_storage/cloud_storage.py +180 -0
- shaapi/template/backend/common/dataclasses.py +52 -0
- shaapi/template/backend/common/email_conf/email.py +105 -0
- shaapi/template/backend/common/enums.py +144 -0
- shaapi/template/backend/common/exception/__init__.py +0 -0
- shaapi/template/backend/common/exception/errors.py +87 -0
- shaapi/template/backend/common/exception/exception_handler.py +280 -0
- shaapi/template/backend/common/log.py +123 -0
- shaapi/template/backend/common/model.py +68 -0
- shaapi/template/backend/common/pagination.py +83 -0
- shaapi/template/backend/common/response/__init__.py +0 -0
- shaapi/template/backend/common/response/response_code.py +158 -0
- shaapi/template/backend/common/response/response_schema.py +110 -0
- shaapi/template/backend/common/schema.py +144 -0
- shaapi/template/backend/common/security/jwt.py +203 -0
- shaapi/template/backend/common/security/rbac.py +98 -0
- shaapi/template/backend/common/security/sec_token.py +6 -0
- shaapi/template/backend/common/socketio/action.py +11 -0
- shaapi/template/backend/common/socketio/server.py +50 -0
- shaapi/template/backend/common/sso/base.py +69 -0
- shaapi/template/backend/common/sso/google.py +127 -0
- shaapi/template/backend/core/conf.py +208 -0
- shaapi/template/backend/core/path_conf.py +24 -0
- shaapi/template/backend/core/registrar.py +195 -0
- shaapi/template/backend/crud/__init__.py +1 -0
- shaapi/template/backend/crud/crud_base.py +35 -0
- shaapi/template/backend/crud/crud_casbin.py +46 -0
- shaapi/template/backend/crud/crud_login_log.py +58 -0
- shaapi/template/backend/crud/crud_opera_log.py +58 -0
- shaapi/template/backend/crud/crud_role.py +128 -0
- shaapi/template/backend/crud/crud_user.py +267 -0
- shaapi/template/backend/database/__init__.py +0 -0
- shaapi/template/backend/database/db_postgres.py +125 -0
- shaapi/template/backend/database/db_redis.py +62 -0
- shaapi/template/backend/entrypoint-api.sh +19 -0
- shaapi/template/backend/lang/en/app.py +18 -0
- shaapi/template/backend/lang/en/auth.py +10 -0
- shaapi/template/backend/lang/fr/app.py +18 -0
- shaapi/template/backend/lang/fr/auth.py +10 -0
- shaapi/template/backend/main.py +54 -0
- shaapi/template/backend/middleware/__init__.py +1 -0
- shaapi/template/backend/middleware/access_middleware.py +19 -0
- shaapi/template/backend/middleware/i18n_middleware.py +19 -0
- shaapi/template/backend/middleware/jwt_auth_middleware.py +73 -0
- shaapi/template/backend/middleware/opera_log_middleware.py +179 -0
- shaapi/template/backend/middleware/state_middleware.py +26 -0
- shaapi/template/backend/models/__init__.py +10 -0
- shaapi/template/backend/models/associations.py +20 -0
- shaapi/template/backend/models/casbin_rule.py +30 -0
- shaapi/template/backend/models/login_log.py +28 -0
- shaapi/template/backend/models/opera_log.py +36 -0
- shaapi/template/backend/models/role.py +27 -0
- shaapi/template/backend/models/user.py +30 -0
- shaapi/template/backend/seeder/json/admin.json +15 -0
- shaapi/template/backend/seeder/json/user.json +15 -0
- shaapi/template/backend/seeder/run.py +34 -0
- shaapi/template/backend/static/ip2region.xdb +0 -0
- shaapi/template/backend/templates/build/meet.html +169 -0
- shaapi/template/backend/templates/build/new_account.html +373 -0
- shaapi/template/backend/templates/build/reset-password.html +170 -0
- shaapi/template/backend/templates/build/test_email.html +25 -0
- shaapi/template/backend/templates/build/welcome-one-1.html +160 -0
- shaapi/template/backend/templates/build/welcome-one.html +178 -0
- shaapi/template/backend/templates/build/welcome-two.html +234 -0
- shaapi/template/backend/templates/index.html +0 -0
- shaapi/template/backend/templates/src/new_account.mjml +15 -0
- shaapi/template/backend/templates/src/reset_password.mjml +19 -0
- shaapi/template/backend/templates/src/test_email.mjml +11 -0
- shaapi/template/backend/templates/ws/ws.html +70 -0
- shaapi/template/backend/utils/demo_site.py +18 -0
- shaapi/template/backend/utils/encrypt.py +108 -0
- shaapi/template/backend/utils/health_check.py +34 -0
- shaapi/template/backend/utils/prometheus.py +135 -0
- shaapi/template/backend/utils/request_parse.py +110 -0
- shaapi/template/backend/utils/serializers.py +75 -0
- shaapi/template/backend/utils/timezone.py +51 -0
- shaapi/template/backend/utils/trace_id.py +7 -0
- shaapi/template/backend/utils/translator.py +28 -0
- shaapi/template/devops/scripts/deploy.sh +7 -0
- shaapi/template/devops/scripts/setup_env.sh +62 -0
- shaapi/template/docker-compose.monitoring.yml +63 -0
- shaapi/template/docker-compose.override.yml +12 -0
- shaapi/template/docker-compose.yml +90 -0
- shaapi/template/docker-run.sh +99 -0
- shaapi/template/etc/dashboards/fastapi-observability.json +1044 -0
- shaapi/template/etc/dashboards.yaml +10 -0
- shaapi/template/etc/grafana/datasource.yml +79 -0
- shaapi/template/etc/prometheus/prometheus.yml +52 -0
- shaapi/template/package-lock.json +2102 -0
- shaapi/template/package.json +16 -0
- shaapi/template/pyproject.toml +78 -0
- shaapi/template/uv.lock +2866 -0
- shaapi-0.1.0.dist-info/METADATA +92 -0
- shaapi-0.1.0.dist-info/RECORD +141 -0
- shaapi-0.1.0.dist-info/WHEEL +4 -0
- shaapi-0.1.0.dist-info/entry_points.txt +2 -0
- shaapi-0.1.0.dist-info/licenses/LICENCE +21 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from fast_captcha import text_captcha
|
|
2
|
+
from sqlalchemy import and_, desc, select
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlalchemy.sql import Select
|
|
5
|
+
|
|
6
|
+
from backend.models import User
|
|
7
|
+
from backend.crud.crud_role import role_dao
|
|
8
|
+
from backend.app.admin.schema.user import (
|
|
9
|
+
UserRegister,
|
|
10
|
+
UserUpdate
|
|
11
|
+
)
|
|
12
|
+
from backend.common.security.jwt import get_hash_password
|
|
13
|
+
from backend.common.enums import Role as Role_enum
|
|
14
|
+
from backend.utils.timezone import timezone
|
|
15
|
+
from backend.crud.crud_base import CRUDBase
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CRUDUser(CRUDBase[User]):
|
|
19
|
+
async def get(self, db: AsyncSession, admin_id: int) -> User | None:
|
|
20
|
+
"""
|
|
21
|
+
Getting admin
|
|
22
|
+
|
|
23
|
+
:param db:
|
|
24
|
+
:param admin_id:
|
|
25
|
+
:return:
|
|
26
|
+
"""
|
|
27
|
+
return await self.select_model(db, admin_id)
|
|
28
|
+
|
|
29
|
+
async def get_by_x_id(self, db: AsyncSession, x_id: str, populates: list = []) -> User | None:
|
|
30
|
+
"""
|
|
31
|
+
Get admin by email
|
|
32
|
+
|
|
33
|
+
:param db:
|
|
34
|
+
:param email:
|
|
35
|
+
:return:
|
|
36
|
+
"""
|
|
37
|
+
return await self.select_model_by_column(db, x_id=x_id, populates=populates)
|
|
38
|
+
|
|
39
|
+
async def get_by_email(self, db: AsyncSession, email: str, populates: list = []) -> User | None:
|
|
40
|
+
"""
|
|
41
|
+
Get admin by email
|
|
42
|
+
|
|
43
|
+
:param db:
|
|
44
|
+
:param email:
|
|
45
|
+
:return:
|
|
46
|
+
"""
|
|
47
|
+
return await self.select_model_by_column(db, email=email, populates=populates)
|
|
48
|
+
|
|
49
|
+
async def get_by_phone(self, db: AsyncSession, phone: str) -> User | None:
|
|
50
|
+
"""
|
|
51
|
+
Get admin by phone
|
|
52
|
+
|
|
53
|
+
:param db:
|
|
54
|
+
:param phone:
|
|
55
|
+
:return:
|
|
56
|
+
"""
|
|
57
|
+
return await self.select_model_by_column(db, phone=phone, populates=['roles'])
|
|
58
|
+
|
|
59
|
+
async def get_by_pseudo(self, db: AsyncSession, pseudo: str) -> User | None:
|
|
60
|
+
"""
|
|
61
|
+
Get admin by pseudo
|
|
62
|
+
|
|
63
|
+
:param db:
|
|
64
|
+
:param pseudo:
|
|
65
|
+
:return:
|
|
66
|
+
"""
|
|
67
|
+
return await self.select_model_by_column(db, pseudo=pseudo, populates=['roles'])
|
|
68
|
+
|
|
69
|
+
async def update_login_time(self, db: AsyncSession, email: str) -> int:
|
|
70
|
+
"""
|
|
71
|
+
Update login time
|
|
72
|
+
|
|
73
|
+
:param db:
|
|
74
|
+
:param email:
|
|
75
|
+
:return:
|
|
76
|
+
"""
|
|
77
|
+
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, email=email)
|
|
78
|
+
|
|
79
|
+
async def create(self, db: AsyncSession, obj: UserRegister, *, social: bool = False) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Create User
|
|
82
|
+
|
|
83
|
+
:param db:
|
|
84
|
+
:param obj:
|
|
85
|
+
:param social: Social admins, adapted to oauth 2.0
|
|
86
|
+
:return:
|
|
87
|
+
"""
|
|
88
|
+
if not social:
|
|
89
|
+
salt = text_captcha(5)
|
|
90
|
+
obj.password = get_hash_password(f'{obj.password}{salt}')
|
|
91
|
+
dict_obj = obj.model_dump()
|
|
92
|
+
dict_obj.update({'salt': salt})
|
|
93
|
+
else:
|
|
94
|
+
dict_obj = obj.model_dump()
|
|
95
|
+
dict_obj.update({'salt': None})
|
|
96
|
+
|
|
97
|
+
new_admin = self.model(**dict_obj)
|
|
98
|
+
await db.add(db, new_admin)
|
|
99
|
+
|
|
100
|
+
async def add(self, db: AsyncSession, obj: UserRegister) -> User:
|
|
101
|
+
"""
|
|
102
|
+
Add admin
|
|
103
|
+
|
|
104
|
+
:param db:
|
|
105
|
+
:param obj:
|
|
106
|
+
:return:
|
|
107
|
+
"""
|
|
108
|
+
salt = text_captcha(5)
|
|
109
|
+
obj.password = get_hash_password(f'{obj.password}{salt}')
|
|
110
|
+
dict_obj = obj.model_dump(exclude={'roles'})
|
|
111
|
+
|
|
112
|
+
dict_obj.update({'salt': salt})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
new_admin = self.model(**dict_obj)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
role = await role_dao.get_by_name(db, Role_enum.ADMIN.value)
|
|
119
|
+
|
|
120
|
+
role_list = []
|
|
121
|
+
if role is not None:
|
|
122
|
+
role_list.append(role)
|
|
123
|
+
new_admin.roles.extend(role_list)
|
|
124
|
+
db.add(new_admin)
|
|
125
|
+
await db.flush()
|
|
126
|
+
await db.refresh(new_admin)
|
|
127
|
+
return new_admin
|
|
128
|
+
|
|
129
|
+
async def update_admin_info(self, db: AsyncSession, input_admin: int, obj: UserUpdate) -> int:
|
|
130
|
+
"""
|
|
131
|
+
Updating admin information
|
|
132
|
+
|
|
133
|
+
:param db:
|
|
134
|
+
:param input_admin:
|
|
135
|
+
:param obj:
|
|
136
|
+
:return:
|
|
137
|
+
"""
|
|
138
|
+
return await self.update_model(db, input_admin, obj)
|
|
139
|
+
|
|
140
|
+
async def update_profile_image(self, db: AsyncSession, input_admin: int, profile_image: dict) -> int:
|
|
141
|
+
"""
|
|
142
|
+
Update admin profile image
|
|
143
|
+
|
|
144
|
+
:param db:
|
|
145
|
+
:param input_admin:
|
|
146
|
+
:param avatar:
|
|
147
|
+
:return:
|
|
148
|
+
"""
|
|
149
|
+
return await self.update_model(db, input_admin, {'profile_image': dict})
|
|
150
|
+
|
|
151
|
+
async def check_email(self, db: AsyncSession, email: str) -> User | None:
|
|
152
|
+
"""
|
|
153
|
+
Check admin email
|
|
154
|
+
|
|
155
|
+
:param db:
|
|
156
|
+
:param email:
|
|
157
|
+
:return:
|
|
158
|
+
"""
|
|
159
|
+
return await self.select_model_by_column(db, email=email)
|
|
160
|
+
|
|
161
|
+
async def reset_password(self, db: AsyncSession, pk: int, new_pwd: str) -> int:
|
|
162
|
+
"""
|
|
163
|
+
Reset admin password
|
|
164
|
+
|
|
165
|
+
:param db:
|
|
166
|
+
:param pk:
|
|
167
|
+
:param new_pwd:
|
|
168
|
+
:return:
|
|
169
|
+
"""
|
|
170
|
+
return await self.update_model(db, pk, {'password': new_pwd})
|
|
171
|
+
|
|
172
|
+
async def get_list(self, email: str = None, phone: str = None, status: bool = None, role: str | None = None) -> Select:
|
|
173
|
+
"""
|
|
174
|
+
Get admin list
|
|
175
|
+
|
|
176
|
+
:param dept:
|
|
177
|
+
:param adminname:
|
|
178
|
+
:param phone:
|
|
179
|
+
:param status:
|
|
180
|
+
:return:
|
|
181
|
+
"""
|
|
182
|
+
stmt = (
|
|
183
|
+
select(self.model)
|
|
184
|
+
.order_by(desc(self.model.join_time))
|
|
185
|
+
)
|
|
186
|
+
where_list = []
|
|
187
|
+
if email:
|
|
188
|
+
where_list.append(self.model.email.like(f'%{email}%'))
|
|
189
|
+
if phone:
|
|
190
|
+
where_list.append(self.model.phone.like(f'%{phone}%'))
|
|
191
|
+
if status is not None:
|
|
192
|
+
where_list.append(self.model.status == status)
|
|
193
|
+
if where_list:
|
|
194
|
+
stmt = stmt.where(and_(*where_list))
|
|
195
|
+
return stmt
|
|
196
|
+
|
|
197
|
+
async def get_status(self, db: AsyncSession, admin_id: int) -> int:
|
|
198
|
+
"""
|
|
199
|
+
Get admin status
|
|
200
|
+
|
|
201
|
+
:param db:
|
|
202
|
+
:param admin_id:
|
|
203
|
+
:return:
|
|
204
|
+
"""
|
|
205
|
+
admin = await self.get(db, admin_id)
|
|
206
|
+
return admin.status
|
|
207
|
+
|
|
208
|
+
async def get_multi_login(self, db: AsyncSession, admin_id: int) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Get admin multipoint login status
|
|
211
|
+
|
|
212
|
+
:param db:
|
|
213
|
+
:param admin_id:
|
|
214
|
+
:return:
|
|
215
|
+
"""
|
|
216
|
+
admin = await self.get(db, admin_id)
|
|
217
|
+
return admin.is_multi_login
|
|
218
|
+
|
|
219
|
+
async def set_status(self, db: AsyncSession, admin_id: int, status: bool) -> int:
|
|
220
|
+
"""
|
|
221
|
+
Set admin account status
|
|
222
|
+
|
|
223
|
+
:param db:
|
|
224
|
+
:param admin_id:
|
|
225
|
+
:param status:
|
|
226
|
+
:return:
|
|
227
|
+
"""
|
|
228
|
+
return await self.update_model(db, admin_id, {'status': status})
|
|
229
|
+
|
|
230
|
+
async def set_multi_login(self, db: AsyncSession, admin_id: int, multi_login: bool) -> int:
|
|
231
|
+
"""
|
|
232
|
+
Set multi login
|
|
233
|
+
|
|
234
|
+
:param db:
|
|
235
|
+
:param admin_id:
|
|
236
|
+
:param multi_login:
|
|
237
|
+
:return:
|
|
238
|
+
"""
|
|
239
|
+
return await self.update_model(db, admin_id, {'is_multi_login': multi_login})
|
|
240
|
+
|
|
241
|
+
async def get_with_relation(self, db: AsyncSession, *, id: int = None, x_id: str = None, email: str = None, populates: list = []) -> User | None:
|
|
242
|
+
"""
|
|
243
|
+
Get admin and (roles)
|
|
244
|
+
|
|
245
|
+
:param db:
|
|
246
|
+
:param admin_id:
|
|
247
|
+
:param email:
|
|
248
|
+
:return:
|
|
249
|
+
"""
|
|
250
|
+
stmt = (
|
|
251
|
+
select(self.model)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
filters = []
|
|
255
|
+
if id:
|
|
256
|
+
filters.append(self.model.id == id)
|
|
257
|
+
if email:
|
|
258
|
+
filters.append(self.model.email == email)
|
|
259
|
+
if x_id:
|
|
260
|
+
filters.append(self.model.x_id == x_id)
|
|
261
|
+
|
|
262
|
+
stmt = self.get_with_relationship(stmt, populates=populates)
|
|
263
|
+
admin = await db.execute(stmt.where(*filters))
|
|
264
|
+
return admin.scalars().first()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
user_dao: CRUDUser = CRUDUser(User)
|
|
File without changes
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Generator
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from sqlalchemy import MetaData, create_engine
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends
|
|
8
|
+
from sqlalchemy import URL
|
|
9
|
+
from sqlalchemy.orm import Session
|
|
10
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
11
|
+
from collections.abc import AsyncGenerator
|
|
12
|
+
from sqlalchemy import event
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from backend.common.log import log
|
|
16
|
+
from backend.common.model import MappedBase
|
|
17
|
+
from backend.core.conf import settings
|
|
18
|
+
from sqlalchemy.orm import sessionmaker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SQLALCHEMY_DATABASE_URL = (
|
|
22
|
+
f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:"
|
|
23
|
+
f"{settings.POSTGRES_PORT}/{settings.POSTGRES_DATABASE}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
engine = create_engine(
|
|
28
|
+
SQLALCHEMY_DATABASE_URL,
|
|
29
|
+
pool_pre_ping=True,
|
|
30
|
+
echo=False,
|
|
31
|
+
future=True,
|
|
32
|
+
)
|
|
33
|
+
session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print(f"DB connection error. detail={e}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_engine_and_session(url: str | URL):
|
|
39
|
+
try:
|
|
40
|
+
# database engine
|
|
41
|
+
engine = create_async_engine(
|
|
42
|
+
url, echo=settings.POSTGRES_ECHO, future=True, pool_pre_ping=True
|
|
43
|
+
)
|
|
44
|
+
# log.success('Database Connection Successful')
|
|
45
|
+
except Exception as e:
|
|
46
|
+
log.error("❌ Database link failure {}", e)
|
|
47
|
+
sys.exit()
|
|
48
|
+
else:
|
|
49
|
+
db_session = async_sessionmaker(
|
|
50
|
+
bind=engine, autoflush=False, expire_on_commit=False
|
|
51
|
+
)
|
|
52
|
+
return engine, db_session
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async_engine, async_db_session = create_engine_and_session(SQLALCHEMY_DATABASE_URL)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
|
59
|
+
"""session generator"""
|
|
60
|
+
session = async_db_session()
|
|
61
|
+
try:
|
|
62
|
+
yield session
|
|
63
|
+
except Exception as se:
|
|
64
|
+
await session.rollback()
|
|
65
|
+
raise se
|
|
66
|
+
finally:
|
|
67
|
+
await session.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Session Annotated
|
|
71
|
+
CurrentSession = Annotated[AsyncSession, Depends(get_async_db)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_db() -> Generator[Session, None, None]:
|
|
75
|
+
"""
|
|
76
|
+
Create a database session when accessing from an endpoint, using Depend
|
|
77
|
+
If there are no errors, validate.
|
|
78
|
+
If there is an error, go back and close in all cases.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
db = None
|
|
82
|
+
try:
|
|
83
|
+
db = session_factory()
|
|
84
|
+
yield db
|
|
85
|
+
db.commit()
|
|
86
|
+
except Exception:
|
|
87
|
+
if db:
|
|
88
|
+
db.rollback()
|
|
89
|
+
finally:
|
|
90
|
+
if db:
|
|
91
|
+
db.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def create_table():
|
|
95
|
+
"""Creating Database Tables"""
|
|
96
|
+
async with async_engine.begin() as coon:
|
|
97
|
+
await coon.run_sync(MappedBase.metadata.create_all)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def uuid4_str() -> str:
|
|
101
|
+
"""Database Engine UUID Type Compatibility Solution"""
|
|
102
|
+
return str(uuid4())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def drop_all_tables() -> None:
|
|
106
|
+
print("start: drop_all_tables")
|
|
107
|
+
"""
|
|
108
|
+
Delete all tables, types, Roles, etc.
|
|
109
|
+
and return to initial state (development environment only)
|
|
110
|
+
"""
|
|
111
|
+
if settings.ENVIRONMENT != "dev":
|
|
112
|
+
# Run only in local environnement
|
|
113
|
+
print("drop_all_table() should be run only in dev env.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
metadata = MetaData()
|
|
117
|
+
metadata.reflect(bind=engine)
|
|
118
|
+
|
|
119
|
+
for table_key in metadata.tables:
|
|
120
|
+
table = metadata.tables.get(table_key)
|
|
121
|
+
if table is not None:
|
|
122
|
+
print(f"Deleting {table_key} table")
|
|
123
|
+
metadata.drop_all(engine, [table], checkfirst=True)
|
|
124
|
+
|
|
125
|
+
print("end: drop_all_tables")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from redis.asyncio import Redis
|
|
4
|
+
from redis.exceptions import AuthenticationError, TimeoutError
|
|
5
|
+
|
|
6
|
+
from backend.common.log import log
|
|
7
|
+
from backend.core.conf import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisCli(Redis):
|
|
11
|
+
def __init__(self):
|
|
12
|
+
super(RedisCli, self).__init__(
|
|
13
|
+
host=settings.REDIS_HOST,
|
|
14
|
+
port=settings.REDIS_PORT,
|
|
15
|
+
# password=settings.REDIS_PASSWORD,
|
|
16
|
+
db=settings.REDIS_DATABASE,
|
|
17
|
+
socket_timeout=settings.REDIS_TIMEOUT,
|
|
18
|
+
decode_responses=True, # Transcoding utf-8
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def open(self):
|
|
22
|
+
"""
|
|
23
|
+
Trigger Initialization Connection
|
|
24
|
+
|
|
25
|
+
:return:
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
await self.ping()
|
|
29
|
+
except TimeoutError:
|
|
30
|
+
log.error('❌ Database redis connection timeout')
|
|
31
|
+
sys.exit()
|
|
32
|
+
except AuthenticationError:
|
|
33
|
+
log.error('❌ Database redis connection authentication failed')
|
|
34
|
+
sys.exit()
|
|
35
|
+
except Exception as e:
|
|
36
|
+
log.error('❌ Database redis connection exception {}', e)
|
|
37
|
+
sys.exit()
|
|
38
|
+
|
|
39
|
+
async def delete_prefix(self, prefix: str, exclude: str | list = None):
|
|
40
|
+
"""
|
|
41
|
+
Delete all keys with the specified prefix
|
|
42
|
+
|
|
43
|
+
:param prefix:
|
|
44
|
+
:param exclude:
|
|
45
|
+
:return:
|
|
46
|
+
"""
|
|
47
|
+
keys = []
|
|
48
|
+
async for key in self.scan_iter(match=f'{prefix}*'):
|
|
49
|
+
if isinstance(exclude, str):
|
|
50
|
+
if key != exclude:
|
|
51
|
+
keys.append(key)
|
|
52
|
+
elif isinstance(exclude, list):
|
|
53
|
+
if key not in exclude:
|
|
54
|
+
keys.append(key)
|
|
55
|
+
else:
|
|
56
|
+
keys.append(key)
|
|
57
|
+
if keys:
|
|
58
|
+
await self.delete(*keys)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Create a redis client instance
|
|
62
|
+
redis_client = RedisCli()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
cd backend
|
|
5
|
+
|
|
6
|
+
# Apply database migrations (idempotent: no-op if already up to date).
|
|
7
|
+
# Migrations are authored explicitly with `shaapi makemigrations`, never
|
|
8
|
+
# auto-generated at boot.
|
|
9
|
+
alembic upgrade head
|
|
10
|
+
|
|
11
|
+
# Live-reload in development (the source is bind-mounted by the dev compose).
|
|
12
|
+
# In production (ENVIRONMENT=prod) the baked-in code is served without reload.
|
|
13
|
+
RELOAD=""
|
|
14
|
+
if [ "${ENVIRONMENT:-dev}" = "dev" ]; then
|
|
15
|
+
RELOAD="--reload"
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Start the API server (exec => uvicorn becomes PID 1 and receives signals)
|
|
19
|
+
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000 $RELOAD
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
locale = {
|
|
2
|
+
"http_200": "Request successful",
|
|
3
|
+
"http_201": "Resource created successfully",
|
|
4
|
+
"http_202": "Request accepted but not yet completed",
|
|
5
|
+
"http_204": "Request successful but no content returned",
|
|
6
|
+
"http_400": "Bad request",
|
|
7
|
+
"http_401": "Unauthorized",
|
|
8
|
+
"http_403": "Forbidden",
|
|
9
|
+
"http_404": "Resource not found",
|
|
10
|
+
"http_410": "Resource permanently deleted",
|
|
11
|
+
"http_422": "Invalid request parameters",
|
|
12
|
+
"http_425": "Request cannot be processed due to server limitations",
|
|
13
|
+
"http_429": "Too many requests, server rate limit reached",
|
|
14
|
+
"http_500": "Internal server error",
|
|
15
|
+
"http_502": "Gateway error",
|
|
16
|
+
"http_503": "Service unavailable",
|
|
17
|
+
"http_504": "Gateway timeout"
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
locale = {
|
|
2
|
+
'incorrect_credential': 'Incorrect email or password',
|
|
3
|
+
'account_locked': 'This account has been locked out. Please contact the system administrator',
|
|
4
|
+
'successful': 'Login Successful',
|
|
5
|
+
'refresh_token_not_found': 'Refresh Token not found',
|
|
6
|
+
'invalid_refresh_token': 'Refresh Token is invalid',
|
|
7
|
+
'enpty_password': 'Empty password',
|
|
8
|
+
'exist': 'this account already exist',
|
|
9
|
+
'code_not_found': 'We can\'t proced this request, try again.',
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
locale = {
|
|
2
|
+
"http_200": "Requête réussie",
|
|
3
|
+
"http_201": "Ressource créée avec succès",
|
|
4
|
+
"http_202": "Requête acceptée mais pas encore terminée",
|
|
5
|
+
"http_204": "Requête réussie mais aucun contenu retourné",
|
|
6
|
+
"http_400": "Mauvaise requête",
|
|
7
|
+
"http_401": "Non autorisé",
|
|
8
|
+
"http_403": "Interdit",
|
|
9
|
+
"http_404": "Ressource non trouvée",
|
|
10
|
+
"http_410": "Ressource supprimée de façon permanente",
|
|
11
|
+
"http_422": "Paramètres de la requête non valides",
|
|
12
|
+
"http_425": "La requête ne peut pas être traitée en raison des limitations du serveur",
|
|
13
|
+
"http_429": "Trop de requêtes, limite de débit du serveur atteinte",
|
|
14
|
+
"http_500": "Erreur interne du serveur",
|
|
15
|
+
"http_502": "Erreur de passerelle",
|
|
16
|
+
"http_503": "Service indisponible",
|
|
17
|
+
"http_504": "Délai d'attente de la passerelle dépassé"
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
locale = {
|
|
2
|
+
'incorrect_credential': 'Email ou mot de passe incorrect',
|
|
3
|
+
'account_locked': 'Ce compte a été verrouillé. Veuillez contacter l\'administrateur du système',
|
|
4
|
+
'successful': 'Connexion réussie',
|
|
5
|
+
'refresh_token_not_found': 'Le Jeton de rafraîchissement est introuvable, veuillez vous reconnecter',
|
|
6
|
+
'invalid_refresh_token': 'Le jeton de rafraîchissement est invalide',
|
|
7
|
+
'enpty_password': 'Mot de passe vide',
|
|
8
|
+
'exist': 'Ce compte existe déjà',
|
|
9
|
+
'code_not_found': 'Nous ne pouvons traiter cette demande veuillez réessayer plus tard.',
|
|
10
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""shaapi application entry point.
|
|
2
|
+
|
|
3
|
+
The top-level FastAPI app is a thin parent that mounts the feature
|
|
4
|
+
sub-applications (currently ``/admin``: auth, users, roles, RBAC...).
|
|
5
|
+
Each sub-app owns its own middleware, lifespan and OpenAPI docs.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
|
|
12
|
+
from backend.core.conf import settings
|
|
13
|
+
from backend.core.registrar import register_app, register_init
|
|
14
|
+
from backend.app.api import admin_router
|
|
15
|
+
|
|
16
|
+
# The parent app owns the lifespan (DB tables, Redis, rate limiter) because
|
|
17
|
+
# Starlette does not run the lifespan of mounted sub-applications.
|
|
18
|
+
app = FastAPI(
|
|
19
|
+
title=settings.FASTAPI_TITLE,
|
|
20
|
+
lifespan=register_init,
|
|
21
|
+
docs_url=None,
|
|
22
|
+
redoc_url=None,
|
|
23
|
+
openapi_url=None,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Observability (opt-in). Enable with OBSERVABILITY_ENABLED=true and a valid
|
|
27
|
+
# OTLP_GRPC_ENDPOINT. Kept out of the lean core so the app boots without the
|
|
28
|
+
# OpenTelemetry/Prometheus stack installed.
|
|
29
|
+
if settings.OBSERVABILITY_ENABLED and settings.OTLP_GRPC_ENDPOINT:
|
|
30
|
+
from backend.utils.prometheus import EndpointFilter, metrics, setting_otlp
|
|
31
|
+
|
|
32
|
+
setting_otlp(app, settings.APP_NAME, settings.OTLP_GRPC_ENDPOINT)
|
|
33
|
+
logging.getLogger("uvicorn.access").addFilter(EndpointFilter())
|
|
34
|
+
app.add_route("/metrics", metrics)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.get("/health", tags=["health"])
|
|
38
|
+
async def health() -> dict:
|
|
39
|
+
"""Liveness probe."""
|
|
40
|
+
return {"status": "ok"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Mount feature sub-applications
|
|
44
|
+
app.mount("/admin", register_app(admin_router, "admin"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
# Handy for IDE debugging. In Docker the entrypoint runs uvicorn directly.
|
|
49
|
+
uvicorn.run(
|
|
50
|
+
"backend.main:app",
|
|
51
|
+
host="0.0.0.0",
|
|
52
|
+
port=8000,
|
|
53
|
+
reload=settings.ENVIRONMENT == "dev",
|
|
54
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import Request, Response
|
|
2
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
3
|
+
|
|
4
|
+
from backend.common.log import log
|
|
5
|
+
from backend.utils.timezone import timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AccessMiddleware(BaseHTTPMiddleware):
|
|
9
|
+
"""Request Log Middleware"""
|
|
10
|
+
|
|
11
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
12
|
+
start_time = timezone.now()
|
|
13
|
+
response = await call_next(request)
|
|
14
|
+
end_time = timezone.now()
|
|
15
|
+
log.info(
|
|
16
|
+
f'{request.client.host: <15} | {request.method: <8} | {response.status_code: <6} | '
|
|
17
|
+
f'{request.url.path} | {round((end_time - start_time).total_seconds(), 3) * 1000.0}ms'
|
|
18
|
+
)
|
|
19
|
+
return response
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
2
|
+
from starlette.requests import Request
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class I18nMiddleware(BaseHTTPMiddleware):
|
|
6
|
+
WHITE_LIST = ['en', 'fr']
|
|
7
|
+
|
|
8
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint):
|
|
9
|
+
# 1. headers 2. path 3. query string
|
|
10
|
+
locale = request.headers.get('locale', None) or \
|
|
11
|
+
request.path_params.get('locale', None) or \
|
|
12
|
+
request.query_params.get('locale', None) or \
|
|
13
|
+
'fr'
|
|
14
|
+
|
|
15
|
+
if locale not in self.WHITE_LIST:
|
|
16
|
+
locale = 'fr'
|
|
17
|
+
request.state.locale = locale
|
|
18
|
+
|
|
19
|
+
return await call_next(request)
|