aiteamutils 0.2.72__py3-none-any.whl → 0.2.75__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 CHANGED
@@ -23,10 +23,17 @@ class BaseColumn(Base):
23
23
  doc="ULID",
24
24
  nullable=False
25
25
  )
26
- created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
26
+ created_at: Mapped[datetime] = mapped_column(
27
+ default=datetime.utcnow,
28
+ index=True
29
+ )
27
30
  updated_at: Mapped[datetime] = mapped_column(
28
31
  default=datetime.utcnow,
29
- onupdate=datetime.utcnow
32
+ onupdate=datetime.utcnow,
33
+ index=True
34
+ )
35
+ deleted_at: Mapped[datetime] = mapped_column(
36
+ default=None,
30
37
  )
31
38
  is_deleted: Mapped[bool] = mapped_column(
32
39
  default=False,
@@ -7,7 +7,13 @@ from sqlalchemy import select
7
7
 
8
8
  #패키지 라이브러리
9
9
  from .exceptions import ErrorCode, CustomException
10
- from .database import list_entities
10
+ from .database import (
11
+ list_entities,
12
+ get_entity,
13
+ create_entity,
14
+ update_entity,
15
+ delete_entity
16
+ )
11
17
 
12
18
  ModelType = TypeVar("ModelType", bound=DeclarativeBase)
13
19
 
@@ -29,13 +35,62 @@ class BaseRepository(Generic[ModelType]):
29
35
  source_function=f"{self.__class__.__name__}.session"
30
36
  )
31
37
  self._session = value
38
+
39
+ #######################
40
+ # 입력 및 수정, 삭제 #
41
+ #######################
42
+ async def create(self, entity_data: Dict[str, Any]) -> ModelType:
43
+ try:
44
+ return await create_entity(
45
+ session=self.session,
46
+ model=self.model,
47
+ entity_data=entity_data
48
+ )
49
+ except CustomException as e:
50
+ raise e
51
+ except Exception as e:
52
+ raise CustomException(
53
+ ErrorCode.INTERNAL_ERROR,
54
+ detail=str(e),
55
+ source_function=f"{self.__class__.__name__}.create",
56
+ original_error=e
57
+ )
58
+
59
+ async def update(
60
+ self,
61
+ update_data: Dict[str, Any],
62
+ conditions: Dict[str, Any]
63
+ ) -> ModelType:
64
+ try:
65
+ return await update_entity(
66
+ session=self.session,
67
+ model=self.model,
68
+ update_data=update_data,
69
+ conditions=conditions
70
+ )
71
+ except CustomException as e:
72
+ raise e
73
+
74
+ async def delete(
75
+ self,
76
+ conditions: Dict[str, Any]
77
+ ) -> None:
78
+ await delete_entity(
79
+ session=self.session,
80
+ model=self.model,
81
+ conditions=conditions
82
+ )
32
83
 
84
+ #########################
85
+ # 조회 및 검색 메서드 #
86
+ #########################
33
87
  async def list(
34
88
  self,
35
89
  skip: int = 0,
36
90
  limit: int = 100,
37
91
  filters: Optional[Dict[str, Any]] = None,
38
- joins: Optional[List[Any]] = None,
92
+ explicit_joins: Optional[List[Any]] = None,
93
+ loading_joins: Optional[List[Any]] = None
39
94
  ) -> List[ModelType]:
