aiteamutils 0.2.134__py3-none-any.whl → 0.2.136__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.
- aiteamutils/base_model.py +40 -1
- aiteamutils/base_service.py +38 -2
- aiteamutils/database.py +0 -2
- aiteamutils/files.py +255 -0
- aiteamutils/version.py +1 -1
- {aiteamutils-0.2.134.dist-info → aiteamutils-0.2.136.dist-info}/METADATA +1 -1
- {aiteamutils-0.2.134.dist-info → aiteamutils-0.2.136.dist-info}/RECORD +8 -7
- {aiteamutils-0.2.134.dist-info → aiteamutils-0.2.136.dist-info}/WHEEL +0 -0
aiteamutils/base_model.py
CHANGED
@@ -93,4 +93,43 @@ class BaseSchema(BaseModel):
|
|
93
93
|
|
94
94
|
def to_dict(self) -> Dict[str, Any]:
|
95
95
|
"""모델을 딕셔너리로 변환"""
|
96
|
-
return self.model_dump()
|
96
|
+
return self.model_dump()
|
97
|
+
|
98
|
+
class BaseFileModel(BaseColumn):
|
99
|
+
"""파일 관련 기본 모델"""
|
100
|
+
__abstract__ = True
|
101
|
+
|
102
|
+
entity_name: Mapped[str] = mapped_column(
|
103
|
+
String,
|
104
|
+
nullable=False,
|
105
|
+
doc="엔티티 이름"
|
106
|
+
)
|
107
|
+
entity_ulid: Mapped[str] = mapped_column(
|
108
|
+
String,
|
109
|
+
nullable=False,
|
110
|
+
doc="엔티티 ULID"
|
111
|
+
)
|
112
|
+
original_name: Mapped[str] = mapped_column(
|
113
|
+
String,
|
114
|
+
nullable=False,
|
115
|
+
doc="원본 파일명"
|
116
|
+
)
|
117
|
+
storage_path: Mapped[str] = mapped_column(
|
118
|
+
String,
|
119
|
+
nullable=False,
|
120
|
+
doc="저장 경로"
|
121
|
+
)
|
122
|
+
mime_type: Mapped[str] = mapped_column(
|
123
|
+
String,
|
124
|
+
nullable=False,
|
125
|
+
doc="MIME 타입"
|
126
|
+
)
|
127
|
+
size: Mapped[int] = mapped_column(
|
128
|
+
nullable=False,
|
129
|
+
doc="파일 크기(bytes)"
|
130
|
+
)
|
131
|
+
checksum: Mapped[str] = mapped_column(
|
132
|
+
String,
|
133
|
+
nullable=False,
|
134
|
+
doc="파일 체크섬"
|
135
|
+
)
|
aiteamutils/base_service.py
CHANGED
@@ -45,7 +45,8 @@ class BaseService(Generic[ModelType]):
|
|
45
45
|
fk_check: List[Dict[str, Any]] | None = None,
|
46
46
|
org_ulid_position: str = "organization_ulid",
|
47
47
|
role_permission: str | None = None,
|
48
|
-
token_settings: Dict[str, Any] | None = None
|
48
|
+
token_settings: Dict[str, Any] | None = None,
|
49
|
+
storage_dir: str | None = None
|
49
50
|
) -> ModelType:
|
50
51
|
|
51
52
|
if role_permission:
|
@@ -72,18 +73,53 @@ class BaseService(Generic[ModelType]):
|
|
72
73
|
if fk_check:
|
73
74
|
await validate_unique_fields(self.db_session, fk_check, find_value=False)
|
74
75
|
|
76
|
+
# _files로 끝나는 키 처리
|
77
|
+
file_keys = [key for key in entity_data.keys() if key.endswith('_files')]
|
78
|
+
file_infos = {}
|
79
|
+
|
80
|
+
if file_keys and not storage_dir:
|
81
|
+
raise CustomException(
|
82
|
+
ErrorCode.INVALID_INPUT,
|
83
|
+
detail="storage_dir is required for file upload",
|
84
|
+
source_function=f"{self.__class__.__name__}.create"
|
85
|
+
)
|
86
|
+
|
87
|
+
# 먼저 엔티티를 생성하여 ULID를 얻음
|
75
88
|
result = await self.repository.create(
|
76
89
|
entity_data=entity_data,
|
77
90
|
exclude_entities=exclude_entities
|
78
91
|
)
|
92
|
+
|
93
|
+
# 파일 처리
|
94
|
+
for file_key in file_keys:
|
95
|
+
files_data = entity_data.pop(file_key) # 파일 데이터 추출 및 제거
|
96
|
+
if files_data:
|
97
|
+
from .files import FileHandler
|
98
|
+
file_infos[file_key] = await FileHandler.save_files(
|
99
|
+
files=files_data,
|
100
|
+
storage_dir=storage_dir,
|
101
|
+
entity_name=self.model.__tablename__,
|
102
|
+
entity_ulid=result.ulid,
|
103
|
+
db_session=self.db_session
|
104
|
+
)
|
79
105
|
|
80
106
|
# 결과 반환
|
81
107
|
if response_model:
|
82
|
-
|
108
|
+
processed_result = process_response(result, response_model)
|
109
|
+
# 파일 정보 추가
|
110
|
+
for key, value in file_infos.items():
|
111
|
+
processed_result[key] = value
|
112
|
+
return processed_result
|
83
113
|
else:
|
84
114
|
return result
|
85
115
|
|
86
116
|
except CustomException as e:
|
117
|
+
# 파일 저장 실패 시 저장된 파일들 삭제
|
118
|
+
if 'file_infos' in locals():
|
119
|
+
for file_list in file_infos.values():
|
120
|
+
for file_info in file_list:
|
121
|
+
from .files import FileHandler
|
122
|
+
await FileHandler.delete_files(file_info["storage_path"])
|
87
123
|
raise e
|
88
124
|
except Exception as e:
|
89
125
|
# 다른 예외 처리
|
aiteamutils/database.py
CHANGED
@@ -157,7 +157,6 @@ def process_response(
|
|
157
157
|
|
158
158
|
# Relationship 처리 (이미 로드된 관계만 처리)
|
159
159
|
for relationship in entity.__mapper__.relationships:
|
160
|
-
print(f"\n[DEBUG] Processing relationship: {relationship.key}")
|
161
160
|
if not relationship.key in entity.__dict__:
|
162
161
|
continue
|
163
162
|
|
@@ -195,7 +194,6 @@ def process_response(
|
|
195
194
|
# response_model에 없는 키 제거
|
196
195
|
for key in current_keys:
|
197
196
|
if key not in response_model.model_fields:
|
198
|
-
print(f"[DEBUG] Removing key not in response model: {key}")
|
199
197
|
result.pop(key)
|
200
198
|
# 모델 검증 및 업데이트
|
201
199
|
try:
|
aiteamutils/files.py
ADDED
@@ -0,0 +1,255 @@
|
|
1
|
+
import os
|
2
|
+
import hashlib
|
3
|
+
import aiofiles
|
4
|
+
from datetime import datetime, timezone
|
5
|
+
from typing import BinaryIO, Dict, Any, List, Tuple, Union
|
6
|
+
from pathlib import Path
|
7
|
+
from mimetypes import guess_type
|
8
|
+
from sqlalchemy import text
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
10
|
+
from ulid import ULID
|
11
|
+
|
12
|
+
from .exceptions import ErrorCode, CustomException
|
13
|
+
|
14
|
+
class FileHandler:
|
15
|
+
"""파일 처리를 위한 핵심 기능 제공 클래스"""
|
16
|
+
|
17
|
+
@staticmethod
|
18
|
+
def _create_directory(directory: str) -> None:
|
19
|
+
"""디렉토리가 없는 경우 생성
|
20
|
+
|
21
|
+
Args:
|
22
|
+
directory (str): 생성할 디렉토리 경로
|
23
|
+
"""
|
24
|
+
try:
|
25
|
+
os.makedirs(directory, exist_ok=True)
|
26
|
+
except Exception as e:
|
27
|
+
raise CustomException(
|
28
|
+
ErrorCode.FILE_SYSTEM_ERROR,
|
29
|
+
detail=str(directory),
|
30
|
+
source_function="FileHandler._create_directory",
|
31
|
+
original_error=e
|
32
|
+
)
|
33
|
+
|
34
|
+
@staticmethod
|
35
|
+
async def _calculate_checksum(file: BinaryIO) -> str:
|
36
|
+
"""파일의 SHA-256 체크섬 계산
|
37
|
+
|
38
|
+
Args:
|
39
|
+
file (BinaryIO): 체크섬을 계산할 파일 객체
|
40
|
+
|
41
|
+
Returns:
|
42
|
+
str: 계산된 체크섬 값
|
43
|
+
"""
|
44
|
+
try:
|
45
|
+
sha256_hash = hashlib.sha256()
|
46
|
+
current_position = file.tell()
|
47
|
+
file.seek(0)
|
48
|
+
|
49
|
+
for chunk in iter(lambda: file.read(4096), b""):
|
50
|
+
sha256_hash.update(chunk)
|
51
|
+
|
52
|
+
file.seek(current_position)
|
53
|
+
return sha256_hash.hexdigest()
|
54
|
+
except Exception as e:
|
55
|
+
raise CustomException(
|
56
|
+
ErrorCode.FILE_SYSTEM_ERROR,
|
57
|
+
detail="checksum calculation failed",
|
58
|
+
source_function="FileHandler._calculate_checksum",
|
59
|
+
original_error=e
|
60
|
+
)
|
61
|
+
|
62
|
+
@staticmethod
|
63
|
+
def _get_mime_type(filename: str) -> str:
|
64
|
+
"""파일의 MIME 타입 추측
|
65
|
+
|
66
|
+
Args:
|
67
|
+
filename (str): MIME 타입을 추측할 파일명
|
68
|
+
|
69
|
+
Returns:
|
70
|
+
str: 추측된 MIME 타입
|
71
|
+
"""
|
72
|
+
mime_type, _ = guess_type(filename)
|
73
|
+
return mime_type or "application/octet-stream"
|
74
|
+
|
75
|
+
@staticmethod
|
76
|
+
async def _save_file(
|
77
|
+
file: BinaryIO,
|
78
|
+
original_name: str,
|
79
|
+
storage_dir: str,
|
80
|
+
entity_name: str,
|
81
|
+
entity_ulid: str
|
82
|
+
) -> Dict[str, Any]:
|
83
|
+
"""파일을 저장하고 메타데이터 반환 (내부 사용)
|
84
|
+
|
85
|
+
Args:
|
86
|
+
file (BinaryIO): 저장할 파일 객체
|
87
|
+
original_name (str): 원본 파일명
|
88
|
+
storage_dir (str): 저장 디렉토리 경로
|
89
|
+
entity_name (str): 엔티티 이름
|
90
|
+
entity_ulid (str): 엔티티 ULID
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
Dict[str, Any]: 저장된 파일의 메타데이터
|
94
|
+
"""
|
95
|
+
try:
|
96
|
+
# 저장 디렉토리 생성
|
97
|
+
FileHandler._create_directory(storage_dir)
|
98
|
+
|
99
|
+
# 파일 메타데이터 준비
|
100
|
+
file_ext = os.path.splitext(original_name)[1]
|
101
|
+
storage_filename = f"{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}_{entity_ulid}{file_ext}"
|
102
|
+
storage_path = os.path.join(storage_dir, storage_filename)
|
103
|
+
|
104
|
+
# 파일 저장
|
105
|
+
current_position = file.tell()
|
106
|
+
file.seek(0)
|
107
|
+
|
108
|
+
async with aiofiles.open(storage_path, 'wb') as f:
|
109
|
+
while chunk := file.read(8192):
|
110
|
+
await f.write(chunk)
|
111
|
+
|
112
|
+
# 체크섬 계산
|
113
|
+
file.seek(0)
|
114
|
+
checksum = await FileHandler._calculate_checksum(file)
|
115
|
+
|
116
|
+
# 파일 포인터 복구
|
117
|
+
file.seek(current_position)
|
118
|
+
|
119
|
+
return {
|
120
|
+
"original_name": original_name,
|
121
|
+
"storage_path": storage_path,
|
122
|
+
"mime_type": FileHandler._get_mime_type(original_name),
|
123
|
+
"size": os.path.getsize(storage_path),
|
124
|
+
"checksum": checksum,
|
125
|
+
"entity_name": entity_name,
|
126
|
+
"entity_ulid": entity_ulid
|
127
|
+
}
|
128
|
+
except CustomException as e:
|
129
|
+
raise e
|
130
|
+
except Exception as e:
|
131
|
+
raise CustomException(
|
132
|
+
ErrorCode.FILE_SYSTEM_ERROR,
|
133
|
+
detail=str(storage_path),
|
134
|
+
source_function="FileHandler._save_file",
|
135
|
+
original_error=e
|
136
|
+
)
|
137
|
+
|
138
|
+
@staticmethod
|
139
|
+
async def save_files(
|
140
|
+
files: Union[Tuple[BinaryIO, str], List[Tuple[BinaryIO, str]]],
|
141
|
+
storage_dir: str,
|
142
|
+
entity_name: str,
|
143
|
+
entity_ulid: str,
|
144
|
+
db_session: AsyncSession
|
145
|
+
) -> List[Dict[str, Any]]:
|
146
|
+
"""파일(들)을 저장하고 메타데이터 반환
|
147
|
+
|
148
|
+
Args:
|
149
|
+
files: 단일 파일 튜플 (file, original_name) 또는 파일 튜플 리스트
|
150
|
+
storage_dir (str): 저장 디렉토리 경로
|
151
|
+
entity_name (str): 엔티티 이름
|
152
|
+
entity_ulid (str): 엔티티 ULID
|
153
|
+
db_session (AsyncSession): DB 세션
|
154
|
+
|
155
|
+
Returns:
|
156
|
+
List[Dict[str, Any]]: 저장된 파일들의 메타데이터 리스트
|
157
|
+
"""
|
158
|
+
file_infos = []
|
159
|
+
# 단일 파일인 경우 리스트로 변환
|
160
|
+
files_list = [files] if isinstance(files, tuple) else files
|
161
|
+
|
162
|
+
for file, original_name in files_list:
|
163
|
+
# 파일 저장 및 메타데이터 생성
|
164
|
+
file_info = await FileHandler._save_file(
|
165
|
+
file=file,
|
166
|
+
original_name=original_name,
|
167
|
+
storage_dir=storage_dir,
|
168
|
+
entity_name=entity_name,
|
169
|
+
entity_ulid=entity_ulid
|
170
|
+
)
|
171
|
+
|
172
|
+
# DB에 파일 정보 저장 (트랜잭션은 BaseService에서 관리)
|
173
|
+
result = await db_session.execute(
|
174
|
+
text("""
|
175
|
+
INSERT INTO files (
|
176
|
+
entity_name, entity_ulid, original_name, storage_path,
|
177
|
+
mime_type, size, checksum
|
178
|
+
) VALUES (
|
179
|
+
:entity_name, :entity_ulid, :original_name, :storage_path,
|
180
|
+
:mime_type, :size, :checksum
|
181
|
+
) RETURNING *
|
182
|
+
"""),
|
183
|
+
{
|
184
|
+
"entity_name": entity_name,
|
185
|
+
"entity_ulid": entity_ulid,
|
186
|
+
"original_name": file_info["original_name"],
|
187
|
+
"storage_path": file_info["storage_path"],
|
188
|
+
"mime_type": file_info["mime_type"],
|
189
|
+
"size": file_info["size"],
|
190
|
+
"checksum": file_info["checksum"]
|
191
|
+
}
|
192
|
+
)
|
193
|
+
|
194
|
+
file_info = dict(result.fetchone())
|
195
|
+
file_infos.append(file_info)
|
196
|
+
|
197
|
+
return file_infos
|
198
|
+
|
199
|
+
@staticmethod
|
200
|
+
async def delete_files(storage_paths: Union[str, List[str]]) -> None:
|
201
|
+
"""파일(들) 삭제
|
202
|
+
|
203
|
+
Args:
|
204
|
+
storage_paths: 단일 파일 경로 또는 파일 경로 리스트
|
205
|
+
"""
|
206
|
+
paths_list = [storage_paths] if isinstance(storage_paths, str) else storage_paths
|
207
|
+
for storage_path in paths_list:
|
208
|
+
await FileHandler._delete_file(storage_path)
|
209
|
+
|
210
|
+
@staticmethod
|
211
|
+
async def _delete_file(storage_path: str) -> None:
|
212
|
+
"""파일 삭제 (내부 사용)
|
213
|
+
|
214
|
+
Args:
|
215
|
+
storage_path (str): 삭제할 파일 경로
|
216
|
+
"""
|
217
|
+
try:
|
218
|
+
if os.path.exists(storage_path):
|
219
|
+
os.remove(storage_path)
|
220
|
+
except Exception as e:
|
221
|
+
raise CustomException(
|
222
|
+
ErrorCode.FILE_SYSTEM_ERROR,
|
223
|
+
detail=str(storage_path),
|
224
|
+
source_function="FileHandler._delete_file",
|
225
|
+
original_error=e
|
226
|
+
)
|
227
|
+
|
228
|
+
@staticmethod
|
229
|
+
async def read_file(storage_path: str) -> BinaryIO:
|
230
|
+
"""파일 읽기
|
231
|
+
|
232
|
+
Args:
|
233
|
+
storage_path (str): 읽을 파일 경로
|
234
|
+
|
235
|
+
Returns:
|
236
|
+
BinaryIO: 파일 객체
|
237
|
+
"""
|
238
|
+
try:
|
239
|
+
if not os.path.exists(storage_path):
|
240
|
+
raise CustomException(
|
241
|
+
ErrorCode.FILE_NOT_FOUND,
|
242
|
+
detail=str(storage_path),
|
243
|
+
source_function="FileHandler.read_file"
|
244
|
+
)
|
245
|
+
|
246
|
+
return open(storage_path, 'rb')
|
247
|
+
except CustomException as e:
|
248
|
+
raise e
|
249
|
+
except Exception as e:
|
250
|
+
raise CustomException(
|
251
|
+
ErrorCode.FILE_SYSTEM_ERROR,
|
252
|
+
detail=str(storage_path),
|
253
|
+
source_function="FileHandler.read_file",
|
254
|
+
original_error=e
|
255
|
+
)
|
aiteamutils/version.py
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
"""버전 정보"""
|
2
|
-
__version__ = "0.2.
|
2
|
+
__version__ = "0.2.136"
|
@@ -1,15 +1,16 @@
|
|
1
1
|
aiteamutils/__init__.py,sha256=kRBpRjark0M8ZwFfmKiMFol6CbIILN3WE4f6_P6iIq0,1089
|
2
|
-
aiteamutils/base_model.py,sha256=
|
2
|
+
aiteamutils/base_model.py,sha256=0rs4cjnF2ea3Q2vBTj6F64BGk7ZglJsChsS7ne_R_tg,4056
|
3
3
|
aiteamutils/base_repository.py,sha256=vzBw3g3jCJetTDblZvZenEGXk89Qu_65_02C7QTcf8Q,4615
|
4
|
-
aiteamutils/base_service.py,sha256=
|
4
|
+
aiteamutils/base_service.py,sha256=jWN0QTIIe5SkNitA_EP2GF0t1tyrCm1VftQtR3hJMT8,12507
|
5
5
|
aiteamutils/cache.py,sha256=07xBGlgAwOTAdY5mnMOQJ5EBxVwe8glVD7DkGEkxCtw,1373
|
6
6
|
aiteamutils/config.py,sha256=YdalpJb70-txhGJAS4aaKglEZAFVWgfzw5BXSWpkUz4,3232
|
7
|
-
aiteamutils/database.py,sha256=
|
7
|
+
aiteamutils/database.py,sha256=_aiS_akyJHHFSWZCmPcO82_O32xdUnXlNrPW5KhzxaA,20396
|
8
8
|
aiteamutils/enums.py,sha256=7WLqlcJqQWtETAga2WAxNp3dJTQIAd2TW-4WzkoHHa8,2498
|
9
9
|
aiteamutils/exceptions.py,sha256=pgf3ersezObyl17wAO3I2fb8m9t2OzWDX1mSjwAWm2Y,16035
|
10
|
+
aiteamutils/files.py,sha256=tdvivl3XLNv7Al7H1gGFczmrHM8XlQpiZsEc2xQ_UTU,8829
|
10
11
|
aiteamutils/security.py,sha256=McUl3t5Z5SyUDVUHymHdDkYyF4YSeg4g9fFMML4W6Kw,11630
|
11
12
|
aiteamutils/validators.py,sha256=msOrha36xWsapm4VAh63YmFq1GVyC9tzZcjXYFCEZ_g,11949
|
12
|
-
aiteamutils/version.py,sha256=
|
13
|
-
aiteamutils-0.2.
|
14
|
-
aiteamutils-0.2.
|
15
|
-
aiteamutils-0.2.
|
13
|
+
aiteamutils/version.py,sha256=GEzMhIGk9CTUp2zEaaKPPRMurlXlp9MeMYAAzWYudjo,43
|
14
|
+
aiteamutils-0.2.136.dist-info/METADATA,sha256=qXiXhvObJ6S8Xlm7NrCwLUMQn6cTLZXnJa-IEDgZ7PY,1719
|
15
|
+
aiteamutils-0.2.136.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
16
|
+
aiteamutils-0.2.136.dist-info/RECORD,,
|
File without changes
|