diff --git a/.dockerignore b/.dockerignore index c0e7357..42a22e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,6 @@ info.log db.sqlite3 .github -*/migrations Dockerfile +logs +nginx.conf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3bd587c..47d9c67 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ db.sqlite3 *.dir # 로그파일 무시 *.log +/logs +nginx.conf \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index beb3562..e1bacbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ FROM python:3.12 WORKDIR /code +RUN apt-get update && apt-get install -y libgl1 COPY ./requirements.txt /code/ RUN pip install -r requirements.txt -RUN apt-get update && apt-get install -y libgl1 COPY . /code/ EXPOSE 8000 \ No newline at end of file diff --git a/authenticate/admin.py b/authenticate/admin.py index 8c38f3f..2a2447a 100644 --- a/authenticate/admin.py +++ b/authenticate/admin.py @@ -1,3 +1,5 @@ from django.contrib import admin # Register your models here. +from .models import OIDC +admin.site.register(OIDC) diff --git a/authenticate/authentications.py b/authenticate/authentications.py index 12be336..ff98adf 100644 --- a/authenticate/authentications.py +++ b/authenticate/authentications.py @@ -1,6 +1,7 @@ import requests from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.tokens import AccessToken from usr.models import User from config.settings import APP_LOGGER @@ -23,17 +24,21 @@ def authenticate(self, request): if prefix != 'Bearer': raise AuthenticationFailed('Invalid Bearer Prefix') - # 액세스 토큰 검증을 시도합니다. - payload = self.validate_kakao_access_token(access_token) + # 사용자 정보를 받아옵니다. try: - sub = payload['id'] + # 액세스 토큰 검증을 시도합니다. + payload = AccessToken(access_token) + sub = payload['sub'] user = User.objects.get(sub=sub) logger.info(f'username: {user.username}(sub: {sub}) User attempting to access backend Api') return user, access_token except User.DoesNotExist: # 사용자 정보가 없을 경우 logger.info('New User attempting to access backend Api') return None + except Exception as e: + logger.info(f'Authentication Failed: {e} - Exception code (a40)') + raise AuthenticationFailed(f'Authentication Failed - {e}') def validate_kakao_access_token(self, access_token): end_point = 'https://kapi.kakao.com/v1/user/access_token_info' # 유효성 검증 url diff --git a/authenticate/tests.py b/authenticate/tests.py index 6b79e02..e052882 100644 --- a/authenticate/tests.py +++ b/authenticate/tests.py @@ -24,8 +24,8 @@ def test_login_callback(self): """ redirect_uri = 'http://localhost:8000/auth/login/' response = self.client.post(f'/auth/get_token/?code={self.AUTH_CODE}&redirect_uri={redirect_uri}') - self.assertEqual(response.status_code, 201) print(response.json()) + self.assertEqual(response.status_code, 201) @unittest.skipIf(SKIP_TEST == 'True', "Skip Login Refresh Test") def test_refresh_token(self): @@ -53,6 +53,7 @@ def test_refresh_token(self): response = self.client.post(target_uri, data=data, content_type='application/json') self.assertEqual(response.status_code, 400) + @unittest.skipIf(SKIP_TEST == 'True', "Skip Login Test") def test_login(self): """ 해당 함수는 flutter sdk로 발급받은 액세스 토큰과 아이디 토큰을 활용하여 로그인 혹은 회원가입 진행이 되는지 확인합니다. diff --git a/authenticate/urls.py b/authenticate/urls.py index a236f91..6f038bd 100644 --- a/authenticate/urls.py +++ b/authenticate/urls.py @@ -1,12 +1,14 @@ from django.urls import path -from .views import kakao_callback, KakaoRefreshTokens, LoginRegisterView +from .views import kakao_callback, KakaoRefreshTokens, LoginRegisterView, CustomTokenRefreshView +from rest_framework_simplejwt.views import TokenRefreshView urlpatterns = [ path('login/', LoginRegisterView.as_view({ 'post': 'create', }), name='login_register'), path('get_token/', kakao_callback, name='login'), # 로그인 url 매핑 - path('refresh/', KakaoRefreshTokens.as_view({ - 'post': 'create', - }), name='refresh_tokens'), + # path('refresh/', KakaoRefreshTokens.as_view({ + # 'post': 'create', + # }), name='refresh_tokens'), + path('refresh/', CustomTokenRefreshView.as_view()) ] \ No newline at end of file diff --git a/authenticate/views.py b/authenticate/views.py index c6f3418..81a0990 100644 --- a/authenticate/views.py +++ b/authenticate/views.py @@ -3,13 +3,27 @@ from rest_framework.response import Response import requests from services.kakao_token_service import KakaoTokenService +from services.kakao_error_handler import KakaoRequestError +from rest_framework_simplejwt.views import TokenRefreshView +from services.exception_handler import * from usr.services import UserService +from rest_framework_simplejwt.tokens import RefreshToken -from config.settings import KAKAO_REST_API_KEY # 환경변수를 가져옵니다. +from config.settings import KAKAO_REAL_NATIVE_API_KEY, KAKAO_REST_API_KEY, APP_LOGGER # 환경변수를 가져옵니다. +import logging +logger = logging.getLogger(APP_LOGGER) # Create your views here. +def get_tokens_for_user(user): + refresh = RefreshToken.for_user(user) + + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + def kakao_callback(request): """ @@ -23,33 +37,17 @@ def kakao_callback(request): if code is None: return JsonResponse({"Error": "인가 코드 추출 실패"}, status=status.HTTP_400_BAD_REQUEST) # 토큰을 발급받기위한 클래스 선언 - token_service = KakaoTokenService() - # 요청 body - data = { - 'grant_type': 'authorization_code', - 'client_id': KAKAO_REST_API_KEY, - 'redirect_uri': redirect_uri, - 'code': code, - } - # 토큰 발급을 요청합니다. - token_service.get_kakao_token_response(data) - if token_service.status_code == 200: - id_token = token_service.id_token - # 아이디 토큰이 존재하지 않는다면 -> 예외처리 - if id_token is None: - return JsonResponse({"Error": "id 토큰이 존재하지 않습니다.", "ErrorResponse": token_service.response}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - # TODO 유저 생성하여 회원가입 처리 or 로그인 처리 - user_service = UserService(id_token) - user, is_new = user_service.get_or_register_user() # 로그인 혹은 회원가입을 처리합니다. - # data 딕셔너리 객체를 생성하여 액세스, 리프레시 토큰만 골라서 추출 - data = dict() - data['access_token'] = token_service.access_token # 액세스 토큰 추가 - data['token_type'] = token_service.token_type # token 타입 정보 추가 - data['refresh_token'] = token_service.refresh_token # 리프레시 토큰 정보 추가 - data['is_new'] = is_new # 신규 유저인지 알려주는 플래그 입니다. - # return JsonResponse(data, status=201) # post 요청을 보내줬기 때문에 201 create를 보내줍니다. - return JsonResponse(token_service.response, status=status.HTTP_201_CREATED) # 모든 정보를 보내줍니다. - return JsonResponse({"Error": token_service.response}, status=status.HTTP_400_BAD_REQUEST) + token_service = KakaoTokenService(KAKAO_REST_API_KEY) + tokens = token_service.get_tokens(code, redirect_uri) + user_service = UserService(tokens.id_token) + user, is_new = user_service.get_or_register_user() # 로그인 혹은 회원가입을 처리합니다. + data = dict() + data['access_token'] = tokens.access_token # 액세스 토큰 추가 + data['token_type'] = tokens.token_type # token 타입 정보 추가 + data['refresh_token'] = tokens.refresh_token # 리프레시 토큰 정보 추가 + data['id_token'] = tokens.id_token + data['is_new'] = is_new # 신규 유저인지 알려주는 플래그 입니다. + return JsonResponse(data, status=201) # post 요청을 보내줬기 때문에 201 create를 보내줍니다. class KakaoRefreshTokens(viewsets.ViewSet): """ @@ -62,23 +60,18 @@ def create(self, request): if refresh_token is None: return Response({"Error": "Refresh token is missing"}, status=400) # 리프레시 토큰이 있는경우 - token_service = KakaoTokenService() - data = { - 'grant_type': 'refresh_token', - 'client_id': KAKAO_REST_API_KEY, - 'refresh_token': refresh_token, - } - token_service.get_kakao_token_response(data) # 카카오 토큰을 재발급 받습니다. - # 헤더와 정보를 조합하여 정보를 보냅니다. - # 올바른 정보가 넘어왔다면 - if token_service.status_code == 200: - # data 딕셔너리 객체를 생성하여 액세스, 리프레시 토큰만 골라서 추출 + token_service = KakaoTokenService(KAKAO_REAL_NATIVE_API_KEY) + try: + tokens = token_service.get_new_tokens(refresh_token) # 카카오 토큰을 재발급 받습니다. data = dict() - data['access_token'] = token_service.access_token # 액세스 토큰 추가 - data['token_type'] = token_service.token_type # token 타입 정보 추가 + data['access_token'] = tokens.access_token # 액세스 토큰 추가 + data['token_type'] = tokens.token_type # token 타입 정보 추가 + data['refresh_token'] = tokens.refresh_token # 리프레시 추가 None 가능 return Response(data, status=status.HTTP_201_CREATED) - # 만일 토큰 정보가 잘못되었거나, refresh_token마저 만료 된경우, 혹은 카카오 측 오류인 경우 - return Response(token_service.response, status=status.HTTP_400_BAD_REQUEST) + except KakaoRequestError as e: + return Response({"Error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({"Error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) class LoginRegisterView(viewsets.ViewSet): """ @@ -101,6 +94,10 @@ def create(self, request): except Exception as e: return Response({"Error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + tokens = get_tokens_for_user(user) + accessToken = tokens['access'] + refreshToken = tokens['refresh'] + return Response({ "message": "login or register success", "is_new": is_new, @@ -110,6 +107,34 @@ def create(self, request): "profile_image_url": user.profile_image_url, "age_range": user.age_range, "gender": user.gender, + }, + "tokens": { + "access_token": accessToken, + "refresh_token": refreshToken, } }, status=status.HTTP_201_CREATED) +class CustomTokenRefreshView(TokenRefreshView): + def post(self, request, *args, **kwargs): + data = request.data.copy() + refresh_token = data.pop('refresh_token', None) + if refresh_token is None: + raise NoRequiredParameterException( + 'NO_PARAMETER', + 'refresh_token 키 값이 존재하지 않습니다.' + ) + data['refresh'] = refresh_token + serializer = self.get_serializer(data=data) + try: + serializer.is_valid(raise_exception=True) + except Exception as e: + raise ExceptionHandler( + 'TOKEN_VALIDATION_ERROR', + e + ) + + return Response({ + 'access_token': serializer.validated_data['access'], + 'token_type': 'Bearer', + 'refresh_token': serializer.validated_data['refresh'], + }, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index fe226a6..70447d5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -39,6 +39,9 @@ KAKAO_REAL_NATIVE_API_KEY = env('KAKAO_REAL_NATIVE_API_KEY') # 카카오 실제 native api 키 # 아래는 매 실행마다 코드를 변경해줘야하는 테스트 코드를 임의로 차단하기 위한 환경변수 입니다. SKIP_TEST = env('SKIP_TEST') +GEOCODER_API_KEY = env('GEOCODER_API_KEY') +KAKAO_REAL_JAVASCRIPT_KEY = env('KAKAO_REAL_JAVASCRIPT_KEY') +REFRESH_TOKEN = env('REFRESH_TOKEN') # Quick-start development settings - unsuitable for production @@ -192,6 +195,7 @@ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'authenticate.authentications.CustomAuthentication', ), + 'EXCEPTION_HANDLER': 'services.exception_handler.custom_exception_handler' } # 아래는 celery setting을 담당합니다. @@ -272,6 +276,9 @@ } # 아래는 로그 설정입니다. +LOG_DIR = './logs' +os.makedirs(LOG_DIR, exist_ok=True) + LOGGING = { 'version': 1, 'disable_existing_loggers': False, # 기본 로거 설정 유지 @@ -281,7 +288,7 @@ 'style': '{', # str.format }, 'simple': { - 'format': '{name} {levelname} {asctime} {message}', + 'format': '[{levelname}] | {asctime} | {message}', 'style': '{', }, 'logstash': { @@ -291,22 +298,24 @@ 'handlers': { # 로그 핸들러 설정 'file': { 'level': 'DEBUG', - 'class': 'logging.FileHandler', - 'filename': 'info.log', - 'formatter': 'verbose', - 'encoding': 'utf-8' + 'class': 'logging.handlers.TimedRotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'app.log'), + 'formatter': 'simple', + 'encoding': 'utf-8', + 'when': 'midnight', # 자정마다 새 로그파일 생성 + 'backupCount': 7, # 일주일치만 저장 }, 'logstash': { 'level': 'INFO', 'class': 'config.tcp_log_handler.TCPLogstashHandler', 'host': env('LOGSTASH_HOST'), - 'port': 5000, + 'port': 3306, 'formatter': 'logstash' } }, 'loggers': { # 로거 설정, 실제 get_logger를 이용하여 로그 설정 가져옴 'django': { # 실제 배포 환경에서 사용하는 로거 - 'handlers': ['logstash'], + 'handlers': ['file'], 'level': 'INFO', 'propagate': True, }, @@ -319,4 +328,7 @@ } # 앱 기본 로거 설정 -APP_LOGGER='django' \ No newline at end of file +APP_LOGGER='django' + +# YOLO 모델 디렉터리 설정 +MODEL_DIR = os.path.join(BASE_DIR, "mission", "yolomodels") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 021159d..0f81e23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: db: image: mysql:latest restart: always - ports: - - "3306:3306" +# ports: +# - "3306:3306" environment: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_DATABASE: ${DB_NAME} @@ -50,7 +50,7 @@ services: - app_network migrations: - image: ytk030305/alpha_be:0.9.0 + image: ytk030305/alpha_be:1.0.8 command: sh entrypoint.sh depends_on: - db @@ -62,10 +62,10 @@ services: - app_network conever_api: # api 컨테이너 - image: ytk030305/alpha_be:0.9.0 + image: ytk030305/alpha_be:1.0.8 command: daphne -b 0.0.0.0 -p 8000 config.asgi:application - ports: - - "8000:8000" +# ports: +# - "8000:8000" # volumes: # - .:/code depends_on: @@ -77,46 +77,63 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 networks: - app_network + volumes: + - log_volume:/code/logs - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:8.10.0 - container_name: elasticsearch - environment: - - discovery.type=single-node - - xpack.security.enabled=false - - xpack.security.http.ssl.enabled=false - - ES_JAVA_OPTS=-Xms512m -Xmx512m # 메모리 제한 설정 + nginx: + image: nginx:latest + container_name: nginx + ports: + - "80:80" # 외부 접근 포트 volumes: - - esdata:/usr/share/elasticsearch/data + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - conever_api networks: - app_network + # 아래부터는 로깅 컨테이너 + # 아래는 promtail 설정 + promtail: + image: grafana/promtail:latest + restart: always + depends_on: + - conever_api + - migrations + - celery_worker + - celery_beat + volumes: + - ./promtail-config.yml:/etc/promtail/config.yml + - log_volume:/var/log/django # 로그 공유 - kibana: - image: docker.elastic.co/kibana/kibana:8.10.1 - container_name: kibana - ports: - - "5601:5601" - environment: - ELASTICSEARCH_HOSTS: "http://elasticsearch:9200" - networks: - - app_network + loki: + image: grafana/loki:2.9.2 + restart: always +# ports: +# - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + volumes: + - ./loki-config.yaml:/etc/loki/local-config.yaml + - loki-storage:/loki - logstash: - image: docker.elastic.co/logstash/logstash:8.10.0 - container_name: logstash + grafana: + image: grafana/grafana:latest + restart: always + ports: + - "3000:3000" volumes: - - ./logstash.conf:/usr/share/logstash/pipeline/logstash.conf + - grafana-storage:/var/lib/grafana depends_on: - - elasticsearch - networks: - - app_network + - loki + volumes: db_data: static_volume: - esdata: + log_volume: + grafana-storage: + loki-storage: networks: app_network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/logstash.conf b/logstash.conf index a84bc9e..ca74baa 100644 --- a/logstash.conf +++ b/logstash.conf @@ -1,6 +1,6 @@ input { tcp { - port => 5000 + port => 5044 codec => json } } diff --git a/loki-config.yaml b/loki-config.yaml new file mode 100644 index 0000000..7361fa7 --- /dev/null +++ b/loki-config.yaml @@ -0,0 +1,53 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9095 + +ingester: + lifecycler: + ring: + kvstore: + store: inmemory + replication_factor: 1 + final_sleep: 0s + chunk_idle_period: 5m + max_chunk_age: 1h + chunk_target_size: 1048576 + wal: + enabled: false + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +storage_config: + boltdb_shipper: + active_index_directory: /loki/index + cache_location: /loki/cache + shared_store: filesystem + + filesystem: + directory: /loki/chunks + +compactor: + working_directory: /loki/compactor + shared_store: filesystem + retention_enabled: true + +limits_config: + retention_period: 7d + enforce_metric_name: false + +chunk_store_config: + max_look_back_period: 0s + +table_manager: + retention_deletes_enabled: true + retention_period: 7d diff --git a/middleware/request_logger.py b/middleware/request_logger.py index b04a3ef..0cf9b79 100644 --- a/middleware/request_logger.py +++ b/middleware/request_logger.py @@ -10,7 +10,13 @@ def __init__(self, get_response): def __call__(self, request): response = self.get_response(request) - logger.info( - f"{request.method} {request.path} - {response.status_code} - {request.META.get('REMOTE_ADDR')}" + try: + content = response.content.decode('utf-8') + except Exception as e: + content = f"<>" + + logger.debug( + f"{request.method} {request.path} - {response.status_code} - {request.META.get('REMOTE_ADDR')} - Response: {content}" ) + logger.info(f"{request.method} {request.path} - {response.status_code} - {request.META.get('REMOTE_ADDR')}") return response diff --git a/mission/services.py b/mission/services.py index bef70f9..d51cebf 100644 --- a/mission/services.py +++ b/mission/services.py @@ -20,12 +20,17 @@ # from tour.models import PlaceImages, TravelDaysAndPlaces, Place # 모델을 가져옵니다. import cv2 +import os +from ultralytics import YOLO import numpy as np import requests from skimage.metrics import structural_similarity as ssim + +from services.exception_handler import FatalError, NoObjectException, ValueException from tour.models import PlaceImages, TravelDaysAndPlaces, Place import logging from config.settings import APP_LOGGER +from django.conf import settings logger = logging.getLogger(APP_LOGGER) @@ -50,27 +55,23 @@ def get_image_from_url(url): img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) # 이미지 디코딩 return img else: - logger.error("Failed Image Request") - return None + raise FatalError(error_message="Failed Image Request") except Exception as e: - logger.error(f"Image Download Error {e}") - return None + raise FatalError(error_message=f"Image Download Error {e}") def get_user_image(self): """ 사용자가 촬영한 미션 이미지를 가져옵니다. """ try: # TravelDaysAndPlaces에서 이미지 객체를 찾고 이미지 경로를 가져옵니다. - image_obj = TravelDaysAndPlaces.objects.get(id=self.travel_id, mission=self.mission_id) + image_obj = TravelDaysAndPlaces.objects.get(id=self.travel_id) # 이미지가 실제로 존재한다면, cv2를 사용하여 이미지 파일을 읽어들입니다. if image_obj.mission_image: - image_path = image_obj.mission_image.path - return cv2.imread(image_path) + image_path = image_obj.mission_image.url + return self.get_image_from_url(image_path) else: - logger.warning("There is no mission image") - return None + raise NoObjectException(error_message="There is no mission image") except TravelDaysAndPlaces.DoesNotExist: - logger.error("Failed to get user image. Mission image does not exist.") - return None + raise NoObjectException(error_message="Failed to get user image. Mission image does not exist.") def get_reference_image(self): """ 장소의 예시 이미지를 가져옵니다. """ @@ -80,7 +81,7 @@ def get_reference_image(self): image_url = image_obj.image_url # 이미지 URL 가져오기 return self.get_image_from_url(image_url) except (Place.DoesNotExist, PlaceImages.DoesNotExist): - logger.error("Failed to get reference image.") + raise FatalError(error_message="Failed to get reference image.") return None def calculate_histogram_similarity(self): @@ -119,7 +120,7 @@ def calculate_ssim(self): def get_similarity_score(self, weight_hist=0.5, weight_ssim=0.5): """ 히스토그램과 SSIM의 가중 평균 유사도 """ hist_similarity = self.calculate_histogram_similarity() - ssim_similarity = self.calculate_ssim() + ssim_similarity = self.calculate_ssim # 가중 평균 유사도 계산 score = (weight_hist * hist_similarity) + (weight_ssim * ssim_similarity) @@ -129,7 +130,7 @@ def get_similarity_score(self, weight_hist=0.5, weight_ssim=0.5): def check_mission_success(self): """ 유사도 40% 이상이면 미션 성공, 이하면 실패 """ score = self.get_similarity_score() - return 1 if score >= 40 else 0 # 성공이면 1, 실패면 0 반환 + return 1 if score >= 20 else 0 # 성공이면 1, 실패면 0 반환 """테스트용 코드 """ # if __name__ == "__main__": @@ -174,4 +175,70 @@ def check_mission_success(self): • 두 이미지를 전처리 후 구조적 유사도 측정 저 역순으로 다시 값 return 하여 유사도 구함 -""" \ No newline at end of file +""" + +class ObjectDetection: + """ + - 커스텀 학습한 best.pt 모델로 handheart, peace, smile 인식 + - COCO pretrained yolov8n.pt 모델로 person 인식 + - 주어진 미션 문구에 따라 객체 검출 성공 여부를 판단 + """ + def __init__(self): + # 모델 경로 설정 + custom_model_path = os.path.join(settings.MODEL_DIR, "best.pt") + person_model_path = os.path.join(settings.MODEL_DIR, "yolov8n.pt") + + # YOLO 모델 로드 + self.model_custom = YOLO(custom_model_path) + self.model_person = YOLO(person_model_path) + + # 커스텀 모델 클래스 이름 + self.class_names_custom = ['handheart', 'peace', 'smile'] + + def detect_and_check(self, image_path, mission_content): + """ + :param image_path: 검증할 이미지 파일 경로 (절대경로 또는 MEDIA 경로 기반) + :param mission_content: 미션 문구 (ex: '손가락 하트를 하고 사진을 찍어보세요') + :return: 성공 여부 (True/False) + """ + + # 이미지 읽기 + image = cv2.imread(image_path) + if image is None: + raise ValueException(error_message=f"이미지를 열 수 없습니다: {image_path}") + + # 객체 카운트 초기화 + counts = {name: 0 for name in self.class_names_custom} + counts['person'] = 0 + + # 커스텀 모델로 handheart, peace, smile 탐지 + results_custom = self.model_custom(image, conf=0.5) + for result in results_custom: + for box in result.boxes: + cls_idx = int(box.cls.item()) + if 0 <= cls_idx < len(self.class_names_custom): + cls_name = self.class_names_custom[cls_idx] + counts[cls_name] += 1 + + # 기본 모델로 person 탐지 + results_person = self.model_person(image, conf=0.5, classes=[0]) # 0번 class = person + for result in results_person: + for box in result.boxes: + counts['person'] += 1 + + # 미션에 맞게 성공 여부 판정 + return self.check_mission(mission_content, counts) + + def check_mission(self, mission_content, counts): + """ + 미션 내용에 따라 필요한 객체가 검출되었는지 판단 + """ + + mission_requirements = { + "손가락 하트를 하고 사진을 찍어보세요": ["handheart"], + "브이 포즈로 사진을 찍어보세요": ["peace"], + "여러분이 사진에 꼭 등장해야 해요!": ["person"], + } + required_objects = mission_requirements.get(mission_content, []) + + return all(counts.get(obj, 0) >= 1 for obj in required_objects) \ No newline at end of file diff --git a/mission/tests.py b/mission/tests.py index ffec65e..bf0f45e 100644 --- a/mission/tests.py +++ b/mission/tests.py @@ -1,31 +1,39 @@ from mission.models import Mission -from tour.models import Place -from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY -from django.test import TestCase +from tour.models import Place,PlaceImages from usr.models import User -from services.kakao_token_service import KakaoTokenService +from tour.models import TravelDaysAndPlaces +from django.core.files import File +from tour.models import Travel +from services.tour_api import NearEventInfo +from mission.services import ObjectDetection +import shutil +import tempfile +from django.test import TestCase, override_settings +import json +from django.conf import settings from tests.base import BaseTestCase +TEMP_MEDIA_ROOT = tempfile.mkdtemp() - +@override_settings(MEDIA_ROOT=TEMP_MEDIA_ROOT, DEFAULT_FILE_STORAGE='django.core.files.storage.FileSystemStorage') class TestMission(BaseTestCase): + def __init__(self, methodName: str = "runTest"): + super().__init__(methodName) + def setUp(self): - # 유저 정보 임의 생성 - user = User.objects.create( - sub=3935716527, - username='TestUser', - gender='male', - age_range='1-9', - profile_image_url='https://example.org' - ) - user.set_password('test_password112') - user.save() - # 임의로 미션을 생성합니다. - Mission.objects.create( - content='예시 사진과 유사하게 사진찍기' - ) - Mission.objects.create( - content='손 하트 만든 상태로 사진찍기' - ) + # 유저 정보 임의 생성 및 저장 + # user = User.objects.create( + # sub=3928446869, + # username='TestUser', + # gender='male', + # age_range='1-9', + # profile_image_url='https://example.org' + # ) + # user.set_password('test_password112') + # user.save() + + # 임의 미션 생성 + Mission.objects.create(content='예시 사진과 유사하게 사진찍기') + Mission.objects.create(content='손 하트 만든 상태로 사진찍기') # 장소 생성 self.place1 = Place.objects.create(name="사진 X 장소1", mapX=127.001, mapY=37.501) @@ -39,26 +47,3 @@ def test_mission(self): end_point = '/mission/list/' response = self.client.get(end_point) self.assertEqual(response.status_code, 200) - - def test_mission_random_create_api(self): - """ - 사진이 없는 장소(place)에 대해 임의 미션을 생성하는 POST API 테스트입니다. - """ - url = '/mission/random/' - - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - - data = { - "places": [ - {"place_id": self.place1.id, "image_url": ""}, - {"place_id": self.place2.id, "image_url": ""}, - {"place_id": self.place3.id, "image_url": "https://example.com/image.jpg"} - ] - } - - response = self.client.post(url, data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 201) - self.assertIn("missions", response.json()) - self.assertEqual(len(response.json()["missions"]), 2) diff --git a/mission/urls.py b/mission/urls.py index 19bb7db..71d5d25 100644 --- a/mission/urls.py +++ b/mission/urls.py @@ -1,5 +1,5 @@ from django.urls import path -from .views import MissionListView, MissionImageUploadView, RandomMissionCreateView, MissionCheckCompleteView +from .views import MissionListView, MissionImageUploadView, RandomMissionCreateView, MissionCheckCompleteView, IsMissionCompleteView, MissionImageGetView, SaveMissionCompleteView urlpatterns = [ path('list/', MissionListView.as_view({ @@ -16,4 +16,13 @@ path('check_complete/', MissionCheckCompleteView.as_view({ 'post': 'create', # 사진을 올리고 검사를 받는 로직 구성 }), name='mission_check'), + path('is_complete//', IsMissionCompleteView.as_view({ + 'get': 'retrieve', + })), + path('get_mission_img//', MissionImageGetView.as_view({ + 'get': 'retrieve', + })), + path('save_mission_complete/', SaveMissionCompleteView.as_view({ + 'post': 'create', + })) ] \ No newline at end of file diff --git a/mission/views.py b/mission/views.py index bd02669..658b484 100644 --- a/mission/views.py +++ b/mission/views.py @@ -4,10 +4,20 @@ from rest_framework import status from .models import Mission from .serializers import MissionSerializer -from tour.models import TravelDaysAndPlaces, Place -from .services import ImageSimilarity +from tour.models import TravelDaysAndPlaces, Place, PlaceImages +from .services import ImageSimilarity, ObjectDetection import random from services.tour_api import NearEventInfo +import requests +import tempfile +import traceback +from services.exception_handler import ( + ValidationException, + NoObjectException, + get_error_line, + get_my_function, + NoAttributeException, NoRequiredParameterException, ValueException, UnExpectedException +) # Create your views here. class MissionListView(viewsets.ModelViewSet): @@ -26,11 +36,11 @@ def create(self, request, *args, **kwargs): travel_days_and_places_id = request.data.get('travel_days_id', None) image = request.FILES.get('image', None) if travel_days_and_places_id is None or image is None: - return Response({"Error": "travel_days_and_places_id or image is missing"}, status=status.HTTP_400_BAD_REQUEST) + raise NoRequiredParameterException(error_message="travel_days_and_places_id or image is missing") try: travel_days_and_places = TravelDaysAndPlaces.objects.get(id=travel_days_and_places_id) except TravelDaysAndPlaces.DoesNotExist: - return Response({"Error": "travel_days_id is not exist"}, status=status.HTTP_404_NOT_FOUND) + raise NoObjectException(error_message="travel_days_id is not exist") travel_days_and_places.mission_image = image travel_days_and_places.save() return Response({ @@ -43,117 +53,182 @@ class MissionCheckCompleteView(viewsets.ViewSet): permission_classes = [IsAuthenticated] def create(self, request, *args, **kwargs): - """ - 사용자의 GPS(mapX, mapY)와 이미지 유사도를 통해 - 미션 성공 여부를 판별하는 API입니다. - """ travel_id = request.data.get('travel_id') place_id = request.data.get('place_id') - mission_id = request.data.get('mission_id') - user_lng = request.data.get('mapX') # 경도 - user_lat = request.data.get('mapY') # 위도 - - # 필수값 누락 검사 - if not travel_id: - return Response({"error": "travel_id is required"}, status=status.HTTP_400_BAD_REQUEST) - if not place_id: - return Response({"error": "place_id is required"}, status=status.HTTP_400_BAD_REQUEST) - if not mission_id: - return Response({"error": "mission_id is required"}, status=status.HTTP_400_BAD_REQUEST) - if not user_lat or not user_lng: - return Response({"error": "mapX and mapY are required"}, status=status.HTTP_400_BAD_REQUEST) + mission_id = request.data.get('mission_id') # object_detection 용일 경우 필요 - try: - place = Place.objects.get(id=place_id) - except Place.DoesNotExist: - return Response({"error": "place_id does not exist"}, status=status.HTTP_404_NOT_FOUND) + if not travel_id or not place_id: + raise NoRequiredParameterException() try: - # 위도 경도 float 변환 - place_lat = float(place.mapY) - place_lng = float(place.mapX) - user_lat = float(user_lat) - user_lng = float(user_lng) - - # 거리 계산, 기존에 있던 모듈 사용 - distance = NearEventInfo.haversine(user_lat, user_lng, place_lat, place_lng) - location_pass = distance <= 200.0 - - # 이미지 유사도 검사 - checker = ImageSimilarity(travel_id, place_id, mission_id) - similarity_score = checker.get_similarity_score() - image_pass = similarity_score >= 40.0 - - # 최종 판단 - is_success = location_pass and image_pass + place = Place.objects.get(id=place_id) + travel_place = TravelDaysAndPlaces.objects.get(place=place, travel_id=travel_id) + + # 이미지 비교 방식 결정 + has_original_image = PlaceImages.objects.filter(place=place).exists() + + if has_original_image: + # ✅ 추천 장소 → 유사도 기반 판별 + checker = ImageSimilarity(travel_place.id, place_id, mission_id) + similarity_score = checker.get_similarity_score() + image_pass = similarity_score >= 40.0 + method = "image_similarity" + + else: + # ✅ 랜덤 미션 → 객체 인식 기반 판별 + if not mission_id: + raise NoAttributeException( + 'mission82', + "랜덤 미션 판별에는 mission_id가 필요합니다." + ) + if not travel_place.mission_image: + raise NoObjectException( + 'mission90', + "업로드된 이미지가 없습니다." + ) + + detector = ObjectDetection() + mission_content = travel_place.mission.content + + # S3에서 이미지 다운로드 후 임시파일로 저장 + image_url = travel_place.mission_image.url + with requests.get(image_url, stream=True) as r: + if r.status_code != 200: + raise ValueError("이미지를 불러올 수 없습니다.") + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp: + for chunk in r.iter_content(chunk_size=8192): + tmp.write(chunk) + tmp_path = tmp.name + + image_pass = detector.detect_and_check(tmp_path, mission_content) + similarity_score = None + method = "object_detection" return Response({ - "result": "success" if is_success else "fail", - "similarity_score": similarity_score, - "distance_to_place": round(distance, 2), "image_check_passed": image_pass, - "location_check_passed": location_pass, - "message": "미션 판별 완료" + "method_used": method, + "message": "이미지 판별 완료" }, status=status.HTTP_200_OK) - except ValueError: - return Response({"error": "mapX and mapY must be valid float values"}, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - return Response({"error": "서버 오류가 발생했습니다."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - + except Place.DoesNotExist: + raise NoObjectException(error_message="place_id가 존재하지 않습니다.") + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="여행지 정보가 존재하지 않습니다.") + except ValueError as ve: + return ValueException(error_message=str(ve)) + # except Exception as e: + # raise UnExpectedException(error_message=str(e)) -class RandomMissionCreateView(viewsets.ModelViewSet): +class RandomMissionCreateView(viewsets.ViewSet): """ - 이미지가 없는 장소(place)에 대해 임의의 미션을 생성합니다. - 프론트에서 이미지 링크가 빈 문자열("")로 들어오는 장소만 처리 대상입니다. + 이미지가 없는 장소(place)에 대해 미리 등록된 Mission 중 랜덤으로 할당합니다. + TravelDaysAndPlaces에 mission 필드를 설정합니다. """ - permission_classes = [IsAuthenticated] # 인증된 사용자만 - - # 임의 미션 문구 리스트 - RANDOM_MISSIONS = [ - "근처 구조물과 함께 사진 찍기", - "간판이 보이도록 찍어주세요!", - "이 장소의 전경이 나오도록 찍어보세요", - "내가 방문한 인증샷 남기기", - "해당 위치의 분위기를 담아보세요" - ] + permission_classes = [IsAuthenticated] def create(self, request, *args, **kwargs): - """ - POST 요청 시 빈 이미지 링크("")를 가진 장소들을 필터링하여 - 랜덤한 미션을 각 장소에 생성해주는 로직입니다. - """ - places = request.data.get("places", []) + places = request.data.get("places", None) if not isinstance(places, list): - return Response({ - "error": "places 필드는 리스트여야 합니다." - }, status=status.HTTP_400_BAD_REQUEST) + return Response({"error": "places 필드는 리스트여야 합니다."}, + status=status.HTTP_400_BAD_REQUEST) + + # 관리자 등록 미션들 + missions_queryset = Mission.objects.all() + if not missions_queryset.exists(): + raise UnExpectedException(error_code='NO_MISSION', error_message="Mission 테이블에 등록된 미션이 없습니다.") created_missions = [] for item in places: - place_id = item.get("place_id") + tdp_id = item.get("tdp_id", None) image_url = item.get("image_url", "") + if tdp_id is None: + raise NoRequiredParameterException() - # 빈 이미지 문자열인 경우만 처리 if image_url == "": try: - place = Place.objects.get(id=place_id) - except Place.DoesNotExist: - continue # 잘못된 장소 ID는 무시하고 진행 + tdp = TravelDaysAndPlaces.objects.get(id=int(tdp_id)) + + if tdp.mission is not None: + created_missions.append({ + "tdp_id": tdp.id, + "mission_id": tdp.mission.id, + "mission_content": tdp.mission.content, + }) + continue + + selected_mission = random.choice(missions_queryset) + tdp.mission = selected_mission + tdp.save() + + created_missions.append({ + "tdp_id": tdp_id, + "mission_id": selected_mission.id, + "mission_content": selected_mission.content, + }) + + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="장소 정보 혹은 해당 여행 경로 정보를 불러올 수 없습니다.") + else: + try: + tdp = TravelDaysAndPlaces.objects.get(id=tdp_id) + created_missions.append({ + "tdp_id": tdp_id, + "mission_content": '예시 사진과 유사하게 찍기', + }) + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="장소 정보 혹은 해당 여행 경로 정보를 불러올 수 없습니다.") - # 랜덤 미션 생성 및 저장 - mission_text = random.choice(self.RANDOM_MISSIONS) - mission = Mission.objects.create(content=mission_text) + return Response({ + "message": "랜덤 미션 할당 완료", + "missions": created_missions + }, status=status.HTTP_201_CREATED) + + +class IsMissionCompleteView(viewsets.ViewSet): - # 여기선 연결만 해주고, 나중에 TravelDaysAndPlaces에서 연결해도 OK - created_missions.append({ - "place_id": place_id, - "mission_content": mission.content, - "mission_id": mission.id - }) + def retrieve(self, request, *args, **kwargs): + tdp = kwargs.get('pk', None) + travel_days_and_places = None + try: + travel_days_and_places = TravelDaysAndPlaces.objects.get(id=tdp) + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="해당 여행 장소를 찾을 수 없습니다.") return Response({ - "message": "랜덤 미션 생성 완료", - "missions": created_missions + 'tdp_id': tdp, + 'mission_success': travel_days_and_places.mission_success + }, status=status.HTTP_200_OK) + +class MissionImageGetView(viewsets.ViewSet): + def retrieve(self, request, *args, **kwargs): + tdp = kwargs.get('pk', None) + travel_days_and_places = None + try: + travel_days_and_places = TravelDaysAndPlaces.objects.get(id=tdp) + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="해당 여행 장소를 찾을 수 없습니다.") + + return Response({ + 'tdp_id': tdp, + 'mission_image': travel_days_and_places.mission_image.url if travel_days_and_places.mission_image else None + }, status=status.HTTP_200_OK) + +class SaveMissionCompleteView(viewsets.ViewSet): + permission_classes = [IsAuthenticated] # 로그인 한 사용자만 등록가능 + + def create(self, request, *args, **kwargs): + tdp_id = request.data.get('tdp_id', None) + is_success = request.data.get('is_success', None) + tdp = None + try: + tdp = TravelDaysAndPlaces.objects.get(id=int(tdp_id)) + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message="해당 여행 정보(tdp)가 존재하지 않습니다.") + + tdp.mission_success = bool(is_success) + tdp.save() + return Response({ + "tdp_id": tdp_id, + "is_success": tdp.mission_success, }, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/mission/yolomodels/best.pt b/mission/yolomodels/best.pt new file mode 100644 index 0000000..7121f0d Binary files /dev/null and b/mission/yolomodels/best.pt differ diff --git a/mission/yolomodels/yolov8n.pt b/mission/yolomodels/yolov8n.pt new file mode 100644 index 0000000..0db4ca4 Binary files /dev/null and b/mission/yolomodels/yolov8n.pt differ diff --git a/promtail-config.yml b/promtail-config.yml new file mode 100644 index 0000000..f55bd71 --- /dev/null +++ b/promtail-config.yml @@ -0,0 +1,20 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /var/lib/promtail/positions.yaml # 로그 읽은 위치 기록 (volume mount 필수) + +clients: + - url: http://loki:3100/loki/api/v1/push # Loki 서버 주소 (docker-compose 기준이면 서비스명 사용) + +scrape_configs: + - job_name: django-logs + static_configs: + - targets: + - localhost + labels: + job: django + app: conever-api + environment: dev + __path__: /var/log/django/*.log* diff --git a/requirements.txt b/requirements.txt index 44817f7..76cd49a 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/services/exception_handler.py b/services/exception_handler.py new file mode 100644 index 0000000..1b2556e --- /dev/null +++ b/services/exception_handler.py @@ -0,0 +1,202 @@ +import sys +import inspect, os +from config.settings import APP_LOGGER +import logging +from rest_framework.exceptions import APIException +from rest_framework.views import exception_handler + +logger = logging.getLogger(APP_LOGGER) + +def get_my_function(depth=1): + return sys._getframe(depth).f_code.co_name + +def get_error_line(depth=1): + return sys._getframe(depth).f_lineno + +def get_error_file(depth=1): + return sys._getframe(depth).f_code.co_filename + +def get_error_context(depth=1): + frame = inspect.stack()[depth] + return { + "file": frame.filename, + "func": frame.function, + "line": frame.lineno, + } + +class ExceptionHandler(APIException): + status_code = 400 + default_detail = f"에러 발생" + default_code = 'error' + + + def __init__(self, error_code, error_message, error_depth=2): + """ + :param error_code: 에러 코드 + :param error_message: 에러 메시지 + """ + context = get_error_context(error_depth) + self.error_file = context["file"] + self.error_func = context["func"] + self.error_line = context["line"] + self.error_code = error_code + self.error_message = error_message + super().__init__(detail=self.get_full_details(), code=self.get_codes()) + + def get_full_details(self): + exception_str = f"error file: {self.error_file}, error_func: {self.error_func}, error_line: {self.error_line}, error_code: {self.error_code}, error_message: {self.error_message}" + logger.info(exception_str) + # 코드 보안을 지키기 위해 에러 메시지만 노출합니다. + return self.error_message + + def get_codes(self): + return self.error_code + +class ValidationException(ExceptionHandler): + """ + 해당 예외는 유효성 검사에서 실패가 발생했을 시 발생하는 예외입니다 + status_code: 400 + """ + default_code = 'VALIDATION_ERROR' + default_detail = '유효성 검사 실패.' + status_code = 400 + + def __init__(self, error_code=None, error_message=None): + # 에러 코드와 에러 메시지는 기본값으로 보내는 것이 가능하도록 설정합니다. + super().__init__(error_code, error_message, 3) + +class NoObjectException(ExceptionHandler): + """ + 해당 예외는 요청한 Object가 존재하지 않을 때 발생하는 예외입니다. + 기본으로 404 status code를 반환합니다. + + Attributes: + error_code (str): Custom error code identifying the error. + error_message (str): Detailed error message describing the issue. + + """ + status_code = 404 + default_code = 'NO_OBJECT' + + def __init__(self, error_code=None, error_message=None): + # 에러 코드와 에러 메시지는 기본값으로 보내는 것이 가능하도록 설정합니다. + super().__init__(error_code, error_message, 3) + +class NoAttributeException(ExceptionHandler): + """ + 해당 예외는 요청 객체의 속성이 없을 때 발생하는 예외입니다. + status_code: 400 + + Attributes: + error_code (str): Custom error code identifying the error. + error_message (str): Detailed error message describing the issue. + """ + default_code = 'NO_ATTRIBUTE' + default_detail = '요청한 속성이 존재하지 않습니다.' + status_code = 400 + + def __init__(self, error_code=None, error_message=None): + # 에러 코드와 에러 메시지는 기본값으로 보내는 것이 가능하도록 설정합니다. + super().__init__(error_code, error_message, 3) + +class NoRequiredParameterException(ExceptionHandler): + """ + 해당 예외는 필수 파라미터가 존재하지 않을 때 발생하는 예외입니다. + status_code: 400 + + Attributes: + error_code (str, optional): Custom error code identifying the error. + error_message (str, optional): Detailed error message describing the issue. + """ + status_code = 400 + def __init__(self, error_code=None, error_message=None): + error_code = error_code or 'NO_REQUIRED_PARAMETER' + error_message = error_message or '필수 파라미터 중 일부 혹은 전체가 없습니다.' + super().__init__(error_code, error_message, 3) + +class ValueException(ExceptionHandler): + """ + 해당 예외는 파라미터로 들어와야 할 데이터 타입은 올바르게 들어왔지만, 올바른 형식이 들어오지 않았을 경우에 발생하는 예외 입니다. + + Attributes: + error_code (str, optional): Custom error code identifying the error. + error_message (str, optional): Detailed error message describing the issue. + """ + status_code = 400 + def __init__(self, error_code=None, error_message=None): + error_code = error_code or 'VALUE_ERROR' + error_message = error_message or '입력 형식이 잘못되었습니다.' + super().__init__( + error_code, + error_message, + 3 + ) + +class UnExpectedException(ExceptionHandler): + """ + 해당 예외는 예상치 못한 예외가 발생한 경우에 사용합니다. + status_code: 500 + + Attributes: + error_code (str, optional): Custom error code identifying the error. + error_message (str, optional): Detailed error message describing the issue. + """ + status_code = 500 + + def __init__(self, error_code=None, error_message=None): + error_code = error_code or 'UNEXPECTED_EXCEPTION' + error_message = error_message or '예상치 못한 예외 발생' + super().__init__( + error_code, + error_message, + 3 + ) + + def get_full_details(self): + exception_str = f"error file: {self.error_file}, error_func: {self.error_func}, error_line: {self.error_line}, error_code: {self.error_code}, error_message: {self.error_message}" + logger.warning(exception_str) + # 코드 보안을 지키기 위해 에러 메시지만 노출합니다. + return self.error_message + +class FatalError(ExceptionHandler): + """ + 해당 예외는 예상치 못한 심각한 오류가 발생한 경우에 사용합니다. + + Attributes: + error_code (str, optional): Custom error code identifying the error. + error_message (str, optional): Detailed error message describing the issue. + """ + status_code = 500 + + def __init__(self, error_code=None, error_message=None): + error_code = error_code or 'UNEXPECTED_ERROR' + error_message = error_message or '예상치 못한 서버 오류 발생' + super().__init__( + error_code, + error_message, + 3 + ) + + def get_full_details(self): + exception_str = f"error file: {self.error_file}, error_func: {self.error_func}, error_line: {self.error_line}, error_code: {self.error_code}, error_message: {self.error_message}" + logger.error(exception_str) + # 코드 보안을 지키기 위해 에러 메시지만 노출합니다. + return self.error_message + + + +def custom_exception_handler(exc, context): + """ + DRF의 커스텀 핸들러를 설정하며, detail만 메시지가 갔던 기존 방식에 비해서 status code와 같은 부가 정보를 추가해 보냅니다. + """ + # Call REST framework's default exception handler first, + # to get the standard error response. + response = exception_handler(exc, context) + + # Now add the HTTP status code to the response. + if response is not None: + response.data['status_code'] = response.status_code + + return response + + diff --git a/services/kakao_error_handler.py b/services/kakao_error_handler.py new file mode 100644 index 0000000..79f8c25 --- /dev/null +++ b/services/kakao_error_handler.py @@ -0,0 +1,37 @@ +from config.settings import APP_LOGGER +import logging +import sys + +logger = logging.getLogger(APP_LOGGER) # 로그 설정 + +class KakaoHttpClientException(Exception): + """ + 카카오에서 날라온 예외를 반환하는 코드입니다. + """ + def __init__(self, error_func, error_line, error_code, error_message): + self.error_func = error_func # 에러가 발생한 함수입니다. + self.error_line = error_line # 에러가 발생한 코드 라인입니다. 유지 보수성을 높이기 위해 도입합니다. + self.error_code = error_code + self.error_message = error_message + + def __str__(self): + logger.warning(f"카카오 API 통신 오류. error_code: {self.error_code}, error_message: {self.error_message}") + return f"error_code: {self.error_code}, error_message: {self.error_message}" + +class KakaoServerError(KakaoHttpClientException): + """ + 해당 예외는 카카오에서 제대로 된 값을 주지 못했을 경우 발생하는 오류 입니다. + """ + + def __str__(self): + logger.warning(f"카카오 서버 오류 의심. 호출 함수: {self.error_func}. error_message: {self.error_message}") + return f"kakao server error. error_message: {self.error_message}" + +class KakaoRequestError(KakaoHttpClientException): + """ + 해당 예외는 사용자가 잘못 요청을 보냈을 경우 발생하는 예외입니다. + """ + + def __str__(self): + logger.info(f'Kakao Request Error. Error Function: {self.error_func}. Error Message: {self.error_message}') + return f'Kakao Request Error: {self.error_message}' diff --git a/services/kakao_http_client.py b/services/kakao_http_client.py new file mode 100644 index 0000000..fd24bc1 --- /dev/null +++ b/services/kakao_http_client.py @@ -0,0 +1,131 @@ +import requests +from config.settings import ( + KAKAO_REAL_REST_API_KEY, + KAKAO_REAL_NATIVE_API_KEY, + KAKAO_REAL_JAVASCRIPT_KEY, + KAKAO_ADMIN_KEY +) +from .kakao_error_handler import ( + KakaoHttpClientException, + KakaoRequestError +) +from .exception_handler import ( + get_my_function, + get_error_line +) + + + +class KakaoHttpClient: + """ + 해당 클래스는 카카오 API와 통신을 담당하는 클래스입니다. + 해당 클래스에서는 카카오 API와 통신한 결과값을 그대로 반환합니다. + """ + def __init__(self, + kakao_native_app_key=KAKAO_REAL_NATIVE_API_KEY, + kakao_rest_api_key=KAKAO_REAL_REST_API_KEY, + kakao_javascript_key=KAKAO_REAL_JAVASCRIPT_KEY, + kakao_admin_key=KAKAO_ADMIN_KEY, + ): + self.__kakao_native_app_key = kakao_native_app_key + self.__kakao_rest_api_key = kakao_rest_api_key + self.__kakao_javascript_key = kakao_javascript_key + self.__kakao_admin_key = kakao_admin_key + + + def get_token_refresh_response(self, refresh_token, kakao_api_key=None): + """ + 해당 함수는 카카오 토큰 갱신 응답을 반환합니다. + :param refresh_token: 카카오에서 발급받은 refresh_token + :param kakao_api_key: kakao_api_key를 의미하면 기본값은 실제 커네버의 rest api키로 들어갑니다. + """ + return self.__get_token_response( + grant_type='refresh_token', + kakao_api_key=kakao_api_key, + refresh_token=refresh_token, + ) + + def get_token_response(self, auth_code, redirect_uri, kakao_rest_api_key=None): + return self.__get_token_response( + grant_type='authorization_code', + kakao_rest_api_key=kakao_rest_api_key, + code=auth_code, + redirect_uri=redirect_uri, + ) + + + + def __get_token_response(self, grant_type='refresh_token', kakao_api_key=None, **kwargs): + token_url = 'https://kauth.kakao.com/oauth/token' + # 요청 헤더 + headers = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + } + data = { + 'grant_type': grant_type, + 'client_id': kakao_api_key or self.__kakao_rest_api_key, + } + if grant_type == 'authorization_code': + data['redirect_uri'] = kwargs.get('redirect_uri') + data['code'] = kwargs.get('code') + elif grant_type == 'refresh_token': + data['refresh_token'] = kwargs.get('refresh_token') + else: + raise KakaoHttpClientException( + get_my_function(), + get_error_line(), + 400, + f'grant_type: {grant_type} is not supported.' + ) + # 실제 요청 발송 + response = requests.post(token_url, data=data, headers=headers) + if response.status_code == 200: + return response.json() + elif response.status_code == 400: + # 요청 잘못으로 판단 + raise KakaoRequestError( + get_my_function(), + get_error_line(), + response.status_code, + response.text + ) + # 카카오 오류 발생 + raise KakaoHttpClientException( + get_my_function(), + get_error_line(), + response.status_code, + response.text + ) + + def get_kakao_user_info(self, sub: int, kakao_admin_key=None): + """ + 해당 함수는 카카오 유저의 정보를 가져오는 함수 입니다. + """ + kakao_user_info_url = f'https://kapi.kakao.com/v2/user/me?target_id_type=user_id&target_id={sub}' + + header = { + 'Authorization': f'KakaoAK {kakao_admin_key or self.__kakao_admin_key}', # 함수 파라미터 우선 + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' + } + response = requests.get(kakao_user_info_url, headers=header) # 요청을 받아옵니다. + if response.status_code == 200: # 정상적인 요청이라면 + return response.json() + elif response.status_code // 100 == 4: # 400번대 인경우 + raise KakaoRequestError( + get_my_function(), + get_error_line(), + response.status_code, + response.text + ) + + raise KakaoHttpClientException( + get_my_function(), + get_error_line(), + response.status_code, + response.text + ) + + + +if __name__ == "__main__": + kakao_http_client = KakaoHttpClient() \ No newline at end of file diff --git a/services/kakao_token_service.py b/services/kakao_token_service.py index 9e34fe5..9e46afd 100644 --- a/services/kakao_token_service.py +++ b/services/kakao_token_service.py @@ -1,71 +1,86 @@ +from typing import Optional import requests import logging, json from config.settings import KAKAO_REST_API_KEY, APP_LOGGER +from .exception_handler import ( + get_my_function, + get_error_line +) +from .kakao_http_client import ( + KakaoHttpClient +) +from .kakao_error_handler import ( + KakaoServerError +) +from dataclasses import dataclass logger = logging.getLogger(APP_LOGGER) +@dataclass +class TokenData: + access_token: Optional[str] + refresh_token: Optional[str] + id_token: Optional[str] + token_type: Optional[str] + class KakaoTokenService: """ 해당 서비스는 카카오 토큰 발급에 관여하는 서비스입니다. """ - response = None - status_code = None - access_token = None - refresh_token = None - id_token = None - token_type = None - - def __init__(self, kakao_rest_api_key=KAKAO_REST_API_KEY): - self.kakao_rest_api_key = kakao_rest_api_key + def __init__(self, kakao_api_key=KAKAO_REST_API_KEY): + self.kakao_api_key = kakao_api_key def get_new_access_token(self, refresh_token) -> str: """ 해당 함수는 리프레시 토큰을 이용하여 액세스 토큰을 발급할 때 사용하는 함수 입니다. """ - return self.get_new_access_and_refresh_token(refresh_token)[0] + return self.get_new_tokens(refresh_token).access_token - def get_new_access_and_refresh_token(self, refresh_token: str) -> tuple[str, str]: - refresh_api_data = { - 'grant_type': 'refresh_token', - 'client_id': self.kakao_rest_api_key, - 'refresh_token': refresh_token, - } - response = self.get_kakao_token_response(refresh_api_data) - if response[0] != 200: - raise Exception(response[1]) + def get_new_tokens(self, refresh_token: str) -> TokenData: + """ + 해당 함수는 refresh token을 이용하여 카카오 토큰을 갱신하여 토큰을 반환하는 함수입니다. + :param refresh_token: 카카오에서 발급 받은 리프레시 토큰입니다. + :return: TokenData를 반환합니다. + """ + kakao_http_client = KakaoHttpClient() + response = kakao_http_client.get_token_refresh_response(refresh_token, self.kakao_api_key) + # 객체 내 토큰 저장 + return self.__validate_token_response(response) - response = response[1] - return response.get('access_token', None), response.get('refresh_token', None) + def get_tokens(self, auth_code: str, redirect_uri: str) -> TokenData: + kakao_http_client = KakaoHttpClient( + kakao_rest_api_key=self.kakao_api_key + ) + response = kakao_http_client.get_token_response(auth_code, redirect_uri, self.kakao_api_key) + # 토큰 반환 + return self.__validate_token_response(response) def get_new_refresh_token(self, refresh_token: str) -> str: - return self.get_new_access_and_refresh_token(refresh_token)[1] - - def get_kakao_token_response(self, data) -> tuple[int, json]: # grant_type: access_token (default), refresh_token """ - 해당 함수는 api 통신을 담당하며, 카카오 API와 통신을 한 결과 값 그대로를 전달합니다. + 해당 함수는 리프레시 토큰만 가져오는 역할을 합니다. + :param refresh_token: 카카오에서 발급 받은 리프레시 토큰입니다. """ - # 토클 발급 url - token_url = 'https://kauth.kakao.com/oauth/token' - # 요청 헤더 - headers = { - 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', - } - response = requests.post(token_url, data=data, headers=headers) - if response.status_code == 200: - self.save_object_property(response) - return response.status_code, response.json() - self.response = response.json() - self.status_code = response.status_code - return response.status_code, response.text + return self.get_new_tokens(refresh_token).refresh_token + + def __validate_token_response(self, response_json): + if response_json.get('access_token') is None or\ + response_json.get('id_token') is None or\ + response_json.get('token_type') is None: + raise KakaoServerError( + get_my_function(), + get_error_line(), + 'kakao server exception', + f'토큰 응답 결과: {response_json}' + ) - def save_object_property(self, response): - self.response = response.json() - self.status_code = response.status_code - self.access_token = self.response.get('access_token', None) - self.refresh_token = self.response.get('refresh_token', None) - self.token_type = self.response.get('token_type', None) - self.id_token = self.response.get('id_token', None) \ No newline at end of file + # TokenData 리턴 + return TokenData( + access_token=response_json.get('access_token', None), + refresh_token=response_json.get('refresh_token', None), + id_token=response_json.get('id_token', None), + token_type=response_json.get('token_type', None), + ) diff --git a/tests/base.py b/tests/base.py index 45ad79c..2367530 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,21 +1,37 @@ from django.test import TestCase from services.kakao_token_service import KakaoTokenService -from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY +from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY, REFRESH_TOKEN +from rest_framework_simplejwt.tokens import RefreshToken + +from usr.models import User + class BaseTestCase(TestCase): is_issued_token = False # 토큰 발급을 하였는가 + is_created_user = False @classmethod def setUpClass(cls): if cls.is_issued_token: return cls.is_issued_token = True super().setUpClass() - token_service = KakaoTokenService() - data = { - 'grant_type': 'refresh_token', - 'client_id': KAKAO_REST_API_KEY, - 'refresh_token': KAKAO_REFRESH_TOKEN, - } - token_service.get_kakao_token_response(data) - cls.KAKAO_TEST_ACCESS_TOKEN = token_service.access_token - cls.KAKAO_TEST_ID_TOKEN = token_service.id_token \ No newline at end of file + token_service = KakaoTokenService(KAKAO_REST_API_KEY) + sub = RefreshToken(REFRESH_TOKEN).payload['sub'] + tokens = RefreshToken.for_user(User.objects.get(sub=sub)) + kakao_tokens = token_service.get_new_tokens(KAKAO_REFRESH_TOKEN) + cls.KAKAO_TEST_ACCESS_TOKEN = tokens.access_token + cls.KAKAO_TEST_ID_TOKEN = kakao_tokens.id_token + + @classmethod + def setUpTestData(cls): + if not cls.is_created_user: + user = User.objects.create( + sub=3928446869, + username='TestUser', + gender='male', + age_range='1-9', + profile_image_url='https://example.org' + ) + user.set_password('test_password112') + user.save() + cls.is_created_user = True \ No newline at end of file diff --git a/tour/admin.py b/tour/admin.py index 35053cc..9d459ac 100644 --- a/tour/admin.py +++ b/tour/admin.py @@ -1,5 +1,9 @@ from django.contrib import admin -from .models import Place +from .models import Place, Travel, TravelDaysAndPlaces, PlaceImages, Event # Register your models here. -admin.site.register(Place) # 장소 정보 관리자가 관리 가능하도록 함 \ No newline at end of file +admin.site.register(Place) # 장소 정보 관리자가 관리 가능하도록 함 +admin.site.register(Travel) +admin.site.register(TravelDaysAndPlaces) +admin.site.register(PlaceImages) +admin.site.register(Event) \ No newline at end of file diff --git a/tour/consumers.py b/tour/consumers.py index 48c9286..90dc5ef 100644 --- a/tour/consumers.py +++ b/tour/consumers.py @@ -1,9 +1,11 @@ import json from channels.generic.websocket import AsyncWebsocketConsumer from config.celery import app -from config.settings import PUBLIC_DATA_PORTAL_API_KEY +from config.settings import PUBLIC_DATA_PORTAL_API_KEY, APP_LOGGER from services.tour_api import * import urllib.parse +import logging +logger = logging.getLogger(APP_LOGGER) class TaskConsumer(AsyncWebsocketConsumer): async def connect(self): @@ -17,19 +19,23 @@ async def connect(self): query_string = self.scope['query_string'].decode() # 쿼리 스트링을 불러들입니다. params = urllib.parse.parse_qs(query_string) # 쿼리 스트링을 파라미터로 변환합니다. self.user_id = params.pop('user_id', [None])[0] # user 고유 sub를 가져옵니다. + self.unique_code = params.pop('unique_code', [""])[0] # 웹소켓 통신을 위한 고유 번호를 가져옵니다. + days = params.pop('days', [None])[0] # 여행 기간을 의미합니다. + self.user_id = self.user_id + '_' + self.unique_code if self.unique_code != "" else self.user_id if self.user_id is None: await self.close() return # 웹소켓 그룹에 가입 + logger.info(f'channel_id: {self.user_id} 웹소켓 가입') await self.channel_layer.group_add(self.user_id, self.channel_name) # user_id를 그룹 이름으로 하고 웹소켓에 가입합니다. await self.accept() # 웹소켓 연결 # 요청을 celery task로 보냅니다. areaCode = params.pop('areaCode', [None])[0] # area_code 가져옴 sigunguName = params.pop('sigunguName', [None])[0] # 시군구 이름 가져옴 - if areaCode is None: # areaCode가 존재하지 않는다면 + if areaCode is None or days is None: # areaCode가 존재하지 않는다면 await self.send(text_data=json.dumps({ 'state': 'ERROR', 'Message': '필수 파라미터 중 일부가 없습니다.' @@ -52,7 +58,7 @@ async def connect(self): sigunguCodes.append(sigunguCode) task_result = app.send_task('tour.tasks.get_recommended_tour_based_area', args=[self.user_id, # 채널 레이어 그룹 특정을 위해 보냅니다. - areaCode, Arrange.TITLE_IMAGE.value, sigunguCodes]) + areaCode, days, Arrange.TITLE_IMAGE.value, sigunguCodes]) await self.send(text_data=json.dumps({ 'state': 'OK', 'Message': { @@ -76,7 +82,10 @@ async def receive(self, text_data=None, bytes_data=None): user_id = data.get("user_id", None) areaCode = data.get("areaCode", None) sigunguName = data.get("sigunguName", None) - if user_id is None or areaCode is None: + unique_code = data.get('unique_code', "") # 웹소켓 통신을 위한 고유 번호를 가져옵니다. + user_id = user_id + '_' + unique_code + days = data.get("days", None) + if user_id is None or areaCode is None or days is None: # 데이터가 없다면 예외 처리 await self.send(text_data=json.dumps({ 'state': 'ERROR', diff --git a/tour/models.py b/tour/models.py index b5bbd49..c99e290 100644 --- a/tour/models.py +++ b/tour/models.py @@ -11,6 +11,9 @@ class Travel(models.Model): start_date = models.DateField() # 여행 시작 날짜 end_date = models.DateField() # 여행 마감 날짜 + def __str__(self): + return self.tour_name + class Place(models.Model): # id: pk name = models.CharField(max_length=100) # 장소 이름, 글자 수 제한 @@ -19,6 +22,9 @@ class Place(models.Model): road_address = models.TextField(blank=True, null=True) # 도로명 주소 address = models.TextField(blank=True, null=True) # 지번 주소 + def __str__(self): + return self.name + class TravelDaysAndPlaces(models.Model): # id: pk travel = models.ForeignKey(Travel, on_delete=models.CASCADE) # 여행 제거시 해당 일차도 제거 @@ -26,12 +32,19 @@ class TravelDaysAndPlaces(models.Model): date = models.DateField() # 여행 날짜 mission = models.ForeignKey(Mission, on_delete=models.SET_NULL, blank=True, null=True) # 미션을 추가합니다. 미션 제거시 해당 일차 미션 NULL mission_image = models.ImageField(upload_to='', blank=True, null=True) # 이미지 필드를 추가합니다. + mission_success = models.BooleanField(null = True, blank = True) + + def __str__(self): + return self.travel.tour_name + " " + self.place.name + " " + str(self.date) class PlaceImages(models.Model): # id: pk place = models.ForeignKey(Place, on_delete=models.CASCADE) image_url = models.URLField() # 이미지 url 정보를 저장합니다. + def __str__(self): + return self.place.name + class Event(models.Model): # id: pk category = models.CharField(max_length=100) # 카테고리 @@ -44,4 +57,7 @@ class Event(models.Model): mapY = models.FloatField() # 행사 위도 정보 homepage_url = models.URLField() # 홈페이지 URL + def __str__(self): + return self.title + diff --git a/tour/services.py b/tour/services.py index b408d5c..b615d53 100644 --- a/tour/services.py +++ b/tour/services.py @@ -1,6 +1,9 @@ import requests import json -from config.settings import KAKAO_REST_API_KEY, APP_LOGGER + +from requests import RequestException + +from config.settings import KAKAO_REST_API_KEY, APP_LOGGER, GEOCODER_API_KEY import logging from services import tour_api @@ -23,6 +26,18 @@ def get_parcel_and_road_address(self, x: float, y: float) -> tuple[str, str]: raise Exception('Service Key is required') response = self.__get_kakao_address_response(x=x, y=y) + if response is None: # 만약에 한도 초과가 발생했다면 + response = self.__get_geocoder_response(x=x, y=y) + if response['status'] != 'OK': + logger.warning(response['message']) + return "", "" + parcel = response['result'][0].get('text', '') + if len(response['result']) == 1: + return parcel, "" + road = response['result'][1].get('text', '') + return parcel, road + + if response['meta']['total_count'] == 0: # 정보가 아예 존재하지 않을 때 logger.warning(f'There is no address (x: {x}, y: {y})') return "", "" @@ -65,8 +80,26 @@ def __get_kakao_address_response(self, **kwargs) -> json: headers = {'Authorization': f'KakaoAK {self.service_key}'} response = requests.get(end_point, params=kwargs, headers=headers) if response.status_code != 200: - logger.error('kakao local api error (38)') - raise Exception('Kakao API Error') + logger.error(response.text) + return None return response.json() + def __get_geocoder_response(self, **kwargs): + end_point = "https://api.vworld.kr/req/address" + params = { + "service": "address", + "request": "getAddress", + "point": f"{kwargs['x']},{kwargs['y']}", + "type": "BOTH", + "key": GEOCODER_API_KEY, + "simple": "true" + } + response = requests.get(end_point, params=params) + if response.status_code != 200: + logger.error(response.text) + if response.status_code == 502: # bad gateway인 경우 즉, CI 환경에서는 아래 mockup으로 동작 + return {'service': {'name': 'address', 'version': '2.0', 'operation': 'getAddress', 'time': '11(ms)'}, 'status': 'OK', 'result': [{'zipcode': '03045', 'text': '서울특별시 종로구 세종로 1-58', 'structure': {'level0': '대한민국', 'level1': '서울특별시', 'level2': '종로구', 'level3': '', 'level4L': '세종로', 'level4LC': '1111011900', 'level4A': '청운효자동', 'level4AC': '1111051500', 'level5': '1-58도', 'detail': ''}}, {'zipcode': '03045', 'text': '서울특별시 종로구 사직로 161 (세종로,경복궁)', 'structure': {'level0': '대한민국', 'level1': '서울특별시', 'level2': '종로구', 'level3': '세종로', 'level4L': '사직로', 'level4LC': '3100005', 'level4A': '청운효자동', 'level4AC': '1111051500', 'level5': '161', 'detail': '경복궁'}}]} + raise Exception('Geocoder API Error') + return response.json()['response'] + diff --git a/tour/tests.py b/tour/tests.py index 903c142..12ebd7f 100644 --- a/tour/tests.py +++ b/tour/tests.py @@ -18,15 +18,15 @@ class TestTour(BaseTestCase): def setUp(self): # 유저 정보 임의 생성 - user = User.objects.create( - sub=3935716527, - username='TestUser', - gender='male', - age_range='1-9', - profile_image_url='https://example.org' - ) - user.set_password('test_password112') - user.save() + # user = User.objects.create( + # sub=3928446869, + # username='TestUser', + # gender='male', + # age_range='1-9', + # profile_image_url='https://example.org' + # ) + # user.set_password('test_password112') + # user.save() # 유저 정보 임의 생성2 user2 = User.objects.create( @@ -298,14 +298,59 @@ def test_delete_tour_course(self): # 생성된 여행의 ID 가져오기 tour_id = create_response.json()['id'] + # 2️⃣ 정상적인 코스 저장 요청 + course_data = { + "tour_id": tour_id, + "date": "2025-04-12", + "places": [ + { + "name": "광화문", + "mapX": "126.9769", + "mapY": "37.5759", + "image_url": "https://image.example.com/gwanghwamun.jpg" + }, + { + "name": "서울역", + "mapX": "126.9706", + "mapY": "37.5562", + "image_url": "https://image.example.com/seoul.jpg" + } + ] + } + response = self.client.post('/tour/course/', data=course_data, headers=headers, content_type='application/json') + self.assertEqual(response.status_code, 201) # ✅ 정상적으로 저장되었는지 확인 + + course_data = { + "tour_id": tour_id, + "date": "2025-04-13", + "places": [ + { + "name": "광화문2", + "mapX": "126.9769", + "mapY": "37.5759", + "image_url": "https://image.example.com/gwanghwamun.jpg" + }, + { + "name": "서울역2", + "mapX": "126.9706", + "mapY": "37.5562", + "image_url": "https://image.example.com/seoul.jpg" + } + ] + } + response = self.client.post('/tour/course/', data=course_data, headers=headers, content_type='application/json') + self.assertEqual(response.status_code, 201) # ✅ 정상적으로 저장되었는지 확인 + # 삭제 요청 delete_endpoint = f'/tour/course/{tour_id}/' - delete_response = self.client.delete(delete_endpoint, headers=headers) + delete_data = { + 'target_date': '2025-04-12' + } + delete_response = self.client.delete(delete_endpoint, data=delete_data, headers=headers, content_type='application/json') self.assertEqual(delete_response.status_code, 204) - # 삭제 후 다시 조회 → 404 떠야 정상 - get_response = self.client.get(f'/tour/{tour_id}/', headers=headers) - self.assertEqual(get_response.status_code, 404) + get_response = self.client.get(f'/tour/course/{tour_id}/', headers=headers) + print(get_response.json()) def test_retrieve_course(self): """ diff --git a/tour/urls.py b/tour/urls.py index 013a425..4abef0a 100644 --- a/tour/urls.py +++ b/tour/urls.py @@ -38,4 +38,5 @@ 'get': 'retrieve', # 개별 조회 'delete': 'destroy' # 삭제 }), name='course-detail'), + ] diff --git a/tour/views.py b/tour/views.py index f5f69f1..dbe48b8 100644 --- a/tour/views.py +++ b/tour/views.py @@ -12,6 +12,12 @@ from .models import Travel, Place, TravelDaysAndPlaces, PlaceImages, Event import datetime import logging +from services.exception_handler import ( + ValidationException, + NoAttributeException, + NoRequiredParameterException, + ValueException, NoObjectException +) logger = logging.getLogger(__name__) @@ -53,7 +59,8 @@ def list(self, request, *args, **kwargs): end_date = request.GET.get('end_date', None) if mapX is None or mapY is None: # 필수 파라미터 검증 - return Response({"ERROR": "필수 파라미터 중 일부 혹은 전체가 없습니다."}, status=status.HTTP_400_BAD_REQUEST) + raise NoRequiredParameterException() + # return Response({"ERROR": "필수 파라미터 중 일부 혹은 전체가 없습니다."}, status=status.HTTP_400_BAD_REQUEST) if Event.objects.count() == 0: # 주변 행사 정보가 DB에 없을 경우, 코드는 200 OK로 보냅니다. logger.warning("Event Info is not exist in DB") # 해당 오류는 서버 오류에 가깝기 때문에 로그를 남깁니다. @@ -63,7 +70,7 @@ def list(self, request, *args, **kwargs): try: events = event_info.get_near_by_events(float(mapY), float(mapX), float(radius)) # 주변 행사 정보를 불러옵니다. except ValueError: - return Response({"ERROR": "경도, 위도, 반경 정보 일부 혹은 모두가 데이터 형식이 실수형이 아닙니다."}, status=status.HTTP_400_BAD_REQUEST) + raise ValueException('Value Error', '경도, 위도, 반경 정보 일부 혹은 모두가 데이터 형식이 실수형이 아닙니다.') try: if start_date is not None: @@ -71,7 +78,7 @@ def list(self, request, *args, **kwargs): if end_date is not None: events = events.filter(end_date__lte=end_date) # 마지막 날짜보다 더 작거나 같은 데이터를 불러옵니다. except ValidationError: - return Response({"ERROR": "날짜 값이 날짜 형식이 아닙니다. 반드시 YYYY-MM-DD 형식이어야 합니다."}, status=status.HTTP_400_BAD_REQUEST) + raise ValidationException(error_message="날짜 값이 날짜 형식이 아닙니다. 반드시 YYYY-MM-DD 형식이어야 합니다.") events = events.order_by('start_date') # 날짜 순 정렬 @@ -89,7 +96,8 @@ def create(self, request, *args, **kwargs): user_sub = request.data.get('add_traveler_sub', None) # post body에서 add_traveler_sub를 가져옵니다. travel_id = request.data.get('travel_id', None) # 추가할 여행 if user_sub is None or travel_id is None: - return Response({"Error": "필수 파라미터가 존재하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST) + raise NoRequiredParameterException() + # return Response({"Error": "필수 파라미터가 존재하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST) travel = None try: travel = Travel.objects.get(id=int(travel_id)) @@ -126,7 +134,7 @@ def list(self, request, *args, **kwargs): code_list.append(int(each['code'])) area_code = int(area_code) if area_code not in code_list: - return Response({"There is no area code": f"{area_code}"}, status=status.HTTP_404_NOT_FOUND) + raise NoObjectException('No Area Code', f"There is no area code {area_code}") area_list = tour.get_sigungu_code_list(area_code) response_data[str(area_code)] = area_list return Response(response_data, status=status.HTTP_200_OK) @@ -221,7 +229,7 @@ def create(self, request, *args, **kwargs): # 여행 경로 저장 API ) # 날짜별 장소 연결 저장 - TravelDaysAndPlaces.objects.get_or_create( + tdp, _ = TravelDaysAndPlaces.objects.get_or_create( travel=travel, place=place, date=date @@ -241,6 +249,8 @@ def create(self, request, *args, **kwargs): # 여행 경로 저장 API "image_url": image_url, "road_address": road_address, "parcel_address": parcel_address, + 'place_id': place.id, + 'tdp_id': tdp.id, }) # 최종 응답 반환 @@ -293,6 +303,8 @@ def retrieve(self, request, pk=None): # 여행 경로 가져오기 API "image_url": image_url, "road_address": entry.place.road_address, "parcel_address": entry.place.address, + "place_id": entry.place.id, + "tdp_id": entry.id, }) # 응답 형태: [{ "date": "YYYY-MM-DD", "places": [...] }, ...] @@ -308,17 +320,25 @@ def retrieve(self, request, pk=None): # 여행 경로 가져오기 API def destroy(self, request, pk=None): user_sub = request.user.sub # 로그인한 사용자의 sub tour_id = pk # URL에서 받은 여행 ID - + del_date = request.data.get('target_date', None) + if not del_date: + raise NoRequiredParameterException() try: - travel = Travel.objects.get(id=tour_id, user__sub=user_sub) - except Travel.DoesNotExist: - logger.warning(f'travel id: {tour_id} && sub: {user_sub} is not exist in DB.') - return Response({ - "error": "404", - "message": "해당 여행 ID가 존재하지 않거나, 접근 권한이 없습니다." - }, status=status.HTTP_404_NOT_FOUND) + tour_date = datetime.datetime.strptime(del_date, "%Y-%m-%d") + except ValueError: + raise ValueException( + error_message=f'date: {del_date} is not date format' + ) - travel.delete() + + instances = TravelDaysAndPlaces.objects.filter(travel__id=int(tour_id), date=tour_date) + if not instances.exists(): + logger.warning(f'travel id: {tour_id} && sub: {user_sub} has no travel days.') + raise NoObjectException( + 'No Object exists.', + f'해당 날짜의 여행이 존재하지 않습니다.' + ) + instances.delete() return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, *args, **kwargs): # 여행 경로 리스트 조회 API @@ -328,11 +348,10 @@ def list(self, request, *args, **kwargs): # 여행 경로 리스트 조회 API try: travels = Travel.objects.filter(user__sub=user_sub) # 해당 user의 여행 경로들 except Travel.DoesNotExist: - logger.warning(f'sub: {user_sub} has no travels.') - return Response({ - "error": "404", - "message": "사용자의 여행 경로가 존재하지 않습니다." - }, status=status.HTTP_404_NOT_FOUND) + raise NoObjectException( + 'No Travel object exists.', + f'sub: {user_sub}의 여행이 존재하지 않습니다.' + ) # 여행 경로들에 대한 결과 리스트 생성 travel_results = [] @@ -365,3 +384,4 @@ def list(self, request, *args, **kwargs): # 여행 경로 리스트 조회 API return Response({ "travels": travel_results }, status=status.HTTP_200_OK) + diff --git a/usr/models.py b/usr/models.py index de9d724..5779628 100644 --- a/usr/models.py +++ b/usr/models.py @@ -7,9 +7,10 @@ class User(AbstractUser): # username: Abstract User 필드 사용, 카카오 ID 토큰의 nickname으로 부터 추출하여 저장 sub = models.BigIntegerField(primary_key=True) # pk, 유저 고유 회원 번호를 의미하며, 카카오 ID 토큰으로 부터 추출합니다. - gender = models.CharField(max_length=20) # male or female - age_range = models.CharField(max_length=20) # '1-9' 형식으로 들어옴 + gender = models.CharField(max_length=20, null=True, blank=True) # male or female + age_range = models.CharField(max_length=20, null=True, blank=True) # '1-9' 형식으로 들어옴 profile_image_url = models.URLField() # 프로필 이미지 링크입니다. + username = models.CharField(max_length=100) USERNAME_FIELD = 'sub' REQUIRED_FIELDS = ['username'] diff --git a/usr/services.py b/usr/services.py index ee3a712..f4059f2 100644 --- a/usr/services.py +++ b/usr/services.py @@ -1,13 +1,20 @@ -from django.core.exceptions import ValidationError +from services.exception_handler import ( + get_my_function, + get_error_line, + ValidationException, + ExceptionHandler, + NoAttributeException +) from .models import User from config.settings import ( - KAKAO_ADMIN_KEY, KAKAO_TEST_REST_API_KEY, KAKAO_TEST_NATIVE_API_KEY, KAKAO_REAL_REST_API_KEY, KAKAO_REAL_NATIVE_API_KEY, + KAKAO_REAL_JAVASCRIPT_KEY, ) +from services.kakao_http_client import KakaoHttpClient import requests import jwt import base64 @@ -31,6 +38,7 @@ class UserService: """ user = None sub = None # sub는 long 방식의 정수형을 받습니다. + nickname = None def __init__(self, id_token): """ @@ -40,8 +48,12 @@ def __init__(self, id_token): # payload = jwt.decode(id_token, options={"verify_signature": False}) payload = self.__validate_id_token(id_token) self.sub = payload.get('sub', None) # 회원 번호 저장 + self.nickname = payload.get('nickname', None) # 닉네임 저장 if self.sub is None: - raise Exception("토큰 내 회원정보 일부가 존재하지 않습니다.") + raise NoAttributeException( + 'usr57', + "토큰 내 회원정보 일부가 존재하지 않습니다." + ) self.user = self.get_user() # 함수를 이용해서 유저를 가져옵니다. def __jwt_to_pem(self, n, e): @@ -85,13 +97,20 @@ def __validate_payload(self, payload): KAKAO_TEST_NATIVE_API_KEY, KAKAO_REAL_REST_API_KEY, KAKAO_REAL_NATIVE_API_KEY, + KAKAO_REAL_JAVASCRIPT_KEY ] iss = payload['iss'] aud = payload['aud'] if iss != 'https://kauth.kakao.com': - return ValidationError('issuer information is invalid') + raise ValidationException( + 'usr104', + 'issuer information is invalid' + ) if aud not in valid_aud_list: - return ValidationError('application key is invalid') + raise ValidationException( + 'usr111', + 'application key is invalid' + ) return payload def __download_oidc(self): @@ -124,7 +143,10 @@ def __get_public_pem_key(self, kid): oidc = OIDC.objects.get(kid=kid) except OIDC.DoesNotExist: # 오류 발생 logger.error('키에 해당하는 공개키 정보 없음. (카카오 id 토큰 헤더 손상 의심)') - raise ValidationError("카카오 JWT 헤더 손상 의심") + raise ValidationException( + 'usr149', + "카카오 JWT 헤더 손상 의심" + ) return self.__jwt_to_pem(oidc.n, oidc.e) @@ -139,9 +161,6 @@ def get_or_register_user(self): return self.user, False def get_user(self): - """ - :param sub: 카카오 고유 회원번호를 의미합니다. - """ user = None try: user = User.objects.get(sub=self.sub) # 유저를 가져오는 시도를 합니다. @@ -155,18 +174,9 @@ def register_user(self): 해당 함수는 신규 유저를 실제로 DB에 등록하는 역할을 합니다. """ # 회원가입 시작 - # 카카오 개인 유저 정보를 갖고 오기 위한 url - kakao_user_info_url = f'https://kapi.kakao.com/v2/user/me?target_id_type=user_id&target_id={self.sub}' - - header = { - 'Authorization': f'KakaoAK {KAKAO_ADMIN_KEY}', - 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' - } - response = requests.get(kakao_user_info_url, headers=header) # 요청을 받아옵니다. - if response.status_code == 200: # 정상적으로 데이터가 왔다면 - return self.__upload_user(response.json()) # 실제 데이터 업로드를 진행합니다. - logger.info(f'sub: {self.sub}에 대한 카카오 회원정보 불러오기 오류') # 프론트가 인위적으로 잘못 요청할 수 있기 때문에 info로 로그 남김 - raise Exception("카카오 회원정보를 불러오는 과정에서 오류가 발생했습니다.") + kakao_http_client = KakaoHttpClient() + response = kakao_http_client.get_kakao_user_info(self.sub) # 요청을 받아옵니다. + return self.__upload_user(response) def __upload_user(self, raw_data): """ @@ -187,15 +197,22 @@ def __upload_user(self, raw_data): 'gender'] # 성별 except KeyError as e: logger.error(f"유저 회원 정보 가져오기 오류 (추가 동의 항목 확인 의심): {e}") - raise KeyError(e) + user = User.objects.create( + sub=self.sub, + username=self.nickname + ) + return user for each in user_dict_keys: - data_dict[each] = raw_data[each] + data_dict[each] = raw_data.get(each, None) # 실제 유저 업로드 try: user = User.objects.create(**data_dict) except Exception as e: - raise Exception(e) + raise ExceptionHandler( + 'Unexpected Error', + e + ) logger.info(f"회원가입 완료. 회원명: {user.username} (sub:{self.sub})") return user diff --git a/usr/tests.py b/usr/tests.py index c95ad73..7945776 100644 --- a/usr/tests.py +++ b/usr/tests.py @@ -12,15 +12,15 @@ def setUp(self): 테스트 환경에서 꼭 필요한 데이터를 업로드 하기 위한 메소드 입니다. """ # 유저 정보 임의 생성 - user = User.objects.create( - sub=3935716527, - username='TestUser', - gender='male', - age_range='1-9', - profile_image_url='https://example.org' - ) - user.set_password('test_password112') - user.save() + # user = User.objects.create( + # sub=3928446869, # 앱 키에 따라 내 고유 정보가 달라짐 + # username='TestUser', + # gender='male', + # age_range='1-9', + # profile_image_url='https://example.org' + # ) + # user.set_password('test_password112') + # user.save() user2 = User.objects.create( sub=1, diff --git a/usr/views.py b/usr/views.py index c03534a..4986197 100644 --- a/usr/views.py +++ b/usr/views.py @@ -2,6 +2,8 @@ from rest_framework.response import Response from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated + +from services.exception_handler import ExceptionHandler, UnExpectedException from .serializers import UserSerializer from usr.models import User @@ -23,7 +25,7 @@ def retrieve(self, request, pk=None): }, status=status.HTTP_200_OK) except Exception as e: - return Response({"error": f"서버 오류: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + raise UnExpectedException(error_message=str(e)) class UserListView(viewsets.ModelViewSet): """ @@ -34,6 +36,7 @@ class UserListView(viewsets.ModelViewSet): def get_queryset(self): user_name = self.request.GET.get('user_name', None) + not_admin_user = User.objects.filter(is_superuser=False).filter(is_staff=False) if user_name is not None: - return User.objects.filter(username__icontains=user_name) - return User.objects.all() + return not_admin_user.filter(username__icontains=user_name) + return not_admin_user