40
95
  """
41
96
  엔티티 목록 조회.
@@ -48,11 +103,10 @@ class BaseRepository(Generic[ModelType]):
48
103
  skip=skip,
49
104
  limit=limit,
50
105
  filters=filters,
51
- joins=joins,
106
+ explicit_joins=explicit_joins,
107
+ loading_joins=loading_joins
52
108
  )
53
109
  except CustomException as e:
54
- e.detail = f"Repository list error for {self.model.__tablename__}: {e.detail}"
55
- e.source_function = f"{self.__class__.__name__}.list -> {e.source_function}"
56
110
  raise e
57
111
  except Exception as e:
58
112
  raise CustomException(
@@ -61,3 +115,27 @@ class BaseRepository(Generic[ModelType]):
61
115
  source_function=f"{self.__class__.__name__}.list",
62
116
  original_error=e
63
117
  )
118
+
119
+ async def get(
120
+ self,
121
+ conditions: Dict[str, Any] | None = None,
122
+ explicit_joins: Optional[List[Any]] = None,
123
+ loading_joins: Optional[List[Any]] = None
124
+ ) -> ModelType:
125
+ try:
126
+ return await get_entity(
127
+ session=self.session,
128
+ model=self.model,
129
+ conditions=conditions,
130
+ explicit_joins=explicit_joins,
131
+ loading_joins=loading_joins
132
+ )
133
+ except CustomException as e:
134
+ raise e
135
+ except Exception as e:
136
+ raise CustomException(
137
+ ErrorCode.INTERNAL_ERROR,
138
+ detail=str(e),
139
+ source_function=f"{self.__class__.__name__}.get",
140
+ original_error=e
141
+ )
@@ -1,18 +1,19 @@
1
1
  #기본 라이브러리
2
2
  from fastapi import Request
3
- from typing import TypeVar, Generic, Type, Dict, Any, Union, List
3
+ from typing import TypeVar, Generic, Type, Dict, Any, Union, List, Optional
4
4
  from sqlalchemy.orm import DeclarativeBase
5
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
6
  from datetime import datetime
7
+ from ulid import ULID
7
8
 
8
9
  #패키지 라이브러리
9
10
  from .exceptions import ErrorCode, CustomException
10
11
  from .base_repository import BaseRepository
11
12
  from .database import (
12
13
  process_response,
13
- build_search_filters
14
+ validate_unique_fields
14
15
  )
15
-
16
+ from .security import hash_password
16
17
  ModelType = TypeVar("ModelType", bound=DeclarativeBase)
17
18
 
18
19
  class BaseService(Generic[ModelType]):
@@ -30,27 +31,151 @@ class BaseService(Generic[ModelType]):
30
31
  self.repository = repository
31
32
  self.db_session = db_session
32
33
  self.additional_models = additional_models or {},
34
+
35
+ #######################
36
+ # 입력 및 수정, 삭제 #
37
+ #######################
38
+ async def create(
39
+ self,
40
+ entity_data: Dict[str, Any],
41
+ model_name: str | None = None,
42
+ response_model: Any = None,
43
+ exclude_entities: List[str] | None = None,
44
+ unique_check: List[Dict[str, Any]] | None = None,
45
+ fk_check: List[Dict[str, Any]] | None = None,
46
+ ) -> ModelType:
47
+
48
+ try:
49
+ # 고유 검사 수행
50
+ if unique_check:
51
+ await validate_unique_fields(self.db_session, unique_check, find_value=True)
52
+ # 외래 키 검사 수행
53
+ if fk_check:
54
+ await validate_unique_fields(self.db_session, fk_check, find_value=False)
55
+ # 비밀번호가 있으면 해시 처리
56
+ if "password" in entity_data:
57
+ entity_data["password"] = hash_password(entity_data["password"])
58
+ # 제외할 엔티티가 있으면 제외
59
+ if exclude_entities:
60
+ entity_data = {k: v for k, v in entity_data.items() if k not in exclude_entities}
61
+
62
+ # repository의 create 메서드를 트랜잭션 내에서 실행
63
+ return await self.repository.create(entity_data)
64
+ except CustomException as e:
65
+ raise e
66
+ except Exception as e:
67
+ # 다른 예외 처리
68
+ raise CustomException(
69
+ ErrorCode.INTERNAL_ERROR,
70
+ detail=str(e),
71
+ source_function=f"{self.__class__.__name__}.create",
72
+ original_error=e
73
+ )
74
+
75
+ async def update(
76
+ self,
77
+ ulid: str | None = None,
78
+ update_data: Dict[str, Any] | None = None,
79
+ conditions: Dict[str, Any] | None = None,
80
+ unique_check: List[Dict[str, Any]] | None = None,
81
+ exclude_entities: List[str] | None = None
82
+ ) -> ModelType:
83
+ try:
84
+ # 고유 검사 수행
85
+ if unique_check:
86
+ await validate_unique_fields(self.db_session, unique_check, find_value=True)
87
+ # 비밀번호가 있으면 해시 처리
88
+ if "password" in update_data:
89
+ update_data["password"] = hash_password(update_data["password"])
90
+ # 제외할 엔티티가 있으면 제외
91
+ if exclude_entities:
92
+ update_data = {k: v for k, v in update_data.items() if k not in exclude_entities}
93
+
94
+ if not ulid and not conditions:
95
+ raise CustomException(
96
+ ErrorCode.INVALID_INPUT,
97
+ detail="Either 'ulid' or 'conditions' must be provided.",
98
+ source_function="database.update_entity"
99
+ )
100
+
101
+ # ulid로 조건 생성
102
+ if ulid:
103
+ if not ULID.from_str(ulid):
104
+ raise CustomException(
105
+ ErrorCode.VALIDATION_ERROR,
106
+ detail=ulid,
107
+ source_function=f"{self.__class__.__name__}.update"
108
+ )
109
+
110
+ conditions = {"ulid": ulid}
111
+
112
+ return await self.repository.update(
113
+ update_data=update_data,
114
+ conditions=conditions
115
+ )
116
+ except CustomException as e:
117
+ raise e
118
+ except Exception as e:
119
+ raise CustomException(
120
+ ErrorCode.INTERNAL_ERROR,
121
+ detail=str(e),
122
+ source_function=f"{self.__class__.__name__}.update",
123
+ original_error=e
124
+ )
125
+
126
+ async def delete(
127
+ self,
128
+ ulid: str | None = None,
129
+ conditions: Dict[str, Any] | None = None
130
+ ) -> None:
131
+ try:
132
+ if not ULID.from_str(ulid):
133
+ raise CustomException(
134
+ ErrorCode.VALIDATION_ERROR,
135
+ detail=ulid,
136
+ source_function=f"{self.__class__.__name__}.delete"
137
+ )
138
+
139
+ if not ulid and not conditions:
140
+ raise CustomException(
141
+ ErrorCode.INVALID_INPUT,
142
+ detail="Either 'ulid' or 'conditions' must be provided.",
143
+ source_function="database.update_entity"
144
+ )
33
145
 
146
+ # ulid로 조건 생성
147
+ if ulid:
148
+ conditions = {"ulid": ulid}
149
+
150
+ conditions["is_deleted"] = False
151
+
152
+ return await self.repository.delete(
153
+ conditions=conditions
154
+ )
155
+ except CustomException as e:
156
+ raise e
157
+ except Exception as e:
158
+ raise CustomException(
159
+ ErrorCode.INTERNAL_ERROR,
160
+ detail=str(e),
161
+ source_function=f"{self.__class__.__name__}.delete",
162
+ original_error=e
163
+ )
164
+
165
+ #########################
166
+ # 조회 및 검색 메서드 #
167
+ #########################
34
168
  async def list(
35
169
  self,
36
170
  skip: int = 0,
37
171
  limit: int = 100,
38
- filters: Dict[str, Any] | None = None,
39
- search_params: Dict[str, Any] | None = None,
172
+ filters: List[Dict[str, Any]] | None = None,
40
173
  model_name: str | None = None,
41
- request: Request | None = None,
42
- response_model: Any = None
174
+ response_model: Any = None,
175
+ explicit_joins: Optional[List[Any]] = None,
176
+ loading_joins: Optional[List[Any]] = None
43
177
  ) -> List[Dict[str, Any]]:
44
178
  try:
45
- # 검색 조건 처리 및 필터 병합
46
- if search_params:
47
- search_filters = build_search_filters(request, search_params)
48
- if filters:
49
- # 기존 filters와 search_filters 병합 (search_filters가 우선 적용)
50
- filters.update(search_filters)
51
- else:
52
- filters = search_filters
53
-
54
179
  # 모델 이름을 통한 동적 처리
55
180
  if model_name:
56
181
  if model_name not in self.additional_models:
@@ -60,19 +185,74 @@ class BaseService(Generic[ModelType]):
60
185
  source_function=f"{self.__class__.__name__}.list"
61
186
  )
62
187
  model = self.additional_models[model_name]
63
- return await self.repository.list(skip=skip, limit=limit, filters=filters, model=model)
188
+ entities = await self.repository.list(
189
+ skip=skip,
190
+ limit=limit,
191
+ filters=filters,
192
+ model=model
193
+ )
194
+ return [process_response(entity, response_model) for entity in entities]
64
195
 
65
- return await self.repository.list(skip=skip, limit=limit, filters=filters)
196
+ entities = await self.repository.list(
197
+ skip=skip,
198
+ limit=limit,
199
+ filters=filters,
200
+ explicit_joins=explicit_joins,
201
+ loading_joins=loading_joins
202
+ )
203
+ return [process_response(entity, response_model) for entity in entities]
204
+
66
205
  except CustomException as e:
67
- e.detail = f"Service list error for {self.repository.model.__tablename__}: {e.detail}"
68
- e.source_function = f"{self.__class__.__name__}.list -> {e.source_function}"
69
206
  raise e
70
207
  except Exception as e:
71
208
  raise CustomException(
72
209
  ErrorCode.INTERNAL_ERROR,
73
- detail=str(e),
74
210
  source_function=f"{self.__class__.__name__}.list",
75
211
  original_error=e
76
212
  )
213
+
214
+ async def get(
215
+ self,
216
+ ulid: str,
217
+ model_name: str | None = None,
218
+ response_model: Any = None,
219
+ conditions: Dict[str, Any] | None = None,
220
+ explicit_joins: Optional[List[Any]] = None,
221
+ loading_joins: Optional[List[Any]] = None
222
+ ):
223
+ try:
224
+ if not ulid and not conditions:
225
+ raise CustomException(
226
+ ErrorCode.INVALID_INPUT,
227
+ detail="Either 'ulid' or 'conditions' must be provided.",
228
+ source_function="database.update_entity"
229
+ )
230
+
231
+ # ulid로 조건 생성
232
+ if ulid:
233
+ if not ULID.from_str(ulid):
234
+ raise CustomException(
235
+ ErrorCode.VALIDATION_ERROR,
236
+ detail=ulid,
237
+ source_function=f"{self.__class__.__name__}.update"
238
+ )
239
+
240
+ conditions = {"ulid": ulid}
241
+
242
+ entity = await self.repository.get(
243
+ conditions=conditions,
244
+ explicit_joins=explicit_joins,
245
+ loading_joins=loading_joins
246
+ )
247
+ return process_response(entity, response_model)
77
248
 
249
+ except CustomException as e:
250
+ raise e
251
+ except Exception as e:
252
+ raise CustomException(
253
+ ErrorCode.INTERNAL_ERROR,
254
+ detail=str(e),
255
+ source_function=f"{self.__class__.__name__}.get",
256
+ original_error=e
257
+ )
78
258
 
aiteamutils/database.py CHANGED
@@ -1,10 +1,24 @@
1
1
  #기본 라이브러리
2
- from typing import TypeVar, Generic, Type, Any, Dict, List, Optional
2
+ from typing import (
3
+ TypeVar,
4
+ Generic,
5
+ Type,
6
+ Any,
7
+ Dict,
8
+ List,
9
+ Optional,
10
+ AsyncGenerator
11
+ )
3
12
  from sqlalchemy.ext.asyncio import AsyncSession
4
- from sqlalchemy import select, and_
5
- from sqlalchemy.orm import DeclarativeBase
13
+ from sqlalchemy import select, and_, or_
14
+ from sqlalchemy.orm import DeclarativeBase, joinedload, selectinload
6
15
  from sqlalchemy.exc import SQLAlchemyError
7
16
  from datetime import datetime
17
+ from contextlib import asynccontextmanager
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
+ from fastapi import Request
20
+ from ulid import ULID
21
+ from sqlalchemy import MetaData, Table, insert
8
22
 
9
23
  #패키지 라이브러리
10
24
  from .exceptions import ErrorCode, CustomException
@@ -15,8 +29,62 @@ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
15
29
  ##################
16
30
  # 전처리 #
17
31
  ##################
32
+ def process_entity_data(
33
+ model: Type[ModelType],
34
+ entity_data: Dict[str, Any],
35
+ existing_data: Dict[str, Any] = None
36
+ ) -> Dict[str, Any]:
37
+ """
38
+ 엔티티 데이터를 전처리하고 모델 속성과 extra_data를 분리합니다.
39
+
40
+ 이 함수는 다음과 같은 작업을 수행합니다:
41
+ 1. 모델의 기본 속성을 식별합니다.
42
+ 2. Swagger 자동 생성 속성을 제외합니다.
43
+ 3. 모델 속성에 해당하는 데이터는 model_data에 저장
44
+ 4. 모델 속성에 없는 데이터는 extra_data에 저장
45
+ 5. 기존 엔티티 데이터의 extra_data를 유지할 수 있습니다.
46
+
47
+ Args:
48
+ model (Type[ModelType]): 데이터 모델 클래스
49
+ entity_data (Dict[str, Any]): 처리할 엔티티 데이터
50
+ existing_entity_data (Dict[str, Any], optional): 기존 엔티티 데이터. Defaults to None.
51
+
52
+ Returns:
53
+ Dict[str, Any]: 전처리된 모델 데이터 (extra_data 포함)
54
+ """
55
+ # 모델의 속성을 가져와서 전처리합니다.
56
+ model_attr = {
57
+ attr for attr in dir(model)
58
+ if not attr.startswith('_') and not callable(getattr(model, attr))
59
+ }
60
+ model_data = {}
61
+ extra_data = {}
62
+
63
+ # 기존 엔티티 데이터가 있으면 추가
64
+ if existing_data and "extra_data" in existing_data:
65
+ extra_data = existing_data["extra_data"].copy()
66
+
67
+
68
+ # Swagger 자동 생성 속성 패턴
69
+ swagger_patterns = {"additionalProp1", "additionalProp2", "additionalProp3"}
70
+
71
+ for key, value in entity_data.items():
72
+ # Swagger 자동 생성 속성 무시
73
+ if key in swagger_patterns:
74
+ continue
75
+
76
+ # 모델 속성에 있는 경우 model_data에 추가
77
+ if key in model_attr:
78
+ model_data[key] = value
79
+ # 모델 속성에 없는 경우 extra_data에 추가
80
+ else:
81
+ extra_data[key] = value
18
82
 
83
+ # extra_data가 있고 모델에 extra_data 속성이 있는 경우 추가
84
+ if extra_data and "extra_data" in model_attr:
85
+ model_data["extra_data"] = extra_data
19
86
 
87
+ return model_data
20
88
 
21
89
  ##################
22
90
  # 응답 처리 #
@@ -115,81 +183,224 @@ def process_response(
115
183
 
116
184
  return result
117
185
 
118
-
119
186
  ##################
120
187
  # 조건 처리 #
121
188
  ##################
122
- def build_search_filters(
123
- request: Dict[str, Any],
124
- search_params: Dict[str, Dict[str, Any]]
125
- ) -> Dict[str, Any]:
126
- """
127
- 요청 데이터와 검색 파라미터를 기반으로 필터 조건을 생성합니다.
128
-
129
- Args:
130
- request: 요청 데이터 (key-value 형태).
131
- search_params: 검색 조건 설정을 위한 파라미터.
132
-
133
- Returns:
134
- filters: 필터 조건 딕셔너리.
135
- """
136
- filters = {}
137
- for key, param in search_params.items():
138
- value = request.get(key)
139
- if value is not None:
140
- if param["like"]:
141
- filters[key] = {"field": param["fields"][0], "operator": "like", "value": f"%{value}%"}
142
- else:
143
- filters[key] = {"field": param["fields"][0], "operator": "eq", "value": value}
144
- return filters
145
-
146
189
  def build_conditions(
147
- filters: Dict[str, Any],
148
- model: Type[ModelType]
190
+ filters: List[Dict[str, Any]],
191
+ model: Type[ModelType]
149
192
  ) -> List[Any]:
150
193
  """
