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