aiteamutils 0.2.73__py3-none-any.whl → 0.2.76__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
@@ -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,,