151
194
  필터 조건을 기반으로 SQLAlchemy 조건 리스트를 생성합니다.
152
195
 
153
196
  Args:
154
- filters: 필터 조건 딕셔너리.
197
+ filters: 필터 조건 리스트.
155
198
  model: SQLAlchemy 모델 클래스.
156
199
 
157
200
  Returns:
158
201
  List[Any]: SQLAlchemy 조건 리스트.
159
202
  """
160
203
  conditions = []
161
- for filter_data in filters.values():
162
- if "." in filter_data["field"]:
163
- # 관계를 따라 암묵적으로 연결된 모델의 필드 가져오기
164
- related_model_name, field_name = filter_data["field"].split(".")
165
- relationship_property = getattr(model, related_model_name)
166
- related_model = relationship_property.property.mapper.class_
167
- field = getattr(related_model, field_name)
168
- else:
169
- # 현재 모델의 필드 가져오기
170
- field = getattr(model, filter_data["field"])
171
204
 
172
- # 조건 생성
173
- operator = filter_data["operator"]
174
- value = filter_data["value"]
205
+ for filter_item in filters:
206
+ value = filter_item.get("value")
207
+ if not value: # 값이 없으면 건너뜀
208
+ continue
209
+
210
+ operator = filter_item.get("operator", "eq")
211
+ or_conditions = []
175
212
 
176
- if operator == "like":
177
- conditions.append(field.ilike(f"%{value}%"))
178
- elif operator == "eq":
179
- conditions.append(field == value)
213
+ for field_path in filter_item.get("fields", []):
214
+ current_model = model
215
+
216
+ # 관계를 따라 필드 가져오기
217
+ for part in field_path.split(".")[:-1]:
218
+ relationship_property = getattr(current_model, part)
219
+ current_model = relationship_property.property.mapper.class_
220
+
221
+ field = getattr(current_model, field_path.split(".")[-1])
222
+
223
+ # 조건 생성
224
+ if operator == "like":
225
+ or_conditions.append(field.ilike(f"%{value}%"))
226
+ elif operator == "eq":
227
+ or_conditions.append(field == value)
228
+
229
+ if or_conditions: # OR 조건이 있을 때만 추가
230
+ conditions.append(or_(*or_conditions))
180
231
 
181
232
  return conditions
182
233
 
183
234
  ##################
184
235
  # 쿼리 실행 #
185
236
  ##################
237
+ async def create_entity(
238
+ session: AsyncSession,
239
+ model: Type[ModelType],
240
+ entity_data: Dict[str, Any]
241
+ ) -> ModelType:
242
+ """
243
+ 새로운 엔티티를 데이터베이스에 생성합니다.
244
+
245
+ Args:
246
+ session (AsyncSession): 데이터베이스 세션
247
+ model (Type[ModelType]): 생성할 모델 클래스
248
+ entity_data (Dict[str, Any]): 엔티티 생성에 필요한 데이터
249
+
250
+ Returns:
251
+ ModelType: 생성된 엔티티
252
+
253
+ Raises:
254
+ CustomException: 엔티티 생성 중 발생하는 데이터베이스 오류
255
+ """
256
+ try:
257
+ # 엔티티 데이터 전처리
258
+ processed_data = process_entity_data(model, entity_data)
259
+
260
+ # 외래 키 필드 검증
261
+
262
+ # 새로운 엔티티 생성
263
+ entity = model(**processed_data)
264
+
265
+ # 세션에 엔티티 추가
266
+ session.add(entity)
267
+
268
+ # 데이터베이스에 커밋
269
+ await session.flush()
270
+ await session.commit()
271
+ await session.refresh(entity)
272
+
273
+ # 생성된 엔티티 반환
274
+ return entity
275
+
276
+ except SQLAlchemyError as e:
277
+ # 데이터베이스 오류 발생 시 CustomException으로 변환
278
+ raise CustomException(
279
+ ErrorCode.DB_CREATE_ERROR,
280
+ detail=f"{model.__name__}|{str(e)}",
281
+ source_function="database.create_entity",
282
+ original_error=e
283
+ )
284
+
285
+ async def update_entity(
286
+ session: AsyncSession,
287
+ model: Type[ModelType],
288
+ conditions: Dict[str, Any],
289
+ update_data: Dict[str, Any]
290
+ ) -> ModelType:
291
+ """
292
+ 조건을 기반으로 엔티티를 조회하고 업데이트합니다.
293
+
294
+ Args:
295
+ session (AsyncSession): 데이터베이스 세션
296
+ model (Type[ModelType]): 업데이트할 모델 클래스
297
+ conditions (Dict[str, Any]): 엔티티 조회 조건
298
+ conditions = {"user_id": 1, "status": "active"}
299
+ update_data (Dict[str, Any]): 업데이트할 데이터
300
+ update_data = {"status": "inactive"}
301
+ Returns:
302
+ ModelType: 업데이트된 엔티티
303
+
304
+ Raises:
305
+ CustomException: 엔티티 조회 또는 업데이트 중 발생하는 데이터베이스 오류
306
+ """
307
+ try:
308
+ # 조건 기반 엔티티 조회
309
+ stmt = select(model)
310
+ for key, value in conditions.items():
311
+ stmt = stmt.where(getattr(model, key) == value)
312
+
313
+ result = await session.execute(stmt)
314
+ entity = result.scalar_one_or_none()
315
+
316
+ if not entity:
317
+ raise CustomException(
318
+ ErrorCode.NOT_FOUND,
319
+ detail=f"{model.__name__}|{conditions}.",
320
+ source_function="database.update_entity"
321
+ )
322
+
323
+ # 기존 데이터를 딕셔너리로 변환
324
+ existing_data = {
325
+ column.name: getattr(entity, column.name)
326
+ for column in entity.__table__.columns
327
+ }
328
+
329
+ # 데이터 병합 및 전처리
330
+ processed_data = process_entity_data(model, update_data, existing_data)
331
+
332
+ # 엔티티 데이터 업데이트
333
+ for key, value in processed_data.items():
334
+ if hasattr(entity, key):
335
+ setattr(entity, key, value)
336
+
337
+ # 변경 사항 커밋
338
+ await session.flush()
339
+ await session.commit()
340
+ await session.refresh(entity)
341
+
342
+ return entity
343
+
344
+ except SQLAlchemyError as e:
345
+ raise CustomException(
346
+ ErrorCode.DB_UPDATE_ERROR,
347
+ detail=f"{model.__name__}|{conditions}",
348
+ source_function="database.update_entity",
349
+ original_error=e
350
+ )
351
+
352
+ async def delete_entity(
353
+ session: AsyncSession,
354
+ model: Type[ModelType],
355
+ conditions: Dict[str, Any]
356
+ ) -> None:
357
+ try:
358
+ stmt = select(model)
359
+ for key, value in conditions.items():
360
+ stmt = stmt.where(getattr(model, key) == value)
361
+
362
+ result = await session.execute(stmt)
363
+ entity = result.scalar_one_or_none()
364
+
365
+ if not entity:
366
+ raise CustomException(
367
+ ErrorCode.NOT_FOUND,
368
+ detail=f"{model.__name__}|{conditions}.",
369
+ source_function="database.delete_entity"
370
+ )
371
+
372
+ entity.is_deleted = True
373
+ entity.deleted_at = datetime.now()
374
+
375
+ await session.flush()
376
+ await session.commit()
377
+ await session.refresh(entity)
378
+
379
+ except SQLAlchemyError as e:
380
+ raise CustomException(
381
+ ErrorCode.DB_DELETE_ERROR,
382
+ detail=f"{model.__name__}|{conditions}",
383
+ source_function="database.delete_entity",
384
+ original_error=e
385
+ )
386
+
387
+ async def purge_entity(
388
+ session: AsyncSession,
389
+ model: Type[ModelType],
390
+ entity: ModelType
391
+ ) -> None:
392
+ # 엔티티를 영구 삭제합니다.
393
+ await session.delete(entity)
394
+ await session.commit()
395
+
186
396
  async def list_entities(
187
397
  session: AsyncSession,
188
398
  model: Type[ModelType],
189
399
  skip: int = 0,
190
400
  limit: int = 100,
191
401
  filters: Optional[Dict[str, Any]] = None,
192
- joins: Optional[List[Any]] = None
402
+ explicit_joins: Optional[List[Any]] = None,
403
+ loading_joins: Optional[List[Any]] = None
193
404
  ) -> List[Dict[str, Any]]:
194
405
  """
