fast-clean 0.4.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.
- fast_clean/__init__.py +3 -0
- fast_clean/broker.py +123 -0
- fast_clean/container.py +235 -0
- fast_clean/contrib/__init__.py +3 -0
- fast_clean/contrib/healthcheck/__init__.py +3 -0
- fast_clean/contrib/healthcheck/router.py +17 -0
- fast_clean/db.py +179 -0
- fast_clean/depends.py +255 -0
- fast_clean/enums.py +39 -0
- fast_clean/exceptions.py +281 -0
- fast_clean/loggers.py +26 -0
- fast_clean/middleware.py +20 -0
- fast_clean/models.py +33 -0
- fast_clean/py.typed +0 -0
- fast_clean/redis.py +23 -0
- fast_clean/repositories/__init__.py +30 -0
- fast_clean/repositories/cache/__init__.py +83 -0
- fast_clean/repositories/cache/in_memory.py +62 -0
- fast_clean/repositories/cache/redis.py +58 -0
- fast_clean/repositories/crud/__init__.py +149 -0
- fast_clean/repositories/crud/db.py +559 -0
- fast_clean/repositories/crud/in_memory.py +369 -0
- fast_clean/repositories/crud/type_vars.py +35 -0
- fast_clean/repositories/settings/__init__.py +52 -0
- fast_clean/repositories/settings/enums.py +16 -0
- fast_clean/repositories/settings/env.py +55 -0
- fast_clean/repositories/settings/exceptions.py +13 -0
- fast_clean/repositories/settings/type_vars.py +9 -0
- fast_clean/repositories/storage/__init__.py +114 -0
- fast_clean/repositories/storage/enums.py +20 -0
- fast_clean/repositories/storage/local.py +118 -0
- fast_clean/repositories/storage/reader.py +122 -0
- fast_clean/repositories/storage/s3.py +118 -0
- fast_clean/repositories/storage/schemas.py +31 -0
- fast_clean/schemas/__init__.py +25 -0
- fast_clean/schemas/exceptions.py +32 -0
- fast_clean/schemas/pagination.py +65 -0
- fast_clean/schemas/repository.py +43 -0
- fast_clean/schemas/request_response.py +36 -0
- fast_clean/schemas/status_response.py +13 -0
- fast_clean/services/__init__.py +16 -0
- fast_clean/services/cryptography/__init__.py +57 -0
- fast_clean/services/cryptography/aes.py +120 -0
- fast_clean/services/cryptography/enums.py +20 -0
- fast_clean/services/lock.py +57 -0
- fast_clean/services/seed.py +91 -0
- fast_clean/services/transaction.py +40 -0
- fast_clean/settings.py +189 -0
- fast_clean/tools/__init__.py +6 -0
- fast_clean/tools/cryptography.py +56 -0
- fast_clean/tools/load_seed.py +31 -0
- fast_clean/use_cases.py +38 -0
- fast_clean/utils/__init__.py +15 -0
- fast_clean/utils/process.py +31 -0
- fast_clean/utils/pydantic.py +23 -0
- fast_clean/utils/ssl_context.py +31 -0
- fast_clean/utils/string.py +28 -0
- fast_clean/utils/thread.py +21 -0
- fast_clean/utils/time.py +14 -0
- fast_clean/utils/type_converters.py +18 -0
- fast_clean/utils/typer.py +23 -0
- fast_clean-0.4.0.dist-info/METADATA +38 -0
- fast_clean-0.4.0.dist-info/RECORD +65 -0
- fast_clean-0.4.0.dist-info/WHEEL +5 -0
- fast_clean-0.4.0.dist-info/top_level.txt +1 -0
fast_clean/depends.py
ADDED
@@ -0,0 +1,255 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий зависимости.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
from collections.abc import AsyncIterator, Sequence
|
7
|
+
from typing import Annotated
|
8
|
+
|
9
|
+
from fastapi import Depends, Request
|
10
|
+
from faststream.kafka import KafkaBroker
|
11
|
+
from flatten_dict import unflatten
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
13
|
+
from starlette.datastructures import FormData
|
14
|
+
from stringcase import snakecase
|
15
|
+
|
16
|
+
from .broker import BrokerFactory
|
17
|
+
from .container import ContainerImpl, ContainerProtocol
|
18
|
+
from .db import SessionFactory, SessionManagerImpl, SessionManagerProtocol
|
19
|
+
from .redis import RedisManager
|
20
|
+
from .repositories import (
|
21
|
+
CacheManager,
|
22
|
+
CacheRepositoryProtocol,
|
23
|
+
LocalStorageParamsSchema,
|
24
|
+
S3StorageParamsSchema,
|
25
|
+
SettingsRepositoryFactoryImpl,
|
26
|
+
SettingsRepositoryFactoryProtocol,
|
27
|
+
SettingsRepositoryProtocol,
|
28
|
+
SettingsSourceEnum,
|
29
|
+
StorageRepositoryFactoryImpl,
|
30
|
+
StorageRepositoryFactoryProtocol,
|
31
|
+
StorageRepositoryProtocol,
|
32
|
+
StorageTypeEnum,
|
33
|
+
)
|
34
|
+
from .schemas import PaginationRequestSchema
|
35
|
+
from .services import (
|
36
|
+
CryptographicAlgorithmEnum,
|
37
|
+
CryptographyServiceFactoryImpl,
|
38
|
+
CryptographyServiceFactoryProtocol,
|
39
|
+
CryptographyServiceProtocol,
|
40
|
+
LockServiceProtocol,
|
41
|
+
RedisLockService,
|
42
|
+
SeedServiceImpl,
|
43
|
+
SeedServiceProtocol,
|
44
|
+
TransactionServiceImpl,
|
45
|
+
TransactionServiceProtocol,
|
46
|
+
)
|
47
|
+
from .settings import CoreCacheSettingsSchema, CoreKafkaSettingsSchema, CoreSettingsSchema, CoreStorageSettingsSchema
|
48
|
+
|
49
|
+
# --- utils ---
|
50
|
+
|
51
|
+
|
52
|
+
def get_container() -> ContainerProtocol:
|
53
|
+
"""
|
54
|
+
Получаем контейнер зависимостей.
|
55
|
+
"""
|
56
|
+
ContainerImpl.init()
|
57
|
+
return ContainerImpl()
|
58
|
+
|
59
|
+
|
60
|
+
async def get_nested_form_data(request: Request) -> FormData:
|
61
|
+
"""
|
62
|
+
Получаем форму, позволяющую использовать вложенные словари.
|
63
|
+
"""
|
64
|
+
dot_data = {k.replace('[', '.').replace(']', ''): v for k, v in (await request.form()).items()}
|
65
|
+
nested_data = unflatten(dot_data, 'dot')
|
66
|
+
for k, v in nested_data.items():
|
67
|
+
if isinstance(v, dict):
|
68
|
+
nested_data[k] = json.dumps(v)
|
69
|
+
return FormData(nested_data)
|
70
|
+
|
71
|
+
|
72
|
+
def get_pagination(page: int | None = None, page_size: int | None = None) -> PaginationRequestSchema:
|
73
|
+
"""
|
74
|
+
Получаем входные данные пагинации.
|
75
|
+
"""
|
76
|
+
return PaginationRequestSchema(page=page or 1, page_size=page_size or 10)
|
77
|
+
|
78
|
+
|
79
|
+
def get_sorting(sorting: str | None = None) -> Sequence[str]:
|
80
|
+
"""
|
81
|
+
Получаем входные данные сортировки.
|
82
|
+
"""
|
83
|
+
if not sorting:
|
84
|
+
return []
|
85
|
+
return [s[0] + snakecase(s[1:]) if s[0] == '-' else snakecase(s) for s in sorting.split(',')]
|
86
|
+
|
87
|
+
|
88
|
+
Container = Annotated[ContainerProtocol, Depends(get_container)]
|
89
|
+
NestedFormData = Annotated[FormData, Depends(get_nested_form_data)]
|
90
|
+
Pagination = Annotated[PaginationRequestSchema, Depends(get_pagination)]
|
91
|
+
Sorting = Annotated[Sequence[str], Depends(get_sorting)]
|
92
|
+
|
93
|
+
# --- repositories ---
|
94
|
+
|
95
|
+
|
96
|
+
def get_settings_repository_factory() -> SettingsRepositoryFactoryProtocol:
|
97
|
+
"""
|
98
|
+
Получаем фабрику репозиториев настроек.
|
99
|
+
"""
|
100
|
+
return SettingsRepositoryFactoryImpl()
|
101
|
+
|
102
|
+
|
103
|
+
SettingsRepositoryFactory = Annotated[SettingsRepositoryFactoryProtocol, Depends(get_settings_repository_factory)]
|
104
|
+
|
105
|
+
|
106
|
+
async def get_settings_repository(settings_repository_factory: SettingsRepositoryFactory) -> SettingsRepositoryProtocol:
|
107
|
+
"""
|
108
|
+
Получаем репозиторий настроек.
|
109
|
+
"""
|
110
|
+
return await settings_repository_factory.make(SettingsSourceEnum.ENV)
|
111
|
+
|
112
|
+
|
113
|
+
SettingsRepository = Annotated[SettingsRepositoryProtocol, Depends(get_settings_repository)]
|
114
|
+
|
115
|
+
|
116
|
+
async def get_settings(settings_repository: SettingsRepository) -> CoreSettingsSchema:
|
117
|
+
"""
|
118
|
+
Получаем настройки.
|
119
|
+
"""
|
120
|
+
return await settings_repository.get(CoreSettingsSchema)
|
121
|
+
|
122
|
+
|
123
|
+
Settings = Annotated[CoreSettingsSchema, Depends(get_settings)]
|
124
|
+
|
125
|
+
|
126
|
+
async def get_broker_repository(settings_repository: SettingsRepository) -> AsyncIterator[KafkaBroker]:
|
127
|
+
"""
|
128
|
+
Получаем репозиторий брокера сообщений.
|
129
|
+
"""
|
130
|
+
kafka_settings = await settings_repository.get(CoreKafkaSettingsSchema)
|
131
|
+
yield BrokerFactory.make_static(kafka_settings)
|
132
|
+
|
133
|
+
|
134
|
+
async def get_cache_repository(settings_repository: SettingsRepository) -> CacheRepositoryProtocol:
|
135
|
+
"""
|
136
|
+
Получаем репозиторий кеша.
|
137
|
+
"""
|
138
|
+
settings = await settings_repository.get(CoreSettingsSchema)
|
139
|
+
if settings.redis_dsn is not None:
|
140
|
+
RedisManager.init(settings.redis_dsn)
|
141
|
+
cache_settings = await settings_repository.get(CoreCacheSettingsSchema)
|
142
|
+
if CacheManager.cache is None:
|
143
|
+
CacheManager.init(cache_settings, RedisManager.redis)
|
144
|
+
if CacheManager.cache is not None:
|
145
|
+
return CacheManager.cache
|
146
|
+
raise ValueError('Cache is not initialized')
|
147
|
+
|
148
|
+
|
149
|
+
def get_storage_repository_factory() -> StorageRepositoryFactoryProtocol:
|
150
|
+
"""
|
151
|
+
Получаем фабрику репозиториев файлового хранилища.
|
152
|
+
"""
|
153
|
+
return StorageRepositoryFactoryImpl()
|
154
|
+
|
155
|
+
|
156
|
+
BrokerRepository = Annotated[KafkaBroker, Depends(get_broker_repository)]
|
157
|
+
CacheRepository = Annotated[CacheRepositoryProtocol, Depends(get_cache_repository)]
|
158
|
+
StorageRepositoryFactory = Annotated[StorageRepositoryFactoryProtocol, Depends(get_storage_repository_factory)]
|
159
|
+
|
160
|
+
|
161
|
+
async def get_storage_repository(
|
162
|
+
settings_repository: SettingsRepository,
|
163
|
+
storage_repository_factory: StorageRepositoryFactory,
|
164
|
+
) -> StorageRepositoryProtocol:
|
165
|
+
"""
|
166
|
+
Получаем репозиторий файлового хранилища.
|
167
|
+
"""
|
168
|
+
storage_settings = await settings_repository.get(CoreStorageSettingsSchema)
|
169
|
+
if storage_settings.provider == 's3' and storage_settings.s3 is not None:
|
170
|
+
return await storage_repository_factory.make(
|
171
|
+
StorageTypeEnum.S3,
|
172
|
+
S3StorageParamsSchema.model_validate(storage_settings.s3.model_dump()),
|
173
|
+
)
|
174
|
+
elif storage_settings.provider == 'local':
|
175
|
+
return await storage_repository_factory.make(
|
176
|
+
StorageTypeEnum.LOCAL, LocalStorageParamsSchema(path=storage_settings.dir)
|
177
|
+
)
|
178
|
+
raise NotImplementedError(f'Storage {storage_settings.provider} not allowed')
|
179
|
+
|
180
|
+
|
181
|
+
StorageRepository = Annotated[StorageRepositoryProtocol, Depends(get_storage_repository)]
|
182
|
+
|
183
|
+
# --- db ---
|
184
|
+
|
185
|
+
|
186
|
+
async def get_async_session(settings_repository: SettingsRepository) -> AsyncIterator[AsyncSession]:
|
187
|
+
"""
|
188
|
+
Получаем асинхронную сессию.
|
189
|
+
"""
|
190
|
+
async with SessionFactory.make_async_session_static(settings_repository) as session:
|
191
|
+
yield session
|
192
|
+
|
193
|
+
|
194
|
+
Session = Annotated[AsyncSession, Depends(get_async_session)]
|
195
|
+
|
196
|
+
|
197
|
+
def get_session_manager(session: Session) -> SessionManagerProtocol:
|
198
|
+
"""
|
199
|
+
Получаем менеджер сессий.
|
200
|
+
"""
|
201
|
+
return SessionManagerImpl(session)
|
202
|
+
|
203
|
+
|
204
|
+
SessionManager = Annotated[SessionManagerProtocol, Depends(get_session_manager)]
|
205
|
+
|
206
|
+
# --- services ---
|
207
|
+
|
208
|
+
|
209
|
+
def get_cryptography_service_factory(settings: Settings) -> CryptographyServiceFactoryProtocol:
|
210
|
+
"""
|
211
|
+
Получаем фабрику сервисов криптографии.
|
212
|
+
"""
|
213
|
+
return CryptographyServiceFactoryImpl(settings.secret_key)
|
214
|
+
|
215
|
+
|
216
|
+
CryptographyServiceFactory = Annotated[CryptographyServiceFactoryProtocol, Depends(get_cryptography_service_factory)]
|
217
|
+
|
218
|
+
|
219
|
+
async def get_cryptography_service(
|
220
|
+
cryptography_service_factory: CryptographyServiceFactory,
|
221
|
+
) -> CryptographyServiceProtocol:
|
222
|
+
"""
|
223
|
+
Получаем сервис криптографии.
|
224
|
+
"""
|
225
|
+
return await cryptography_service_factory.make(CryptographicAlgorithmEnum.AES_GCM)
|
226
|
+
|
227
|
+
|
228
|
+
def get_lock_service(settings: Settings) -> LockServiceProtocol:
|
229
|
+
"""
|
230
|
+
Получаем сервис распределенной блокировки.
|
231
|
+
"""
|
232
|
+
assert settings.redis_dsn is not None
|
233
|
+
RedisManager.init(settings.redis_dsn)
|
234
|
+
assert RedisManager.redis is not None
|
235
|
+
return RedisLockService(RedisManager.redis)
|
236
|
+
|
237
|
+
|
238
|
+
def get_seed_service(session_manager: SessionManager) -> SeedServiceProtocol:
|
239
|
+
"""
|
240
|
+
Получаем сервис для загрузки данных из файлов.
|
241
|
+
"""
|
242
|
+
return SeedServiceImpl(session_manager)
|
243
|
+
|
244
|
+
|
245
|
+
def get_transaction_service(session: Session) -> TransactionServiceProtocol:
|
246
|
+
"""
|
247
|
+
Получаем сервис транзакций.
|
248
|
+
"""
|
249
|
+
return TransactionServiceImpl(session)
|
250
|
+
|
251
|
+
|
252
|
+
CryptographyService = Annotated[CryptographyServiceProtocol, Depends(get_cryptography_service)]
|
253
|
+
LockService = Annotated[LockServiceProtocol, Depends(get_lock_service)]
|
254
|
+
SeedService = Annotated[SeedServiceProtocol, Depends(get_seed_service)]
|
255
|
+
TransactionService = Annotated[TransactionServiceProtocol, Depends(get_transaction_service)]
|
fast_clean/enums.py
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий перечисления.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from enum import StrEnum, auto
|
6
|
+
from typing import Self
|
7
|
+
|
8
|
+
|
9
|
+
class CascadeEnum(StrEnum):
|
10
|
+
"""
|
11
|
+
Настройки каскадного поведения для SQLAlchemy.
|
12
|
+
"""
|
13
|
+
|
14
|
+
SAVE_UPDATE = 'save-update'
|
15
|
+
MERGE = 'merge'
|
16
|
+
REFRESH_EXPIRE = 'refresh-expire'
|
17
|
+
EXPUNGE = 'expunge'
|
18
|
+
DELETE = 'delete'
|
19
|
+
ALL = 'all'
|
20
|
+
DELETE_ORPHAN = 'delete-orphan'
|
21
|
+
|
22
|
+
ALL_DELETE_ORPHAN = 'all, delete-orphan'
|
23
|
+
|
24
|
+
def __add__(self: Self, value: str) -> str:
|
25
|
+
return f'{self}, {value}'
|
26
|
+
|
27
|
+
def __radd__(self: Self, value: str) -> str:
|
28
|
+
return f'{value}, {self}'
|
29
|
+
|
30
|
+
|
31
|
+
class ModelActionEnum(StrEnum):
|
32
|
+
"""
|
33
|
+
Действие с моделью.
|
34
|
+
"""
|
35
|
+
|
36
|
+
INSERT = auto()
|
37
|
+
UPDATE = auto()
|
38
|
+
UPSERT = auto()
|
39
|
+
DELETE = auto()
|
fast_clean/exceptions.py
ADDED
@@ -0,0 +1,281 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий исключения.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import traceback
|
6
|
+
import uuid
|
7
|
+
from abc import ABC, abstractmethod
|
8
|
+
from collections.abc import Iterable, Sequence
|
9
|
+
from functools import partial
|
10
|
+
from typing import Self, TypeVar
|
11
|
+
|
12
|
+
from fastapi import FastAPI, HTTPException, Request, Response, status
|
13
|
+
from fastapi.exception_handlers import http_exception_handler
|
14
|
+
from stringcase import camelcase, snakecase
|
15
|
+
|
16
|
+
from .enums import ModelActionEnum
|
17
|
+
from .schemas import BusinessLogicExceptionSchema, ModelAlreadyExistsErrorSchema, ValidationErrorSchema
|
18
|
+
from .settings import CoreSettingsSchema
|
19
|
+
|
20
|
+
ModelType = TypeVar('ModelType')
|
21
|
+
|
22
|
+
|
23
|
+
class ContainerError(Exception):
|
24
|
+
"""
|
25
|
+
Ошибка контейнера зависимостей.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, message: str, *args: object) -> None:
|
29
|
+
super().__init__(*args)
|
30
|
+
self.message = message
|
31
|
+
|
32
|
+
|
33
|
+
class LockError(Exception):
|
34
|
+
"""
|
35
|
+
Ошибка распределенной блокировки.
|
36
|
+
"""
|
37
|
+
|
38
|
+
message = 'Errors acquiring or releasing a lock'
|
39
|
+
|
40
|
+
|
41
|
+
class BusinessLogicException(Exception, ABC):
|
42
|
+
"""
|
43
|
+
Базовое исключение бизнес-логики.
|
44
|
+
"""
|
45
|
+
|
46
|
+
@property
|
47
|
+
def type(self: Self) -> str:
|
48
|
+
"""
|
49
|
+
Тип ошибки.
|
50
|
+
"""
|
51
|
+
return snakecase(type(self).__name__.replace('Error', ''))
|
52
|
+
|
53
|
+
@property
|
54
|
+
@abstractmethod
|
55
|
+
def msg(self: Self) -> str:
|
56
|
+
"""
|
57
|
+
Сообщение ошибки.
|
58
|
+
"""
|
59
|
+
...
|
60
|
+
|
61
|
+
def __str__(self: Self) -> str:
|
62
|
+
return self.msg
|
63
|
+
|
64
|
+
def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
|
65
|
+
"""
|
66
|
+
Получаем схему исключения.
|
67
|
+
"""
|
68
|
+
return BusinessLogicExceptionSchema(
|
69
|
+
type=self.type,
|
70
|
+
msg=self.msg,
|
71
|
+
traceback=(''.join(traceback.format_exception(type(self), self, self.__traceback__)) if debug else None),
|
72
|
+
)
|
73
|
+
|
74
|
+
|
75
|
+
class PermissionDeniedError(BusinessLogicException):
|
76
|
+
"""
|
77
|
+
Ошибка, возникающая при недостатке прав для выполнения действия.
|
78
|
+
"""
|
79
|
+
|
80
|
+
@property
|
81
|
+
def msg(self: Self) -> str:
|
82
|
+
return 'Недостаточно прав для выполнения действия'
|
83
|
+
|
84
|
+
|
85
|
+
class ModelNotFoundError(BusinessLogicException):
|
86
|
+
"""
|
87
|
+
Ошибка, возникающая при невозможности найти модель.
|
88
|
+
"""
|
89
|
+
|
90
|
+
def __init__(
|
91
|
+
self,
|
92
|
+
model: type[ModelType] | str,
|
93
|
+
*args: object,
|
94
|
+
model_id: int | uuid.UUID | Iterable[int | uuid.UUID] | None = None,
|
95
|
+
model_name: str | Iterable[str] | None = None,
|
96
|
+
message: str | None = None,
|
97
|
+
) -> None:
|
98
|
+
super().__init__(*args)
|
99
|
+
self.model = model
|
100
|
+
self.model_id = model_id
|
101
|
+
self.model_name = model_name
|
102
|
+
self.custom_message = message
|
103
|
+
|
104
|
+
@property
|
105
|
+
def msg(self: Self) -> str:
|
106
|
+
if self.custom_message is not None:
|
107
|
+
return self.custom_message
|
108
|
+
msg = f'Не удалось найти модель {self.model if isinstance(self.model, str) else self.model.__name__}'
|
109
|
+
if self.model_id is not None:
|
110
|
+
if isinstance(self.model_id, Iterable):
|
111
|
+
return f'{msg} по идентификаторам: [{", ".join(map(str, self.model_id))}]'
|
112
|
+
return f'{msg} по идентификатору: {self.model_id}'
|
113
|
+
if self.model_name is not None:
|
114
|
+
if isinstance(self.model_name, Iterable):
|
115
|
+
return f'{msg} по именам: [{", ".join(self.model_name)}]'
|
116
|
+
return f'{msg} по имени: {self.model_name}'
|
117
|
+
return msg
|
118
|
+
|
119
|
+
|
120
|
+
class ModelAlreadyExistsError(BusinessLogicException):
|
121
|
+
"""
|
122
|
+
Ошибка, возникающая при попытке создать модель с существующим уникальным полем.
|
123
|
+
"""
|
124
|
+
|
125
|
+
def __init__(self, field: str, message: str, *args: object) -> None:
|
126
|
+
super().__init__(*args)
|
127
|
+
self.field = field
|
128
|
+
self.message = message
|
129
|
+
|
130
|
+
@property
|
131
|
+
def msg(self: Self) -> str:
|
132
|
+
return self.message
|
133
|
+
|
134
|
+
def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
|
135
|
+
return ModelAlreadyExistsErrorSchema.model_validate(
|
136
|
+
{**super().get_schema(debug).model_dump(), 'field': self.field}
|
137
|
+
)
|
138
|
+
|
139
|
+
|
140
|
+
class ModelIntegrityError(BusinessLogicException):
|
141
|
+
"""
|
142
|
+
Ошибка целостности данных при взаимодействии с моделью.
|
143
|
+
"""
|
144
|
+
|
145
|
+
def __init__(self, model: type[ModelType] | str, action: ModelActionEnum, *args: object) -> None:
|
146
|
+
super().__init__(*args)
|
147
|
+
self.model = model
|
148
|
+
self.action = action
|
149
|
+
|
150
|
+
@property
|
151
|
+
def msg(self: Self) -> str:
|
152
|
+
"""
|
153
|
+
Сообщение ошибки.
|
154
|
+
"""
|
155
|
+
msg = 'Ошибка целостности данных'
|
156
|
+
model_name = self.model if isinstance(self.model, str) else self.model.__name__
|
157
|
+
match self.action:
|
158
|
+
case ModelActionEnum.INSERT:
|
159
|
+
msg += f' при создании модели {model_name}'
|
160
|
+
case ModelActionEnum.UPDATE:
|
161
|
+
msg += f' при изменении модели {model_name}'
|
162
|
+
case ModelActionEnum.UPSERT:
|
163
|
+
msg += f' при создании или изменении модели {model_name}'
|
164
|
+
case ModelActionEnum.DELETE:
|
165
|
+
msg += f' при удалении модели {model_name}'
|
166
|
+
return msg
|
167
|
+
|
168
|
+
|
169
|
+
class ValidationError(BusinessLogicException):
|
170
|
+
"""
|
171
|
+
Ошибка валидации.
|
172
|
+
"""
|
173
|
+
|
174
|
+
def __init__(self, field: str | Sequence[str], message: str, *args: object) -> None:
|
175
|
+
super().__init__(*args)
|
176
|
+
self.field = field
|
177
|
+
self.message = message
|
178
|
+
|
179
|
+
@property
|
180
|
+
def fields(self: Self) -> Sequence[str]:
|
181
|
+
"""
|
182
|
+
Поля ошибки.
|
183
|
+
"""
|
184
|
+
return [self.field] if isinstance(self.field, str) else self.field
|
185
|
+
|
186
|
+
@property
|
187
|
+
def msg(self: Self) -> str:
|
188
|
+
return self.message
|
189
|
+
|
190
|
+
def get_schema(self: Self, debug: bool) -> BusinessLogicExceptionSchema:
|
191
|
+
return ValidationErrorSchema.model_validate(
|
192
|
+
{
|
193
|
+
**super().get_schema(debug).model_dump(),
|
194
|
+
'fields': list(map(camelcase, self.fields)),
|
195
|
+
}
|
196
|
+
)
|
197
|
+
|
198
|
+
|
199
|
+
class SortingFieldNotFoundError(BusinessLogicException):
|
200
|
+
"""
|
201
|
+
Ошибка, возникающая при невозможности найти поле для сортировки.
|
202
|
+
"""
|
203
|
+
|
204
|
+
def __init__(self, field: str, *args: object) -> None:
|
205
|
+
super().__init__(*args)
|
206
|
+
self.field = field
|
207
|
+
|
208
|
+
@property
|
209
|
+
def msg(self: Self) -> str:
|
210
|
+
return f'Не удалось найти поле для сортировки: {self.field}'
|
211
|
+
|
212
|
+
|
213
|
+
async def business_logic_exception_handler(
|
214
|
+
settings: CoreSettingsSchema, request: Request, exception: BusinessLogicException
|
215
|
+
) -> Response:
|
216
|
+
"""
|
217
|
+
Обработчик базового исключения бизнес-логики.
|
218
|
+
"""
|
219
|
+
return await http_exception_handler(
|
220
|
+
request,
|
221
|
+
HTTPException(
|
222
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
223
|
+
detail=[exception.get_schema(settings.debug).model_dump()],
|
224
|
+
),
|
225
|
+
)
|
226
|
+
|
227
|
+
|
228
|
+
async def permission_denied_error_handler(
|
229
|
+
settings: CoreSettingsSchema, request: Request, error: PermissionDeniedError
|
230
|
+
) -> Response:
|
231
|
+
"""
|
232
|
+
Обработчик ошибки, возникающей при недостатке прав для выполнения действия.
|
233
|
+
"""
|
234
|
+
return await http_exception_handler(
|
235
|
+
request,
|
236
|
+
HTTPException(
|
237
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
238
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
239
|
+
),
|
240
|
+
)
|
241
|
+
|
242
|
+
|
243
|
+
async def model_not_found_error_handler(
|
244
|
+
settings: CoreSettingsSchema, request: Request, error: ModelNotFoundError
|
245
|
+
) -> Response:
|
246
|
+
"""
|
247
|
+
Обработчик ошибки, возникающей при невозможности найти модель.
|
248
|
+
"""
|
249
|
+
return await http_exception_handler(
|
250
|
+
request,
|
251
|
+
HTTPException(
|
252
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
253
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
254
|
+
),
|
255
|
+
)
|
256
|
+
|
257
|
+
|
258
|
+
async def model_already_exists_error_handler(
|
259
|
+
settings: CoreSettingsSchema, request: Request, error: ModelAlreadyExistsError
|
260
|
+
) -> Response:
|
261
|
+
"""
|
262
|
+
Обработчик ошибки, возникающей при попытке создать модель с существующим уникальным
|
263
|
+
полем.
|
264
|
+
"""
|
265
|
+
return await http_exception_handler(
|
266
|
+
request,
|
267
|
+
HTTPException(
|
268
|
+
status_code=status.HTTP_409_CONFLICT,
|
269
|
+
detail=[error.get_schema(settings.debug).model_dump()],
|
270
|
+
),
|
271
|
+
)
|
272
|
+
|
273
|
+
|
274
|
+
def use_exceptions_handlers(app: FastAPI, settings: CoreSettingsSchema) -> None:
|
275
|
+
"""
|
276
|
+
Регистрируем глобальные обработчики исключений.
|
277
|
+
"""
|
278
|
+
app.exception_handler(BusinessLogicException)(partial(business_logic_exception_handler, settings))
|
279
|
+
app.exception_handler(PermissionDeniedError)(partial(permission_denied_error_handler, settings))
|
280
|
+
app.exception_handler(ModelNotFoundError)(partial(model_not_found_error_handler, settings))
|
281
|
+
app.exception_handler(ModelAlreadyExistsError)(partial(model_already_exists_error_handler, settings))
|
fast_clean/loggers.py
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий функционал, связанный логированием.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import logging
|
6
|
+
import logging.config
|
7
|
+
from pathlib import Path
|
8
|
+
|
9
|
+
import yaml
|
10
|
+
|
11
|
+
|
12
|
+
def use_logging(base_dir: Path) -> None:
|
13
|
+
"""
|
14
|
+
Применяем настройки логирования.
|
15
|
+
"""
|
16
|
+
files = ['.logging.dev.yaml', '.logging.yaml']
|
17
|
+
for file in files:
|
18
|
+
ls_file = base_dir / file
|
19
|
+
if ls_file.exists():
|
20
|
+
with open(ls_file, 'rt') as f:
|
21
|
+
config = yaml.safe_load(f.read())
|
22
|
+
logging.config.dictConfig(config)
|
23
|
+
break
|
24
|
+
else:
|
25
|
+
print(f'Missing configuration logging files: {", ".join(files)}')
|
26
|
+
exit(0)
|
fast_clean/middleware.py
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий middleware.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from fastapi import FastAPI
|
6
|
+
from starlette.middleware.cors import CORSMiddleware
|
7
|
+
|
8
|
+
|
9
|
+
def use_middleware(app: FastAPI, cors_origins: list[str]) -> FastAPI:
|
10
|
+
"""
|
11
|
+
Регистрируем middleware.
|
12
|
+
"""
|
13
|
+
app.add_middleware(
|
14
|
+
CORSMiddleware,
|
15
|
+
allow_origins=cors_origins,
|
16
|
+
allow_credentials=True,
|
17
|
+
allow_methods=['*'],
|
18
|
+
allow_headers=['*'],
|
19
|
+
)
|
20
|
+
return app
|
fast_clean/models.py
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий модели.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import datetime as dt
|
6
|
+
|
7
|
+
from sqlalchemy import DateTime
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
9
|
+
from sqlalchemy.sql import func
|
10
|
+
|
11
|
+
|
12
|
+
class TimestampMixin:
|
13
|
+
"""
|
14
|
+
Миксин, содержащий дату и время создания и обновления модели.
|
15
|
+
"""
|
16
|
+
|
17
|
+
created_at: Mapped[dt.datetime] = mapped_column(
|
18
|
+
DateTime(timezone=True),
|
19
|
+
default=lambda: dt.datetime.now(dt.UTC),
|
20
|
+
server_default=func.now(),
|
21
|
+
)
|
22
|
+
"""
|
23
|
+
Дата и время создания.
|
24
|
+
"""
|
25
|
+
updated_at: Mapped[dt.datetime] = mapped_column(
|
26
|
+
DateTime(timezone=True),
|
27
|
+
default=lambda: dt.datetime.now(dt.UTC),
|
28
|
+
server_default=func.now(),
|
29
|
+
onupdate=lambda: dt.datetime.now(dt.UTC),
|
30
|
+
)
|
31
|
+
"""
|
32
|
+
Дата и время обновления.
|
33
|
+
"""
|
fast_clean/py.typed
ADDED
File without changes
|
fast_clean/redis.py
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
"""
|
2
|
+
Модуль, содержащий функционал, связанный с Redis.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from pydantic import RedisDsn
|
6
|
+
|
7
|
+
from redis import asyncio as aioredis
|
8
|
+
|
9
|
+
|
10
|
+
class RedisManager:
|
11
|
+
"""
|
12
|
+
Менеджер для управления клиентом Redis.
|
13
|
+
"""
|
14
|
+
|
15
|
+
redis: aioredis.Redis | None = None
|
16
|
+
|
17
|
+
@classmethod
|
18
|
+
def init(cls, redis_dsn: RedisDsn) -> None:
|
19
|
+
"""
|
20
|
+
Инициализируем клиент Redis.
|
21
|
+
"""
|
22
|
+
if cls.redis is None:
|
23
|
+
cls.redis = aioredis.from_url(url=str(redis_dsn), decode_responses=True)
|