aiteamutils 0.2.73__py3-none-any.whl → 0.2.75__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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,26 +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
- response_model: Any = None
174
+ response_model: Any = None,
175
+ explicit_joins: Optional[List[Any]] = None,
176
+ loading_joins: Optional[List[Any]] = None
42
177
  ) -> List[Dict[str, Any]]:
43
178
  try:
44
- # 검색 조건 처리 및 필터 병합
45
- if search_params:
46
- search_filters = build_search_filters(search_params)
47
- if filters:
48
- # 기존 filters와 search_filters 병합 (search_filters가 우선 적용)
49
- filters.update(search_filters)
50
- else:
51
- filters = search_filters
52
-
53
179
  # 모델 이름을 통한 동적 처리
54
180
  if model_name:
55
181
  if model_name not in self.additional_models:
@@ -59,19 +185,74 @@ class BaseService(Generic[ModelType]):
59
185
  source_function=f"{self.__class__.__name__}.list"
60
186
  )
61
187
  model = self.additional_models[model_name]
62
- 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]
63
195
 
64
- 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
+
65
205
  except CustomException as e:
66
- e.detail = f"Service list error for {self.repository.model.__tablename__}: {e.detail}"
67
- e.source_function = f"{self.__class__.__name__}.list -> {e.source_function}"
68
206
  raise e
69
207
  except Exception as e:
70
208
  raise CustomException(
71
209
  ErrorCode.INTERNAL_ERROR,
72
- detail=str(e),
73
210
  source_function=f"{self.__class__.__name__}.list",
74
211
  original_error=e
75
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)
76
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
+ )
77
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,79 +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
- search_params: Dict[str, Dict[str, Any]]
124
- ) -> Dict[str, Any]:
125
- """
126
- 검색 파라미터를 기반으로 필터 조건을 생성합니다.
127
-
128
- Args:
129
- search_params: 검색 조건 설정을 위한 파라미터.
130
-
131
- Returns:
132
- filters: 필터 조건 딕셔너리.
133
- """
134
- filters = {}
135
- for key, param in search_params.items():
136
- value = param.get("value")
137
- if value is not None:
138
- if param.get("like", False):
139
- filters[key] = {"field": param["fields"][0], "operator": "like", "value": f"%{value}%"}
140
- else:
141
- filters[key] = {"field": param["fields"][0], "operator": "eq", "value": value}
142
- return filters
143
-
144
189
  def build_conditions(
145
- filters: Dict[str, Any],
146
- model: Type[ModelType]
190
+ filters: List[Dict[str, Any]],
191
+ model: Type[ModelType]
147
192
  ) -> List[Any]:
148
193
  """
149
194
  필터 조건을 기반으로 SQLAlchemy 조건 리스트를 생성합니다.
150
195
 
151
196
  Args:
152
- filters: 필터 조건 딕셔너리.
197
+ filters: 필터 조건 리스트.
153
198
  model: SQLAlchemy 모델 클래스.
154
199
 
155
200
  Returns:
156
201
  List[Any]: SQLAlchemy 조건 리스트.
157
202
  """
158
203
  conditions = []
159
- for filter_data in filters.values():
160
- if "." in filter_data["field"]:
161
- # 관계를 따라 암묵적으로 연결된 모델의 필드 가져오기
162
- related_model_name, field_name = filter_data["field"].split(".")
163
- relationship_property = getattr(model, related_model_name)
164
- related_model = relationship_property.property.mapper.class_
165
- field = getattr(related_model, field_name)
166
- else:
167
- # 현재 모델의 필드 가져오기
168
- field = getattr(model, filter_data["field"])
169
204
 
170
- # 조건 생성
171
- operator = filter_data["operator"]
172
- 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 = []
173
212
 
174
- if operator == "like":
175
- conditions.append(field.ilike(f"%{value}%"))
176
- elif operator == "eq":
177
- 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))
178
231
 
179
232
  return conditions
180
233
 
181
234
  ##################
182
235
  # 쿼리 실행 #
183
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
+
184
396
  async def list_entities(
185
397
  session: AsyncSession,
186
398
  model: Type[ModelType],
187
399
  skip: int = 0,
188
400
  limit: int = 100,
189
401
  filters: Optional[Dict[str, Any]] = None,
190
- joins: Optional[List[Any]] = None
402
+ explicit_joins: Optional[List[Any]] = None,
403
+ loading_joins: Optional[List[Any]] = None
191
404
  ) -> List[Dict[str, Any]]:
192
405
  """
193
406
  엔터티 리스트를 필터 및 조건에 따라 가져오는 함수.
@@ -218,20 +431,26 @@ async def list_entities(
218
431
  try:
219
432
  query = select(model)
220
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
+
221
444
  # 필터 조건 적용
222
445
  if filters:
223
446
  conditions = build_conditions(filters, model)
224
447
  query = query.where(and_(*conditions))
225
448
 
226
- # 조인 로딩 적용
227
- if joins:
228
- for join_option in joins:
229
- query = query.options(join_option)
230
-
231
449
  # 페이지네이션 적용
232
450
  query = query.limit(limit).offset(skip)
233
451
 
234
452
  result = await session.execute(query)
453
+
235
454
  return result.scalars().unique().all()
236
455
  except SQLAlchemyError as e:
237
456
  raise CustomException(
@@ -240,3 +459,125 @@ async def list_entities(
240
459
  source_function="database.list_entities",
241
460
  original_error=e
242
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.73"
2
+ __version__ = "0.2.75"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiteamutils
3
- Version: 0.2.73
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=M02lAcDmzTul97VBC1H8i5OtNKV8a9En2_sNCz84e-w,2852
5
- aiteamutils/cache.py,sha256=07xBGlgAwOTAdY5mnMOQJ5EBxVwe8glVD7DkGEkxCtw,1373
6
- aiteamutils/config.py,sha256=YdalpJb70-txhGJAS4aaKglEZAFVWgfzw5BXSWpkUz4,3232
7
- aiteamutils/database.py,sha256=4Z1baj7UISE8KsVC_KilKwtCR4Yp8qR-kbwB7CHpDtE,8032
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=p9Q6ycHvK4ulfUsk9t-OMhQk41NdIQmGrAm5soMo2OY,42
13
- aiteamutils-0.2.73.dist-info/METADATA,sha256=oGgO7P8aKOYwsEPaGzDSRRIxCXfs3PVqhDT9GCehssM,1718
14
- aiteamutils-0.2.73.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
15
- aiteamutils-0.2.73.dist-info/RECORD,,