195
406
  엔터티 리스트를 필터 및 조건에 따라 가져오는 함수.
@@ -220,20 +431,26 @@ async def list_entities(
220
431
  try:
221
432
  query = select(model)
222
433
 
434
+ # 명시적 조인 적용
435
+ if explicit_joins:
436
+ for join_target in explicit_joins:
437
+ query = query.join(join_target) # 명시적으로 정의된 조인 추가
438
+
439
+ # 조인 로딩 적용
440
+ if loading_joins:
441
+ for join_option in loading_joins:
442
+ query = query.options(join_option)
443
+
223
444
  # 필터 조건 적용
224
445
  if filters:
225
446
  conditions = build_conditions(filters, model)
226
447
  query = query.where(and_(*conditions))
227
448
 
228
- # 조인 로딩 적용
229
- if joins:
230
- for join_option in joins:
231
- query = query.options(join_option)
232
-
233
449
  # 페이지네이션 적용
234
450
  query = query.limit(limit).offset(skip)
235
451
 
236
452
  result = await session.execute(query)
453
+
237
454
  return result.scalars().unique().all()
238
455
  except SQLAlchemyError as e:
239
456
  raise CustomException(
@@ -242,3 +459,125 @@ async def list_entities(
242
459
  source_function="database.list_entities",
243
460
  original_error=e
244
461
  )
462
+
463
+ async def get_entity(
464
+ session: AsyncSession,
465
+ model: Type[ModelType],
466
+ conditions: Dict[str, Any],
467
+ explicit_joins: Optional[List[Any]] = None,
468
+ loading_joins: Optional[List[Any]] = None
469
+ ) -> ModelType:
470
+ try:
471
+ query = select(model)
472
+
473
+ if explicit_joins:
474
+ for join_target in explicit_joins:
475
+ query = query.join(join_target)
476
+
477
+ if loading_joins:
478
+ for join_option in loading_joins:
479
+ query = query.options(join_option)
480
+
481
+ if conditions:
482
+ for key, value in conditions.items():
483
+ query = query.where(getattr(model, key) == value)
484
+
485
+ result = await session.execute(query)
486
+ return result.scalars().unique().one_or_none()
487
+
488
+ except SQLAlchemyError as e:
489
+ raise CustomException(
490
+ ErrorCode.DB_READ_ERROR,
491
+ detail=str(e),
492
+ source_function="database.get_entity",
493
+ original_error=str(e)
494
+ )
495
+
496
+ ##################
497
+ # 로그 등 #
498
+ ##################
499
+ async def log_create(
500
+ session: AsyncSession,
501
+ table_name: str,
502
+ log_data: Dict[str, Any],
503
+ request: Request = None
504
+ ) -> None:
505
+ try:
506
+ # ULID 생성
507
+ if "ulid" not in log_data:
508
+ log_data["ulid"] = ULID()
509
+
510
+ # 사용자 에이전트 및 IP 주소 추가
511
+ if request:
512
+ log_data["user_agent"] = request.headers.get("user-agent")
513
+ log_data["ip_address"] = request.headers.get("x-forwarded-for") or request.client.host
514
+
515
+ # 동적으로 테이블 로드
516
+ metadata = MetaData(bind=session.bind)
517
+ table = Table(table_name, metadata, autoload_with=session.bind)
518
+
519
+ # 데이터 삽입
520
+ insert_stmt = insert(table).values(log_data)
521
+ await session.execute(insert_stmt)
522
+ await session.commit()
523
+
524
+ except Exception as e:
525
+ raise CustomException(
526
+ ErrorCode.INTERNAL_ERROR,
527
+ detail=f"{model.__name__}|{str(e)}",
528
+ source_function="database.log_create",
529
+ original_error=e
530
+ )
531
+
532
+ ######################
533
+ # 검증 #
534
+ ######################
535
+ async def validate_unique_fields(
536
+ session: AsyncSession,
537
+ unique_check: List[Dict[str, Any]] | None = None,
538
+ find_value: bool = True # True: 값이 있는지, False: 값이 없는지 확인
539
+ ) -> None:
540
+ try:
541
+ for check in unique_check:
542
+ value = check["value"]
543
+ model = check["model"]
544
+ fields = check["fields"]
545
+
546
+ # 여러 개의 컬럼이 있을 경우 모든 조건을 만족해야 한다
547
+ conditions = [getattr(model, column) == value for column in fields]
548
+
549
+ # 쿼리 실행
550
+ query = select(model).where(or_(*conditions))
551
+ result = await session.execute(query)
552
+ existing = result.scalar_one_or_none()
553
+
554
+ # 값이 있는지 확인 (find_value=True) 또는 값이 없는지 확인 (find_value=False)
555
+ if find_value and existing: # 값이 존재하는 경우
556
+ raise CustomException(
557
+ ErrorCode.FIELD_INVALID_UNIQUE,
558
+ detail=f"{model.name}|{value}",
559
+ source_function="database.validate_unique_fields.existing"
560
+ )
561
+ elif not find_value and not existing: # 값이 존재하지 않는 경우
562
+ raise CustomException(
563
+ ErrorCode.FIELD_INVALID_NOT_EXIST,
564
+ detail=f"{model.name}|{value}",
565
+ source_function="database.validate_unique_fields.not_existing"
566
+ )
567
+
568
+ except CustomException as e:
569
+ # 특정 CustomException 처리
570
+ raise CustomException(
571
+ e.error_code,
572
+ detail=str(e),
573
+ source_function="database.validate_unique_fields.Exception",
574
+ original_error=e
575
+ )
576
+ except Exception as e:
577
+ # 알 수 없는 예외 처리
578
+ raise CustomException(
579
+ ErrorCode.INTERNAL_ERROR,
580
+ detail=f"Unexpected error: {str(e)}",
581
+ source_function="database.validate_unique_fields.Exception",
582
+ original_error=e
583
+ )
aiteamutils/exceptions.py CHANGED
@@ -64,12 +64,16 @@ class ErrorCode(Enum):
64
64
  VALIDATION_ERROR = ErrorResponse(4001, "VALIDATION_ERROR", 422, "유효성 검사 오류")
65
65
  FIELD_INVALID_FORMAT = ErrorResponse(4002, "FIELD_INVALID_FORMAT", 400, "잘못된 형식입니다")
66
66
  REQUIRED_FIELD_MISSING = ErrorResponse(4003, "VALIDATION_REQUIRED_FIELD_MISSING", 400, "필수 필드가 누락되었습니다")
67
+ FIELD_INVALID_UNIQUE = ErrorResponse(4004, "VALIDATION_FIELD_INVALID_UNIQUE", 400, "중복된 값이 존재합니다")
68
+ FIELD_INVALID_NOT_EXIST = ErrorResponse(4005, "VALIDATION_FIELD_INVALID_NOT_EXIST", 400, "존재하지 않는 값입니다")
67
69
 
68
70
  # General 에러: 5000번대
69
71
  NOT_FOUND = ErrorResponse(5001, "GENERAL_NOT_FOUND", 404, "리소스를 찾을 수 없습니다")
70
72
  INTERNAL_ERROR = ErrorResponse(5002, "GENERAL_INTERNAL_ERROR", 500, "내부 서버 오류")
71
73
  SERVICE_UNAVAILABLE = ErrorResponse(5003, "GENERAL_SERVICE_UNAVAILABLE", 503, "서비스를 사용할 수 없습니다")
72
74
  SERVICE_NOT_REGISTERED = ErrorResponse(5003, "GENERAL_SERVICE_UNAVAILABLE", 503, "서비스를 사용할 수 없습니다")
75
+ LOGIN_ERROR = ErrorResponse(5004, "LOGIN_ERROR", 401, "로그인 오류")
76
+ TOKEN_ERROR = ErrorResponse(5005, "TOKEN_ERROR", 401, "토큰 오류")
73
77
 
74
78
  class CustomException(Exception):
75
79
  """사용자 정의 예외 클래스"""
@@ -98,7 +102,7 @@ class CustomException(Exception):
98
102
 
99
103
  self.error_chain.append({
100
104
  "error_code": str(self.error_code),
101
- "detail": str(self.detail),
105
+ "detail": str(self.detail) if self.detail else None,
102
106
  "source_function": self.source_function,
103
107
  "original_error": str(self.original_error) if self.original_error else None
104
108
  })
@@ -115,7 +119,7 @@ class CustomException(Exception):
115
119
  """에러 정보를 딕셔너리로 반환합니다."""
116
120
  return {
117
121
  "error_code": self.error_code.name,
118
- "detail": self.detail,
122
+ "detail": self.detail if self.detail else None,
119
123
  "source_function": self.source_function,
120
124
  "error_chain": self.error_chain,
121
125
  "original_error": str(self.original_error) if self.original_error else None
aiteamutils/security.py CHANGED
@@ -6,10 +6,11 @@ from functools import wraps
6
6
  from jose import jwt, JWTError
7
7
  from passlib.context import CryptContext
8
8
  import logging
9
+ from sqlalchemy.ext.asyncio import AsyncSession
9
10
 
10
11
  from .exceptions import CustomException, ErrorCode
11
12
  from .enums import ActivityType
12
- from .config import get_settings
13
+ from .database import log_create
13
14
 
14
15
  pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
15
16
 
@@ -115,7 +116,6 @@ def rate_limit(
115
116
  except Exception as e:
116
117
  raise CustomException(
117
118
  ErrorCode.INTERNAL_ERROR,
118
- detail=str(e),
119
119
  source_function=func.__name__,
120
120
  original_error=e
121
121
  )
@@ -125,7 +125,6 @@ def rate_limit(
125
125
  except Exception as e:
126
126
  raise CustomException(
127
127
  ErrorCode.INTERNAL_ERROR,
128
- detail=str(e),
129
128
  source_function="rate_limit",
130
129
  original_error=e
131
130
  )
@@ -136,20 +135,20 @@ def rate_limit(
136
135
  async def create_jwt_token(
137
136
  user_data: Dict[str, Any],
138
137
  token_type: Literal["access", "refresh"],
139
- db_service: Any,
140
- request: Optional[Request] = None
138
+ db_session: AsyncSession,
139
+ token_settings: Dict[str, str],
140
+ request: Optional[Request] = None,
141
141
  ) -> str:
142
142
  """JWT 토큰을 생성하고 로그를 기록합니다."""
143
143
  try:
144
- settings = get_settings()
145
-
144
+ # 토큰 데이터 구성
146
145
  if token_type == "access":
147
- expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
146
+ expires_at = datetime.now(UTC) + timedelta(minutes=token_settings.ACCESS_TOKEN_EXPIRE_MINUTES)
148
147
  token_data = {
149
148
  # 등록 클레임
150
- "iss": settings.token_issuer,
149
+ "iss": token_settings.TOKEN_ISSUER,
151
150
  "sub": user_data["username"],
152
- "aud": settings.token_audience,
151
+ "aud": token_settings.TOKEN_AUDIENCE,
153
152
  "exp": expires_at,
154
153
 
155
154
  # 공개 클레임
@@ -172,22 +171,23 @@ async def create_jwt_token(
172
171
  else: # refresh token
173
172
  expires_at = datetime.now(UTC) + timedelta(days=14)
174
173
  token_data = {
175
- "iss": settings.token_issuer,
174
+ "iss": token_settings.TOKEN_ISSUER,
176
175
  "sub": user_data["username"],
177
176
  "exp": expires_at,
178
177
  "token_type": token_type,
179
178
  "user_ulid": user_data["ulid"]
180
179
  }
181
180
 
181
+ # JWT 토큰 생성
182
182
  try:
183
183
  token = jwt.encode(
184
184
  token_data,
185
- settings.jwt_secret,
186
- algorithm=settings.jwt_algorithm
185
+ token_settings.JWT_SECRET,
186
+ algorithm=token_settings.JWT_ALGORITHM
187
187
  )
188
188
  except Exception as e:
189
189
  raise CustomException(
190
- ErrorCode.INTERNAL_ERROR,
190
+ ErrorCode.TOKEN_ERROR,
191
191
  detail=f"token|{token_type}",
192
192
  source_function="security.create_jwt_token",
193
193
  original_error=e
@@ -195,14 +195,18 @@ async def create_jwt_token(
195
195
 
196
196
  # 로그 생성
197
197
  try:
198
- activity_type = ActivityType.ACCESS_TOKEN_ISSUED if token_type == "access" else ActivityType.REFRESH_TOKEN_ISSUED
199
- await db_service.create_log(
200
- {
201
- "type": activity_type,
202
- "user_ulid": user_data["ulid"],
203
- "token": token
204
- },
205
- request
198
+ log_data = {
199
+ "type": ActivityType.ACCESS_TOKEN_ISSUED if token_type == "access" else ActivityType.REFRESH_TOKEN_ISSUED,
200
+ "user_ulid": user_data["ulid"],
201
+ "token": token
202
+ }
203
+
204
+ # log_create 함수 호출
205
+ await log_create(
206
+ session=db_session,
207
+ table_name="user_logs",
208
+ log_data=log_data,
209
+ request=request
206
210
  )
207
211
  except Exception as e:
208
212
  # 로그 생성 실패는 토큰 생성에 영향을 주지 않음
@@ -217,9 +221,10 @@ async def create_jwt_token(
217
221
  ErrorCode.INTERNAL_ERROR,
218
222
  detail=str(e),
219
223
  source_function="security.create_jwt_token",
220
- original_error=e
224
+ original_error=str(e)
221
225
  )
222
226
 
227
+
223
228
  async def verify_jwt_token(
224
229
  token: str,
225
230
  expected_type: Optional[Literal["access", "refresh"]] = None
aiteamutils/version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  """버전 정보"""
2
- __version__ = "0.2.72"
2
+ __version__ = "0.2.75"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiteamutils
3
- Version: 0.2.72
3
+ Version: 0.2.75
4
4
  Summary: AI Team Utilities
5
5
  Project-URL: Homepage, https://github.com/yourusername/aiteamutils
6
6
  Project-URL: Issues, https://github.com/yourusername/aiteamutils/issues
@@ -0,0 +1,15 @@
1
+ aiteamutils/__init__.py,sha256=kRBpRjark0M8ZwFfmKiMFol6CbIILN3WE4f6_P6iIq0,1089
2
+ aiteamutils/base_model.py,sha256=TJiiA7bFhePObPJEzF1Qz4ekPfFoIxKGpe848BBdHLc,2840
3
+ aiteamutils/base_repository.py,sha256=oTZP3mrkqZwAGv8cNqi0tp9wellvTuO6EXN1P1o7zRs,4285
4
+ aiteamutils/base_service.py,sha256=tkGGVN8wrx9dmCVVnRSsoYGPmVD2H7NmEz-PoifIvW0,9300
5
+ aiteamutils/cache.py,sha256=07xBGlgAwOTAdY5mnMOQJ5EBxVwe8glVD7DkGEkxCtw,1373
6
+ aiteamutils/config.py,sha256=YdalpJb70-txhGJAS4aaKglEZAFVWgfzw5BXSWpkUz4,3232
7
+ aiteamutils/database.py,sha256=8T8c7RpcPEcBXO-AU_-1UhnTI4RwkPLxWF0eWQYwnME,19080
8
+ aiteamutils/enums.py,sha256=ipZi6k_QD5-3QV7Yzv7bnL0MjDz-vqfO9I5L77biMKs,632
9
+ aiteamutils/exceptions.py,sha256=3FUCIqXgYmMqonnMgUlh-J2xtApiiCgg4WM-2UV4vmQ,15823
10
+ aiteamutils/security.py,sha256=7cKY6XhiSEkQJe-XYE2tyYc2u1JLYeXUEjg7AUzEnQY,10277
11
+ aiteamutils/validators.py,sha256=PvI9hbMEAqTawgxPbiWRyx2r9yTUrpNBQs1AD3w4F2U,7726
12
+ aiteamutils/version.py,sha256=FgfB6O4LkLSpldQbG5FG5Fai_x5fo9cg9AQ85w_3RF8,42
13
+ aiteamutils-0.2.75.dist-info/METADATA,sha256=QtxRHW3XcGt5NndEB95Fue80VRGgM54zWbLtssOtkcI,1718
14
+ aiteamutils-0.2.75.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
+ aiteamutils-0.2.75.dist-info/RECORD,,
@@ -1,15 +0,0 @@
1
- aiteamutils/__init__.py,sha256=kRBpRjark0M8ZwFfmKiMFol6CbIILN3WE4f6_P6iIq0,1089
2
- aiteamutils/base_model.py,sha256=ODEnjvUVoxQ1RPCfq8-uZTfTADIA4c7Z3E6G4EVsSX0,2708
3
- aiteamutils/base_repository.py,sha256=tG_xz4hHYAN3-wkrLvEPxyTucV4pzT6dihoKVJp2JIc,2079
4
- aiteamutils/base_service.py,sha256=CfNBAgFREqOC1791ex2plKLZiSZ1U5el4GvvkVsN4qU,2901
5
- aiteamutils/cache.py,sha256=07xBGlgAwOTAdY5mnMOQJ5EBxVwe8glVD7DkGEkxCtw,1373
6
- aiteamutils/config.py,sha256=YdalpJb70-txhGJAS4aaKglEZAFVWgfzw5BXSWpkUz4,3232
7
- aiteamutils/database.py,sha256=CbX7eNFwqz9O4ywVQMLlLrb6hDUJPtsgGg_Hgef-p2I,8126
8
- aiteamutils/enums.py,sha256=ipZi6k_QD5-3QV7Yzv7bnL0MjDz-vqfO9I5L77biMKs,632
9
- aiteamutils/exceptions.py,sha256=_lKWXq_ujNj41xN6LDE149PwsecAP7lgYWbOBbLOntg,15368
10
- aiteamutils/security.py,sha256=xFVrjttxwXB1TTjqgRQQgQJQohQBT28vuW8FVLjvi-M,10103
11
- aiteamutils/validators.py,sha256=PvI9hbMEAqTawgxPbiWRyx2r9yTUrpNBQs1AD3w4F2U,7726
12
- aiteamutils/version.py,sha256=9RIZRehNxp6dA61_1-bTUirXc1q9QDosp-QZ_woFu0g,42
13
- aiteamutils-0.2.72.dist-info/METADATA,sha256=WsFA3r2H1XUIsDXmhenz97uj3eT5jyZpN4CbNbps8rA,1718
14
- aiteamutils-0.2.72.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- aiteamutils-0.2.72.dist-info/RECORD,,