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,280 @@
|
|
|
1
|
+
from fastapi import FastAPI, Request
|
|
2
|
+
from fastapi.exceptions import RequestValidationError
|
|
3
|
+
from pydantic import ValidationError
|
|
4
|
+
from pydantic.errors import PydanticUserError
|
|
5
|
+
from starlette.exceptions import HTTPException
|
|
6
|
+
from starlette.middleware.cors import CORSMiddleware
|
|
7
|
+
from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
|
|
8
|
+
|
|
9
|
+
from backend.common.exception.errors import BaseExceptionMixin
|
|
10
|
+
from backend.common.response.response_code import CustomResponseCode, StandardResponseCode
|
|
11
|
+
from backend.common.response.response_schema import response_base
|
|
12
|
+
from backend.common.schema import (
|
|
13
|
+
CUSTOM_USAGE_ERROR_MESSAGES,
|
|
14
|
+
CUSTOM_VALIDATION_ERROR_MESSAGES,
|
|
15
|
+
)
|
|
16
|
+
from backend.core.conf import settings
|
|
17
|
+
from backend.utils.serializers import MsgSpecJSONResponse
|
|
18
|
+
from backend.utils.trace_id import get_request_trace_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_exception_code(status_code: int):
|
|
22
|
+
"""
|
|
23
|
+
Get return status code, OpenAPI, Uvicorn... The available status codes are based on the RFC definitions, see the link below for detailed code.
|
|
24
|
+
|
|
25
|
+
`python Status Code Standard Support <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http
|
|
26
|
+
/__init__.py#L7>`__
|
|
27
|
+
|
|
28
|
+
`IANA status code registry <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__
|
|
29
|
+
|
|
30
|
+
:param status_code:
|
|
31
|
+
:return:
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
STATUS_PHRASES[status_code]
|
|
35
|
+
except Exception:
|
|
36
|
+
code = StandardResponseCode.HTTP_400
|
|
37
|
+
else:
|
|
38
|
+
code = status_code
|
|
39
|
+
return code
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError):
|
|
43
|
+
"""
|
|
44
|
+
Data Validation Exception Handling
|
|
45
|
+
|
|
46
|
+
:param e:
|
|
47
|
+
:return:
|
|
48
|
+
"""
|
|
49
|
+
errors = []
|
|
50
|
+
for error in e.errors():
|
|
51
|
+
custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
|
|
52
|
+
if custom_message:
|
|
53
|
+
ctx = error.get('ctx')
|
|
54
|
+
if not ctx:
|
|
55
|
+
error['msg'] = custom_message
|
|
56
|
+
else:
|
|
57
|
+
error['msg'] = custom_message.format(**ctx)
|
|
58
|
+
ctx_error = ctx.get('error')
|
|
59
|
+
if ctx_error:
|
|
60
|
+
error['ctx']['error'] = (
|
|
61
|
+
ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
|
|
62
|
+
)
|
|
63
|
+
errors.append(error)
|
|
64
|
+
error = errors[0]
|
|
65
|
+
if error.get('type') == 'json_invalid':
|
|
66
|
+
message = 'json parsing failure'
|
|
67
|
+
else:
|
|
68
|
+
error_input = error.get('input')
|
|
69
|
+
field = str(error.get('loc')[-1])
|
|
70
|
+
error_msg = error.get('msg')
|
|
71
|
+
message = f'{field} {error_msg} Input:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg
|
|
72
|
+
msg = f'Illegal request parameters: {message}'
|
|
73
|
+
data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None
|
|
74
|
+
content = {
|
|
75
|
+
'code': StandardResponseCode.HTTP_422,
|
|
76
|
+
'msg': msg,
|
|
77
|
+
'data': data,
|
|
78
|
+
}
|
|
79
|
+
request.state.__request_validation_exception__ = content # 用于在中间件中获取异常信息
|
|
80
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
81
|
+
return MsgSpecJSONResponse(status_code=422, content=content)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def register_exception(app: FastAPI):
|
|
85
|
+
@app.exception_handler(HTTPException)
|
|
86
|
+
async def http_exception_handler(request: Request, exc: HTTPException):
|
|
87
|
+
"""
|
|
88
|
+
Global HTTP Exception Handling
|
|
89
|
+
|
|
90
|
+
:param request:
|
|
91
|
+
:param exc:
|
|
92
|
+
:return:
|
|
93
|
+
"""
|
|
94
|
+
if settings.ENVIRONMENT == 'dev':
|
|
95
|
+
content = {
|
|
96
|
+
'code': exc.status_code,
|
|
97
|
+
'msg': exc.detail,
|
|
98
|
+
'data': None,
|
|
99
|
+
}
|
|
100
|
+
else:
|
|
101
|
+
res = response_base.fail(request=request, res=CustomResponseCode.HTTP_400)
|
|
102
|
+
content = res.model_dump()
|
|
103
|
+
request.state.__request_http_exception__ = content
|
|
104
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
105
|
+
return MsgSpecJSONResponse(
|
|
106
|
+
status_code=_get_exception_code(exc.status_code),
|
|
107
|
+
content=content,
|
|
108
|
+
headers=exc.headers,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@app.exception_handler(RequestValidationError)
|
|
112
|
+
async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
113
|
+
"""
|
|
114
|
+
fastapi Data Validation Exception Handling
|
|
115
|
+
|
|
116
|
+
:param request:
|
|
117
|
+
:param exc:
|
|
118
|
+
:return:
|
|
119
|
+
"""
|
|
120
|
+
return await _validation_exception_handler(request, exc)
|
|
121
|
+
|
|
122
|
+
@app.exception_handler(ValidationError)
|
|
123
|
+
async def pydantic_validation_exception_handler(request: Request, exc: ValidationError):
|
|
124
|
+
"""
|
|
125
|
+
pydantic Data Validation Exception Handling
|
|
126
|
+
|
|
127
|
+
:param request:
|
|
128
|
+
:param exc:
|
|
129
|
+
:return:
|
|
130
|
+
"""
|
|
131
|
+
return await _validation_exception_handler(request, exc)
|
|
132
|
+
|
|
133
|
+
@app.exception_handler(PydanticUserError)
|
|
134
|
+
async def pydantic_user_error_handler(request: Request, exc: PydanticUserError):
|
|
135
|
+
"""
|
|
136
|
+
Pydantic User Exception Handling
|
|
137
|
+
|
|
138
|
+
:param request:
|
|
139
|
+
:param exc:
|
|
140
|
+
:return:
|
|
141
|
+
"""
|
|
142
|
+
content = {
|
|
143
|
+
'code': StandardResponseCode.HTTP_500,
|
|
144
|
+
'msg': CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
|
|
145
|
+
'data': None,
|
|
146
|
+
}
|
|
147
|
+
request.state.__request_pydantic_user_error__ = content
|
|
148
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
149
|
+
return MsgSpecJSONResponse(
|
|
150
|
+
status_code=StandardResponseCode.HTTP_500,
|
|
151
|
+
content=content,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@app.exception_handler(AssertionError)
|
|
155
|
+
async def assertion_error_handler(request: Request, exc: AssertionError):
|
|
156
|
+
"""
|
|
157
|
+
assertion error handling
|
|
158
|
+
|
|
159
|
+
:param request:
|
|
160
|
+
:param exc:
|
|
161
|
+
:return:
|
|
162
|
+
"""
|
|
163
|
+
if settings.ENVIRONMENT == 'dev':
|
|
164
|
+
content = {
|
|
165
|
+
'code': StandardResponseCode.HTTP_500,
|
|
166
|
+
'msg': str(''.join(exc.args) if exc.args else exc.__doc__),
|
|
167
|
+
'data': None,
|
|
168
|
+
}
|
|
169
|
+
else:
|
|
170
|
+
res = response_base.fail(request=request, res=CustomResponseCode.HTTP_500)
|
|
171
|
+
content = res.model_dump()
|
|
172
|
+
request.state.__request_assertion_error__ = content
|
|
173
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
174
|
+
return MsgSpecJSONResponse(
|
|
175
|
+
status_code=StandardResponseCode.HTTP_500,
|
|
176
|
+
content=content,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@app.exception_handler(BaseExceptionMixin)
|
|
180
|
+
async def custom_exception_handler(request: Request, exc: BaseExceptionMixin):
|
|
181
|
+
"""
|
|
182
|
+
Global custom exception handling
|
|
183
|
+
|
|
184
|
+
:param request:
|
|
185
|
+
:param exc:
|
|
186
|
+
:return:
|
|
187
|
+
"""
|
|
188
|
+
content = {
|
|
189
|
+
'code': exc.code,
|
|
190
|
+
'msg': str(exc.msg),
|
|
191
|
+
'data': exc.data if exc.data else None,
|
|
192
|
+
}
|
|
193
|
+
request.state.__request_custom_exception__ = content
|
|
194
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
195
|
+
return MsgSpecJSONResponse(
|
|
196
|
+
status_code=_get_exception_code(exc.code),
|
|
197
|
+
content=content,
|
|
198
|
+
background=exc.background,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@app.exception_handler(Exception)
|
|
202
|
+
async def all_unknown_exception_handler(request: Request, exc: Exception):
|
|
203
|
+
"""
|
|
204
|
+
global unknown exception handling (GUE)
|
|
205
|
+
|
|
206
|
+
:param request:
|
|
207
|
+
:param exc:
|
|
208
|
+
:return:
|
|
209
|
+
"""
|
|
210
|
+
if settings.ENVIRONMENT == 'dev':
|
|
211
|
+
content = {
|
|
212
|
+
'code': StandardResponseCode.HTTP_500,
|
|
213
|
+
'msg': str(exc),
|
|
214
|
+
'data': None,
|
|
215
|
+
}
|
|
216
|
+
else:
|
|
217
|
+
res = response_base.fail(request=request, res=CustomResponseCode.HTTP_500)
|
|
218
|
+
content = res.model_dump()
|
|
219
|
+
request.state.__request_all_unknown_exception__ = content
|
|
220
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
221
|
+
return MsgSpecJSONResponse(
|
|
222
|
+
status_code=StandardResponseCode.HTTP_500,
|
|
223
|
+
content=content,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if settings.MIDDLEWARE_CORS:
|
|
227
|
+
|
|
228
|
+
@app.exception_handler(StandardResponseCode.HTTP_500)
|
|
229
|
+
async def cors_custom_code_500_exception_handler(request, exc):
|
|
230
|
+
"""
|
|
231
|
+
Cross Domain Customization 500 Exception Handling
|
|
232
|
+
|
|
233
|
+
`Related issue <https://github.com/encode/starlette/issues/1175>`_
|
|
234
|
+
`Solution <https://github.com/fastapi/fastapi/discussions/7847#discussioncomment-5144709>`_
|
|
235
|
+
|
|
236
|
+
:param request:
|
|
237
|
+
:param exc:
|
|
238
|
+
:return:
|
|
239
|
+
"""
|
|
240
|
+
if isinstance(exc, BaseExceptionMixin):
|
|
241
|
+
content = {
|
|
242
|
+
'code': exc.code,
|
|
243
|
+
'msg': exc.msg,
|
|
244
|
+
'data': exc.data,
|
|
245
|
+
}
|
|
246
|
+
else:
|
|
247
|
+
if settings.ENVIRONMENT == 'dev':
|
|
248
|
+
content = {
|
|
249
|
+
'code': StandardResponseCode.HTTP_500,
|
|
250
|
+
'msg': str(exc),
|
|
251
|
+
'data': None,
|
|
252
|
+
}
|
|
253
|
+
else:
|
|
254
|
+
res = response_base.fail(request=request, res=CustomResponseCode.HTTP_500)
|
|
255
|
+
content = res.model_dump()
|
|
256
|
+
request.state.__request_cors_500_exception__ = content
|
|
257
|
+
content.update(trace_id=get_request_trace_id(request))
|
|
258
|
+
response = MsgSpecJSONResponse(
|
|
259
|
+
status_code=exc.code if isinstance(exc, BaseExceptionMixin) else StandardResponseCode.HTTP_500,
|
|
260
|
+
content=content,
|
|
261
|
+
background=exc.background if isinstance(exc, BaseExceptionMixin) else None,
|
|
262
|
+
)
|
|
263
|
+
origin = request.headers.get('origin')
|
|
264
|
+
if origin:
|
|
265
|
+
cors = CORSMiddleware(
|
|
266
|
+
app=app,
|
|
267
|
+
allow_origins=settings.CORS_ALLOWED_ORIGINS,
|
|
268
|
+
allow_credentials=True,
|
|
269
|
+
allow_methods=['*'],
|
|
270
|
+
allow_headers=['*'],
|
|
271
|
+
expose_headers=settings.CORS_EXPOSE_HEADERS,
|
|
272
|
+
)
|
|
273
|
+
response.headers.update(cors.simple_headers)
|
|
274
|
+
has_cookie = 'cookie' in request.headers
|
|
275
|
+
if cors.allow_all_origins and has_cookie:
|
|
276
|
+
response.headers['Access-Control-Allow-Origin'] = origin
|
|
277
|
+
elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
|
|
278
|
+
response.headers['Access-Control-Allow-Origin'] = origin
|
|
279
|
+
response.headers.add_vary_header('Origin')
|
|
280
|
+
return response
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from sys import stderr, stdout
|
|
6
|
+
|
|
7
|
+
from asgi_correlation_id import correlation_id
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from backend.core import path_conf
|
|
11
|
+
from backend.core.conf import settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InterceptHandler(logging.Handler):
|
|
15
|
+
"""
|
|
16
|
+
Default handler from examples in loguru documentation.
|
|
17
|
+
See https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def emit(self, record: logging.LogRecord):
|
|
21
|
+
# Get corresponding Loguru level if it exists
|
|
22
|
+
try:
|
|
23
|
+
level = logger.level(record.levelname).name
|
|
24
|
+
except ValueError:
|
|
25
|
+
level = record.levelno
|
|
26
|
+
|
|
27
|
+
# Find caller from where originated the logged message.
|
|
28
|
+
frame, depth = inspect.currentframe(), 0
|
|
29
|
+
while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
|
|
30
|
+
frame = frame.f_back
|
|
31
|
+
depth += 1
|
|
32
|
+
|
|
33
|
+
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_logging():
|
|
37
|
+
"""
|
|
38
|
+
From https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/
|
|
39
|
+
https://github.com/pawamoy/pawamoy.github.io/issues/17
|
|
40
|
+
"""
|
|
41
|
+
# Intercept everything at the root logger
|
|
42
|
+
logging.root.handlers = [InterceptHandler()]
|
|
43
|
+
logging.root.setLevel(settings.LOG_ROOT_LEVEL)
|
|
44
|
+
|
|
45
|
+
# Remove all log handlers and propagate to root logger
|
|
46
|
+
for name in logging.root.manager.loggerDict.keys():
|
|
47
|
+
logging.getLogger(name).handlers = []
|
|
48
|
+
if 'uvicorn.access' in name or 'watchfiles.main' in name:
|
|
49
|
+
logging.getLogger(name).propagate = False
|
|
50
|
+
else:
|
|
51
|
+
logging.getLogger(name).propagate = True
|
|
52
|
+
|
|
53
|
+
# Debug log handlers
|
|
54
|
+
# logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}')
|
|
55
|
+
|
|
56
|
+
# Remove every other logger's handlers
|
|
57
|
+
logger.remove()
|
|
58
|
+
|
|
59
|
+
# Define the correlation_id filter function
|
|
60
|
+
# https://github.com/snok/asgi-correlation-id?tab=readme-ov-file#configure-logging
|
|
61
|
+
# https://github.com/snok/asgi-correlation-id/issues/7
|
|
62
|
+
def correlation_id_filter(record) -> bool:
|
|
63
|
+
cid = correlation_id.get(settings.LOG_CID_DEFAULT_VALUE)
|
|
64
|
+
record['correlation_id'] = cid[: settings.LOG_CID_UUID_LENGTH]
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Configure loguru logger before starts logging
|
|
68
|
+
logger.configure(
|
|
69
|
+
handlers=[
|
|
70
|
+
{
|
|
71
|
+
'sink': stdout,
|
|
72
|
+
'level': settings.LOG_STDOUT_LEVEL,
|
|
73
|
+
'filter': lambda record: correlation_id_filter(record) and record['level'].no <= 25,
|
|
74
|
+
'format': settings.LOG_STD_FORMAT,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
'sink': stderr,
|
|
78
|
+
'level': settings.LOG_STDERR_LEVEL,
|
|
79
|
+
'filter': lambda record: correlation_id_filter(record) and record['level'].no >= 30,
|
|
80
|
+
'format': settings.LOG_STD_FORMAT,
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def set_customize_logfile():
|
|
87
|
+
log_path = path_conf.LOG_DIR
|
|
88
|
+
if not os.path.exists(log_path):
|
|
89
|
+
os.mkdir(log_path)
|
|
90
|
+
|
|
91
|
+
# log files
|
|
92
|
+
log_stdout_file = os.path.join(log_path, settings.LOG_STDOUT_FILENAME)
|
|
93
|
+
log_stderr_file = os.path.join(log_path, settings.LOG_STDERR_FILENAME)
|
|
94
|
+
|
|
95
|
+
# loguru logger: https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
|
|
96
|
+
log_config = {
|
|
97
|
+
'rotation': '10 MB',
|
|
98
|
+
'retention': '15 days',
|
|
99
|
+
'compression': 'tar.gz',
|
|
100
|
+
'enqueue': True,
|
|
101
|
+
'format': settings.LOG_LOGURU_FORMAT,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# stdout file
|
|
105
|
+
logger.add(
|
|
106
|
+
str(log_stdout_file),
|
|
107
|
+
level=settings.LOG_STDOUT_LEVEL,
|
|
108
|
+
**log_config,
|
|
109
|
+
backtrace=False,
|
|
110
|
+
diagnose=False,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# stderr file
|
|
114
|
+
logger.add(
|
|
115
|
+
str(log_stderr_file),
|
|
116
|
+
level=settings.LOG_STDERR_LEVEL,
|
|
117
|
+
**log_config,
|
|
118
|
+
backtrace=True,
|
|
119
|
+
diagnose=True,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
log = logger
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
|
|
2
|
+
from sqlalchemy import DateTime, func
|
|
3
|
+
from ulid import ULID
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from sqlalchemy.sql.functions import current_timestamp
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
id_key = Annotated[
|
|
11
|
+
int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='Primary key id')
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
def get_id() -> str:
|
|
15
|
+
"""Create a new ULID object from the current timestamp"""
|
|
16
|
+
return str(ULID()).lower()
|
|
17
|
+
|
|
18
|
+
# Mixin: A concept of object-oriented programming, makes the structure clearer, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
|
|
19
|
+
class UserMixin(MappedAsDataclass):
|
|
20
|
+
"""User Mixin data class"""
|
|
21
|
+
|
|
22
|
+
create_user: Mapped[int] = mapped_column(sort_order=998, comment='Creator')
|
|
23
|
+
update_user: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='Updater')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DateTimeMixin(MappedAsDataclass):
|
|
27
|
+
"""Datetime Mixin data class"""
|
|
28
|
+
|
|
29
|
+
created_time: Mapped[datetime] = mapped_column(
|
|
30
|
+
DateTime(timezone=True), init=False, default=current_timestamp(), comment='Creation time'
|
|
31
|
+
)
|
|
32
|
+
updated_time: Mapped[datetime | None] = mapped_column(
|
|
33
|
+
DateTime(timezone=True), init=False, onupdate=func.now(), comment='update time'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MappedBase(DeclarativeBase):
|
|
39
|
+
"""
|
|
40
|
+
Declarative base class, the original DeclarativeBase class, exists as the parent class of all base or data model classes
|
|
41
|
+
|
|
42
|
+
`DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__
|
|
43
|
+
`mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@declared_attr.directive
|
|
47
|
+
def __tablename__(cls) -> str:
|
|
48
|
+
return cls.__name__.lower()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DataClassBase(MappedAsDataclass, MappedBase):
|
|
52
|
+
"""
|
|
53
|
+
Declarative data class base, it integrates with the data class, allowing for more advanced configuration,
|
|
54
|
+
but you must be aware of some of its features, especially when used together with DeclarativeBase
|
|
55
|
+
|
|
56
|
+
`MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__
|
|
57
|
+
""" # noqa: E501
|
|
58
|
+
|
|
59
|
+
__abstract__ = True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Base(DataClassBase, DateTimeMixin):
|
|
63
|
+
"""
|
|
64
|
+
Declarative Mixin data class base, integrates with the data class, and includes the basic table structure of the Mixin data class.
|
|
65
|
+
You can simply understand it as a base class for data classes that includes the basic table structure.
|
|
66
|
+
""" # noqa: E501
|
|
67
|
+
|
|
68
|
+
__abstract__ = True
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Dict, Generic, Sequence, TypeVar
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Query
|
|
8
|
+
from fastapi_pagination import pagination_ctx
|
|
9
|
+
from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
|
|
10
|
+
from fastapi_pagination.ext.sqlalchemy import paginate
|
|
11
|
+
from fastapi_pagination.links.bases import create_links
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from sqlalchemy import Select
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
|
|
18
|
+
T = TypeVar('T')
|
|
19
|
+
DataT = TypeVar('DataT')
|
|
20
|
+
SchemaT = TypeVar('SchemaT')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Params(BaseModel, AbstractParams):
|
|
24
|
+
page: int = Query(1, ge=1, description='Page number')
|
|
25
|
+
size: int = Query(10, gt=0, le=100, description='Page size') # Default 10 records
|
|
26
|
+
|
|
27
|
+
def to_raw_params(self) -> RawParams:
|
|
28
|
+
return RawParams(
|
|
29
|
+
limit=self.size,
|
|
30
|
+
offset=self.size * (self.page - 1),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Page(AbstractPage[T], Generic[T]):
|
|
35
|
+
items: Sequence[T] # Data
|
|
36
|
+
total: int # total data
|
|
37
|
+
page: int # Page n
|
|
38
|
+
size: int # per page
|
|
39
|
+
total_pages: int # of total pages
|
|
40
|
+
links: Dict[str, str | None] # Jump links
|
|
41
|
+
|
|
42
|
+
__params_type__ = _Params # Use custom Params
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def create(
|
|
46
|
+
cls,
|
|
47
|
+
items: Sequence[T],
|
|
48
|
+
total: int,
|
|
49
|
+
params: _Params,
|
|
50
|
+
) -> _Page[T]:
|
|
51
|
+
page = params.page
|
|
52
|
+
size = params.size
|
|
53
|
+
total_pages = math.ceil(total / params.size)
|
|
54
|
+
links = create_links(**{
|
|
55
|
+
'first': {'page': 1, 'size': f'{size}'},
|
|
56
|
+
'last': {'page': f'{math.ceil(total / params.size)}', 'size': f'{size}'} if total > 0 else None,
|
|
57
|
+
'next': {'page': f'{page + 1}', 'size': f'{size}'} if (page + 1) <= total_pages else None,
|
|
58
|
+
'prev': {'page': f'{page - 1}', 'size': f'{size}'} if (page - 1) >= 1 else None,
|
|
59
|
+
}).model_dump()
|
|
60
|
+
|
|
61
|
+
return cls(items=items, total=total, page=params.page, size=params.size, total_pages=total_pages, links=links)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _PageData(BaseModel, Generic[DataT]):
|
|
65
|
+
page_data: DataT | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def paging_data(db: AsyncSession, select: Select, page_data_schema: SchemaT) -> dict:
|
|
69
|
+
"""
|
|
70
|
+
Creating Paged Data Based on SQLAlchemy
|
|
71
|
+
|
|
72
|
+
:param db:
|
|
73
|
+
:param select:
|
|
74
|
+
:param page_data_schema:
|
|
75
|
+
:return:
|
|
76
|
+
"""
|
|
77
|
+
_paginate = await paginate(db, select)
|
|
78
|
+
page_data = _PageData[_Page[page_data_schema]](page_data=_paginate).model_dump()['page_data']
|
|
79
|
+
return page_data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Paging dependency injection
|
|
83
|
+
DependsPagination = Depends(pagination_ctx(_Page))
|
|
File without changes
|