aiteamutils 0.2.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,668 @@
1
+ """기본 서비스 모듈."""
2
+ from datetime import datetime
3
+ from typing import TypeVar, Generic, Dict, Any, List, Optional, Type, Union
4
+ from sqlalchemy.orm import DeclarativeBase
5
+ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
6
+ from .database import DatabaseService
7
+ from .exceptions import CustomException, ErrorCode
8
+ from .base_repository import BaseRepository
9
+ from .security import hash_password
10
+ from fastapi import Request
11
+ from ulid import ULID
12
+
13
+ ModelType = TypeVar("ModelType", bound=DeclarativeBase)
14
+
15
+ class BaseService(Generic[ModelType]):
16
+
17
+ ##################
18
+ # 1. 초기화 영역 #
19
+ ##################
20
+ def __init__(
21
+ self,
22
+ repository: BaseRepository[ModelType],
23
+ additional_models: Dict[str, Type[DeclarativeBase]] = None
24
+ ):
25
+ """
26
+ Args:
27
+ repository (BaseRepository[ModelType]): 레포지토리 인스턴스
28
+ additional_models (Dict[str, Type[DeclarativeBase]], optional): 추가 모델 매핑. Defaults to None.
29
+ """
30
+ self.repository = repository
31
+ self.model = repository.model
32
+ self.additional_models = additional_models or {}
33
+ self.db_service = repository.db_service
34
+ self.searchable_fields = {
35
+ "name": {"type": "text", "description": "이름"},
36
+ "organization_ulid": {"type": "exact", "description": "조직 ID"}
37
+ }
38
+
39
+ #########################
40
+ # 2. 이벤트 처리 메서드 #
41
+ #########################
42
+ async def pre_save(self, data: Dict[str, Any]) -> Dict[str, Any]:
43
+ """저장 전 처리를 수행합니다.
44
+
45
+ Args:
46
+ data (Dict[str, Any]): 저장할 데이터
47
+
48
+ Returns:
49
+ Dict[str, Any]: 처리된 데이터
50
+ """
51
+ return data
52
+
53
+ async def post_save(self, entity: ModelType) -> None:
54
+ """저장 후 처리를 수행합니다.
55
+
56
+ Args:
57
+ entity (ModelType): 저장된 엔티티
58
+ """
59
+ pass
60
+
61
+ async def pre_delete(self, ulid: str) -> None:
62
+ """삭제 전 처리를 수행합니다.
63
+
64
+ Args:
65
+ ulid (str): 삭제할 엔티티의 ULID
66
+ """
67
+ pass
68
+
69
+ async def post_delete(self, ulid: str) -> None:
70
+ """삭제 후 처리를 수행합니다.
71
+
72
+ Args:
73
+ ulid (str): 삭제된 엔티티의 ULID
74
+ """
75
+ pass
76
+
77
+ ######################
78
+ # 3. 캐시 관리 메서드 #
79
+ ######################
80
+ async def get_from_cache(self, key: str) -> Optional[Any]:
81
+ """캐시에서 데이터를 조회합니다.
82
+
83
+ Args:
84
+ key (str): 캐시 키
85
+
86
+ Returns:
87
+ Optional[Any]: 캐시된 데이터 또는 None
88
+ """
89
+ return None
90
+
91
+ async def set_to_cache(self, key: str, value: Any, ttl: int = 3600) -> None:
92
+ """데이터를 캐시에 저장합니다.
93
+
94
+ Args:
95
+ key (str): 캐시 키
96
+ value (Any): 저장할 값
97
+ ttl (int, optional): 캐시 유효 시간(초). Defaults to 3600.
98
+ """
99
+ pass
100
+
101
+ async def invalidate_cache(self, key: str) -> None:
102
+ """캐시를 무효화합니다.
103
+
104
+ Args:
105
+ key (str): 캐시 키
106
+ """
107
+ pass
108
+
109
+ ##########################
110
+ # 4. 비즈니스 검증 메서드 #
111
+ ##########################
112
+ def _validate_business_rules(self, data: Dict[str, Any]) -> None:
113
+ """비즈니스 규칙을 검증합니다.
114
+
115
+ Args:
116
+ data (Dict[str, Any]): 검증할 데이터
117
+
118
+ Raises:
119
+ CustomException: 비즈니스 규칙 위반 시
120
+ """
121
+ pass
122
+
123
+ def _validate_permissions(self, request: Request, action: str) -> None:
124
+ """권한을 검증합니다.
125
+
126
+ Args:
127
+ request (Request): FastAPI 요청 객체
128
+ action (str): 수행할 작업
129
+
130
+ Raises:
131
+ CustomException: 권한이 없는 경우
132
+ """
133
+ pass
134
+
135
+ ########################
136
+ # 5. 응답 처리 메서드 #
137
+ ########################
138
+ def _handle_response_model(self, entity: ModelType, response_model: Any) -> Dict[str, Any]:
139
+ """응답 모델에 맞게 데이터를 처리합니다.
140
+
141
+ Args:
142
+ entity (ModelType): 처리할 엔티티
143
+ response_model (Any): 응답 모델
144
+
145
+ Returns:
146
+ Dict[str, Any]: 처리된 데이터
147
+ """
148
+ if not response_model:
149
+ return self._process_response(entity)
150
+
151
+ result = self._process_response(entity)
152
+
153
+ # response_model에 없는 필드 제거
154
+ keys_to_remove = [key for key in result if key not in response_model.model_fields]
155
+ for key in keys_to_remove:
156
+ result.pop(key)
157
+
158
+ # 모델 검증
159
+ return response_model(**result).model_dump()
160
+
161
+ def _handle_exclude_fields(self, data: Dict[str, Any], exclude_fields: List[str]) -> Dict[str, Any]:
162
+ """제외할 필드를 처리합니다.
163
+
164
+ Args:
165
+ data (Dict[str, Any]): 처리할 데이터
166
+ exclude_fields (List[str]): 제외할 필드 목록
167
+
168
+ Returns:
169
+ Dict[str, Any]: 처리된 데이터
170
+ """
171
+ if not exclude_fields:
172
+ return data
173
+
174
+ return {k: v for k, v in data.items() if k not in exclude_fields}
175
+
176
+ def _validate_ulid(self, ulid: str) -> bool:
177
+ """ULID 형식을 검증합니다.
178
+
179
+ Args:
180
+ ulid (str): 검증할 ULID
181
+
182
+ Returns:
183
+ bool: 유효한 ULID 여부
184
+ """
185
+ try:
186
+ ULID.from_str(ulid)
187
+ return True
188
+ except (ValueError, AttributeError):
189
+ return False
190
+
191
+ def _process_columns(self, entity: ModelType, exclude_extra_data: bool = True) -> Dict[str, Any]:
192
+ """엔티티의 컬럼들을 처리합니다.
193
+
194
+ Args:
195
+ entity (ModelType): 처리할 엔티티
196
+ exclude_extra_data (bool, optional): extra_data 컬럼 제외 여부. Defaults to True.
197
+
198
+ Returns:
199
+ Dict[str, Any]: 처리된 컬럼 데이터
200
+ """
201
+ result = {}
202
+ for column in entity.__table__.columns:
203
+ if exclude_extra_data and column.name == 'extra_data':
204
+ continue
205
+
206
+ # 필드 값 처리
207
+ if hasattr(entity, column.name):
208
+ value = getattr(entity, column.name)
209
+ if isinstance(value, datetime):
210
+ value = value.isoformat()
211
+ result[column.name] = value
212
+ elif hasattr(entity, 'extra_data') and isinstance(entity.extra_data, dict):
213
+ result[column.name] = entity.extra_data.get(column.name)
214
+ else:
215
+ result[column.name] = None
216
+
217
+ # extra_data의 내용을 최상위 레벨로 업데이트
218
+ if hasattr(entity, 'extra_data') and isinstance(entity.extra_data, dict):
219
+ result.update(entity.extra_data or {})
220
+
221
+ return result
222
+
223
+ def _process_response(self, entity: ModelType, response_model: Any = None) -> Dict[str, Any]:
224
+ """응답 데이터를 처리합니다.
225
+ extra_data의 내용을 최상위 레벨로 변환하고, 라우터에서 선언한 응답 스키마에 맞게 데이터를 변환합니다.
226
+
227
+ Args:
228
+ entity (ModelType): 처리할 엔티티
229
+ response_model (Any, optional): 응답 스키마. Defaults to None.
230
+
231
+ Returns:
232
+ Dict[str, Any]: 처리된 엔티티 데이터
233
+ """
234
+ if not entity:
235
+ return None
236
+
237
+ # 모든 필드 처리
238
+ result = self._process_columns(entity)
239
+
240
+ # Relationship 처리 (이미 로드된 관계만 처리)
241
+ for relationship in entity.__mapper__.relationships:
242
+ if not relationship.key in entity.__dict__:
243
+ continue
244
+
245
+ try:
246
+ value = getattr(entity, relationship.key)
247
+ # response_model이 있는 경우 해당 필드의 annotation type을 가져옴
248
+ nested_response_model = None
249
+ if response_model and relationship.key in response_model.model_fields:
250
+ field_info = response_model.model_fields[relationship.key]
251
+ nested_response_model = field_info.annotation
252
+
253
+ if value is not None:
254
+ if isinstance(value, list):
255
+ result[relationship.key] = [
256
+ self._process_response(item, nested_response_model)
257
+ for item in value
258
+ ]
259
+ else:
260
+ result[relationship.key] = self._process_response(value, nested_response_model)
261
+ else:
262
+ result[relationship.key] = None
263
+ except Exception:
264
+ result[relationship.key] = None
265
+
266
+ # response_model이 있는 경우 필터링
267
+ if response_model:
268
+ # 현재 키 목록을 저장
269
+ current_keys = list(result.keys())
270
+ # response_model에 없는 키 제거
271
+ for key in current_keys:
272
+ if key not in response_model.model_fields:
273
+ result.pop(key)
274
+ # 모델 검증 및 업데이트
275
+ result.update(response_model(**result).model_dump())
276
+
277
+ return result
278
+
279
+ def _process_basic_fields(self, entity: ModelType) -> Dict[str, Any]:
280
+ """엔티티의 기본 필드만 처리합니다.
281
+
282
+ Args:
283
+ entity (ModelType): 처리할 엔티티
284
+
285
+ Returns:
286
+ Dict[str, Any]: 기본 필드만 포함된 딕셔너리
287
+ """
288
+ if not entity:
289
+ return None
290
+
291
+ return self._process_columns(entity)
292
+
293
+ async def _create_for_model(self, model_name: str, data: Dict[str, Any], exclude_fields: List[str] = None) -> DeclarativeBase:
294
+ """지정된 모델에 대해 새로운 엔티티를 생성합니다.
295
+
296
+ Args:
297
+ model_name (str): 생성할 모델 이름
298
+ data (Dict[str, Any]): 생성할 엔티티 데이터
299
+ exclude_fields (List[str], optional): 제외할 필드 목록. Defaults to None.
300
+
301
+ Returns:
302
+ DeclarativeBase: 생성된 엔티티
303
+
304
+ Raises:
305
+ CustomException: 데이터베이스 작업 중 오류 발생 시
306
+ """
307
+ if model_name not in self.additional_models:
308
+ raise CustomException(
309
+ ErrorCode.INVALID_REQUEST,
310
+ detail=f"Model {model_name} not registered",
311
+ source_function=f"{self.__class__.__name__}._create_for_model"
312
+ )
313
+
314
+ try:
315
+ # 제외할 필드 처리
316
+ if exclude_fields:
317
+ data = {k: v for k, v in data.items() if k not in exclude_fields}
318
+
319
+ return await self.db_service.create_entity(self.additional_models[model_name], data)
320
+ except CustomException as e:
321
+ raise e
322
+ except Exception as e:
323
+ raise CustomException(
324
+ ErrorCode.DB_CREATE_ERROR,
325
+ detail=str(e),
326
+ source_function=f"{self.__class__.__name__}._create_for_model",
327
+ original_error=e
328
+ )
329
+
330
+ def _process_password(self, data: Dict[str, Any]) -> Dict[str, Any]:
331
+ """비밀번호 필드가 있는 경우 해시화합니다.
332
+
333
+ Args:
334
+ data (Dict[str, Any]): 처리할 데이터
335
+
336
+ Returns:
337
+ Dict[str, Any]: 처리된 데이터
338
+ """
339
+ if "password" in data:
340
+ data["password"] = hash_password(data["password"])
341
+ return data
342
+
343
+ #######################
344
+ # 6. CRUD 작업 메서드 #
345
+ #######################
346
+ async def create(self, data: Dict[str, Any], exclude_fields: List[str] = None, model_name: str = None) -> Union[ModelType, DeclarativeBase]:
347
+ """새로운 엔티티를 생성합니다.
348
+
349
+ Args:
350
+ data (Dict[str, Any]): 생성할 엔티티 데이터
351
+ exclude_fields (List[str], optional): 제외할 필드 목록. Defaults to None.
352
+ model_name (str, optional): 생성할 모델 이름. Defaults to None.
353
+
354
+ Returns:
355
+ Union[ModelType, DeclarativeBase]: 생성된 엔티티
356
+
357
+ Raises:
358
+ CustomException: 데이터베이스 작업 중 오류 발생 시
359
+ """
360
+ try:
361
+ # 비밀번호 해시화
362
+ data = self._process_password(data)
363
+
364
+ # 제외할 필드 처리
365
+ if exclude_fields:
366
+ data = {k: v for k, v in data.items() if k not in exclude_fields}
367
+
368
+ if model_name:
369
+ return await self._create_for_model(model_name, data)
370
+
371
+ return await self.repository.create(data)
372
+ except CustomException as e:
373
+ raise e
374
+ except Exception as e:
375
+ raise CustomException(
376
+ ErrorCode.DB_CREATE_ERROR,
377
+ detail=str(e),
378
+ source_function=f"{self.__class__.__name__}.create",
379
+ original_error=e
380
+ )
381
+
382
+ async def update(
383
+ self,
384
+ ulid: str,
385
+ data: Dict[str, Any],
386
+ exclude_fields: List[str] = None,
387
+ model_name: str = None
388
+ ) -> Optional[ModelType]:
389
+ """기존 엔티티를 수정합니다.
390
+
391
+ Args:
392
+ ulid (str): 수정할 엔티티의 ULID
393
+ data (Dict[str, Any]): 수정할 데이터
394
+ exclude_fields (List[str], optional): 제외할 필드 목록. Defaults to None.
395
+ model_name (str, optional): 수정할 모델 이름. Defaults to None.
396
+
397
+ Returns:
398
+ Optional[ModelType]: 수정된 엔티티, 없으면 None
399
+
400
+ Raises:
401
+ CustomException: 데이터베이스 작업 중 오류 발생 시
402
+ """
403
+ try:
404
+ # 비밀번호 해시화
405
+ data = self._process_password(data)
406
+
407
+ # 제외할 필드 처리
408
+ if exclude_fields:
409
+ data = {k: v for k, v in data.items() if k not in exclude_fields}
410
+
411
+ async with self.db_service.transaction():
412
+ if model_name:
413
+ if model_name not in self.additional_models:
414
+ raise CustomException(
415
+ ErrorCode.INVALID_REQUEST,
416
+ detail=f"Model {model_name} not registered",
417
+ source_function=f"{self.__class__.__name__}.update"
418
+ )
419
+ entity = await self.db_service.update_entity(
420
+ self.additional_models[model_name],
421
+ {"ulid": ulid},
422
+ data
423
+ )
424
+ if not entity:
425
+ raise CustomException(
426
+ ErrorCode.NOT_FOUND,
427
+ detail=f"{self.additional_models[model_name].__tablename__}|ulid|{ulid}",
428
+ source_function=f"{self.__class__.__name__}.update"
429
+ )
430
+ return entity
431
+
432
+ entity = await self.repository.update(ulid, data)
433
+ if not entity:
434
+ raise CustomException(
435
+ ErrorCode.NOT_FOUND,
436
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
437
+ source_function=f"{self.__class__.__name__}.update"
438
+ )
439
+ return entity
440
+ except CustomException as e:
441
+ raise e
442
+ except Exception as e:
443
+ raise CustomException(
444
+ ErrorCode.DB_UPDATE_ERROR,
445
+ detail=str(e),
446
+ source_function=f"{self.__class__.__name__}.update",
447
+ original_error=e
448
+ )
449
+
450
+ async def delete(self, ulid: str, model_name: str = None) -> bool:
451
+ """엔티티를 소프트 삭제합니다 (is_deleted = True).
452
+
453
+ Args:
454
+ ulid (str): 삭제할 엔티티의 ULID
455
+ model_name (str, optional): 삭제할 모델 이름. Defaults to None.
456
+
457
+ Returns:
458
+ bool: 삭제 성공 여부
459
+
460
+ Raises:
461
+ CustomException: 데이터베이스 작업 중 오류 발생 시
462
+ """
463
+ try:
464
+ if model_name:
465
+ if model_name not in self.additional_models:
466
+ raise CustomException(
467
+ ErrorCode.INVALID_REQUEST,
468
+ detail=f"Model {model_name} not registered",
469
+ source_function=f"{self.__class__.__name__}.delete"
470
+ )
471
+ entity = await self.db_service.soft_delete_entity(self.additional_models[model_name], ulid)
472
+ if not entity:
473
+ raise CustomException(
474
+ ErrorCode.NOT_FOUND,
475
+ detail=f"{self.additional_models[model_name].__tablename__}|ulid|{ulid}",
476
+ source_function=f"{self.__class__.__name__}.delete"
477
+ )
478
+ return True
479
+
480
+ entity = await self.repository.delete(ulid)
481
+ if not entity:
482
+ raise CustomException(
483
+ ErrorCode.NOT_FOUND,
484
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
485
+ source_function=f"{self.__class__.__name__}.delete"
486
+ )
487
+ return True
488
+ except CustomException as e:
489
+ raise e
490
+ except Exception as e:
491
+ raise CustomException(
492
+ ErrorCode.DB_DELETE_ERROR,
493
+ detail=str(e),
494
+ source_function=f"{self.__class__.__name__}.delete",
495
+ original_error=e
496
+ )
497
+
498
+ async def real_row_delete(self, ulid: str, model_name: str = None) -> bool:
499
+ """엔티티를 실제로 삭제합니다.
500
+
501
+ Args:
502
+ ulid (str): 삭제할 엔티티의 ULID
503
+ model_name (str, optional): 삭제할 모델 이름. Defaults to None.
504
+
505
+ Returns:
506
+ bool: 삭제 성공 여부
507
+
508
+ Raises:
509
+ CustomException: 데이터베이스 작업 중 오류 발생 시
510
+ """
511
+ try:
512
+ if model_name:
513
+ if model_name not in self.additional_models:
514
+ raise CustomException(
515
+ ErrorCode.INVALID_REQUEST,
516
+ detail=f"Model {model_name} not registered",
517
+ source_function=f"{self.__class__.__name__}.real_row_delete"
518
+ )
519
+ entity = await self.db_service.retrieve_entity(
520
+ self.additional_models[model_name],
521
+ {"ulid": ulid}
522
+ )
523
+ if entity:
524
+ await self.db_service.delete_entity(entity)
525
+ return True
526
+ return False
527
+
528
+ return await self.repository.real_row_delete(ulid)
529
+ except CustomException as e:
530
+ raise e
531
+ except Exception as e:
532
+ raise CustomException(
533
+ ErrorCode.DB_DELETE_ERROR,
534
+ detail=str(e),
535
+ source_function=f"{self.__class__.__name__}.real_row_delete",
536
+ original_error=e
537
+ )
538
+
539
+ #########################
540
+ # 7. 조회 및 검색 메서드 #
541
+ #########################
542
+ async def list(
543
+ self,
544
+ skip: int = 0,
545
+ limit: int = 100,
546
+ filters: Dict[str, Any] | None = None,
547
+ search_params: Dict[str, Any] | None = None,
548
+ model_name: str | None = None,
549
+ request: Request | None = None,
550
+ response_model: Any = None
551
+ ) -> List[Dict[str, Any]]:
552
+ """엔티티 목록을 조회합니다.
553
+
554
+ Args:
555
+ skip (int, optional): 건너뛸 레코드 수. Defaults to 0.
556
+ limit (int, optional): 조회할 최대 레코드 수. Defaults to 100.
557
+ filters (Dict[str, Any] | None, optional): 필터링 조건. Defaults to None.
558
+ search_params (Dict[str, Any] | None, optional): 검색 파라미터. Defaults to None.
559
+ model_name (str | None, optional): 조회할 모델 이름. Defaults to None.
560
+ request (Request | None, optional): 요청 객체. Defaults to None.
561
+ response_model (Any, optional): 응답 스키마. Defaults to None.
562
+
563
+ Returns:
564
+ List[Dict[str, Any]]: 엔티티 목록
565
+ """
566
+ try:
567
+ if model_name:
568
+ if model_name not in self.additional_models:
569
+ raise CustomException(
570
+ ErrorCode.INVALID_REQUEST,
571
+ detail=f"Model {model_name} not registered",
572
+ source_function=f"{self.__class__.__name__}.list"
573
+ )
574
+ entities = await self.db_service.list_entities(
575
+ self.additional_models[model_name],
576
+ skip=skip,
577
+ limit=limit,
578
+ filters=filters
579
+ )
580
+ return [self._process_response(entity, response_model) for entity in entities]
581
+
582
+ entities = await self.repository.list(
583
+ skip=skip,
584
+ limit=limit,
585
+ filters=filters,
586
+ search_params=search_params
587
+ )
588
+ return [self._process_response(entity, response_model) for entity in entities]
589
+
590
+ except CustomException as e:
591
+ e.detail = f"Service list error for {self.repository.model.__tablename__}: {e.detail}"
592
+ e.source_function = f"{self.__class__.__name__}.list -> {e.source_function}"
593
+ raise e
594
+ except Exception as e:
595
+ raise CustomException(
596
+ ErrorCode.INTERNAL_ERROR,
597
+ detail=str(e),
598
+ source_function=f"{self.__class__.__name__}.list",
599
+ original_error=e
600
+ )
601
+
602
+ async def get(
603
+ self,
604
+ ulid: str,
605
+ model_name: str | None = None,
606
+ request: Request | None = None,
607
+ response_model: Any = None
608
+ ) -> Optional[Dict[str, Any]]:
609
+ """특정 엔티티를 조회합니다.
610
+
611
+ Args:
612
+ ulid (str): 조회할 엔티티의 ULID
613
+ model_name (str | None, optional): 조회할 모델 이름. Defaults to None.
614
+ request (Request | None, optional): 요청 객체. Defaults to None.
615
+ response_model (Any, optional): 응답 스키마. Defaults to None.
616
+
617
+ Returns:
618
+ Optional[Dict[str, Any]]: 조회된 엔티티, 없으면 None
619
+
620
+ Raises:
621
+ CustomException: 데이터베이스 작업 중 오류 발생 시
622
+ """
623
+ try:
624
+ # ULID 검증
625
+ if not self._validate_ulid(ulid):
626
+ raise CustomException(
627
+ ErrorCode.VALIDATION_ERROR,
628
+ detail=f"Invalid ULID format: {ulid}",
629
+ source_function=f"{self.__class__.__name__}.get"
630
+ )
631
+
632
+ if model_name:
633
+ if model_name not in self.additional_models:
634
+ raise CustomException(
635
+ ErrorCode.INVALID_REQUEST,
636
+ detail=f"Model {model_name} not registered",
637
+ source_function=f"{self.__class__.__name__}.get"
638
+ )
639
+ entity = await self.db_service.retrieve_entity(
640
+ self.additional_models[model_name],
641
+ {"ulid": ulid, "is_deleted": False}
642
+ )
643
+ if not entity:
644
+ raise CustomException(
645
+ ErrorCode.NOT_FOUND,
646
+ detail=f"{self.additional_models[model_name].__tablename__}|ulid|{ulid}",
647
+ source_function=f"{self.__class__.__name__}.get"
648
+ )
649
+ return self._process_response(entity, response_model)
650
+
651
+ entity = await self.repository.get(ulid)
652
+ if not entity:
653
+ raise CustomException(
654
+ ErrorCode.NOT_FOUND,
655
+ detail=f"{self.model.__tablename__}|ulid|{ulid}",
656
+ source_function=f"{self.__class__.__name__}.get"
657
+ )
658
+
659
+ return self._process_response(entity, response_model)
660
+ except CustomException as e:
661
+ raise e
662
+ except Exception as e:
663
+ raise CustomException(
664
+ ErrorCode.INTERNAL_ERROR,
665
+ detail=str(e),
666
+ source_function=f"{self.__class__.__name__}.get",
667
+ original_error=e
668
+ )