diff --git a/.github/workflows/django_CI.yml b/.github/workflows/django_CI.yml index 3d4413e..201da92 100644 --- a/.github/workflows/django_CI.yml +++ b/.github/workflows/django_CI.yml @@ -24,7 +24,6 @@ jobs: - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y libgl1 sudo apt-get install -y dos2unix dos2unix requirements.txt - name: Install Dependencies @@ -37,4 +36,4 @@ jobs: echo "${{ secrets.ENV_FILE }}" > .env - name: Run Tests run: | - python manage.py test --settings=config.settings_ci + python manage.py test --settings=config.settings_ci -v 2 diff --git a/.gitignore b/.gitignore index 47d9c67..913613b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ db.sqlite3 # 로그파일 무시 *.log /logs -nginx.conf \ No newline at end of file +nginx.conf +*.sqlite3 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e1bacbe..8b586d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ 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 COPY . /code/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f0f8fa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 임태근, 조시연 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1ef9d2 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Conever Backend API + +
+

AI가 추천하는 맞춤 여행지 · 순간을 기록하고 다시 꺼내보는 리마인드

+

Conever 모바일 앱의 API Gateway 백엔드

+
+ +--- + +## 목차 +- [프로젝트 개요](#-프로젝트-개요) +- [문제 정의 & 가치 제안](#-문제-정의--가치-제안) +- [핵심 기능](#-핵심-기능) +- [아키텍처 개요](#-아키텍처-개요) +- [기술 스택](#-기술-스택) +- [프로젝트 구조](#-프로젝트-구조) +- [보안 · 프라이버시](#-보안--프라이버시) +- [관측성 · 안정성](#-관측성--안정성) +- [차별점 & 확장성](#-차별점--확장성) +- [커밋 컨벤션](#-커밋-컨벤션) +- [팀-백엔드](#-팀---백엔드) +- [문의](#-문의) +- [라이선스](#-라이선스) + +--- + +## 📌 프로젝트 개요 +**Conever**는 사용자의 지역/테마 선호를 바탕으로 **AI가 여행지를 추천**하고, 여행 중 촬영한 사진을 **인생네컷 형태로 재구성**하며, 특정 주기에 **리마인드 알림**으로 추억을 다시 꺼내보게 하는 서비스입니다. +본 저장소는 모바일 클라이언트를 위한 **API Gateway 백엔드**로, 인증/인가, 데이터 집계, 비동기 오케스트레이션을 담당합니다. + +--- + +## 🎯 문제 정의 & 가치 제안 +- **문제**: 기존 여행 서비스는 예약·길안내 중심으로, *여정의 감정과 스토리*를 기록하고 *주기적으로 리마인드*하는 경험이 부족합니다. +- **가치**: Conever는 (1) **개인화 추천**, (2) **기록의 구조화(인생네컷)**, (3) **시간이 흐른 뒤의 재경험(리마인드)** 을 결합하여 *여행의 전체 수명 주기*를 지원합니다. + +--- + +## 🎯 핵심 기능 +### 🤖 AI 기반 추천 +- 17개 시·도, 7개 카테고리 기반 개인화 추천 +- 한국관광공사 TourAPI + 생성형 AI(Gemini 2.5 Flash) 후처리 파이프라인 + +### 📍 연관 관광지 추천 +- 실제 이동/방문 데이터 기반 연관 장소 제안 +- 인기·연관도 Top-N(예: 최대 50개) 활용, 동선 설계 보조 + +### 📸 사진 저장 & 인생네컷 +- 촬영 사진 업로드/보관 +- 4장 자동 레이아웃 인생네컷 생성 +- (선택) 감정/스토리 태깅 + +### 🔔 추억 리마인드 +- 3개월/6개월/1년/2년/5년 주기 알림 +- 과거 여행 기록/이미지 재노출 + +### 🤝 동반자 & 포즈 추천 +- 관광지별 포즈 추천 +- 동반자 초대, 사진/인생네컷 공동 열람 + +--- + +## 🏗 아키텍처 개요 +- **API Gateway (Django/DRF)**: 인증/인가, 요청 검증, 응답 집계 +- **비동기 처리 (Celery + Redis)**: 이미지 처리, 알림 발송, 데이터 동기화 +- **데이터 레이어 (MySQL)**: 도메인 데이터 영속화 +- **파일 스토리지 (AWS S3)**: 사진/인생네컷 저장 +- **실시간/푸시 (Channels/WebSocket & FCM 연계 가능)**: (선택) 실시간 상태/알림 +- **모니터링 (Promtail → Loki → Grafana)**: 로그/지표 관제 + +간단한 흐름(요약): +``` +Mobile App → (Auth) → API Gateway ─┬─ MySQL + ├─ Redis/Celery (비동기) + ├─ S3 (이미지/인생네컷) + └─ 외부 API(TourAPI/Geocoder/Gemini) +``` + +--- + +## 🛠 기술 스택 +- **Backend**: Django 5.x, Django REST Framework, Django Channels +- **DB/Cache**: MySQL 8.x, Redis 7.2.x +- **Queue/Schedule**: Celery 5.x, Celery Beat +- **Infra**: Docker, Docker Compose, Nginx, AWS S3 +- **Monitoring**: Grafana, Loki, Promtail +- **External APIs**: 한국관광공사 TourAPI, Geocoder API 2.0, Google Gemini + +> 실제 버전은 `requirements.txt`를 기준으로 관리됩니다. + +--- + +## 📂 프로젝트 구조 +``` +alphaBE/ +├── authenticate/ # 인증/인가 +├── config/ # Django 설정 +├── middleware/ # 커스텀 미들웨어 +├── services/ # 서비스 레이어(외부 API, 도메인 로직) +├── tour/ # 여행 도메인 +├── usr/ # 사용자 도메인 +├── tests/ # 테스트 +├── docker-compose.yml +├── Dockerfile +├── requirements.txt +├── manage.py +└── entrypoint.sh +``` + +--- + +## 🔐 보안 · 프라이버시 +- **비밀 관리**: 모든 자격증명/키는 저장소에 포함하지 않습니다(.env, 키 파일 등은 커밋 금지). +- **데이터 최소 수집**: 추천 및 리마인드 목적 범위 내 최소 정보만 수집·저장. +- **익명화/가명화**: 로그·분석 단계에서 개인 식별 정보 최소화. +- **권한 분리**: 읽기/쓰기 권한을 역할별로 분리(원칙: 최소 권한). + +--- + +## 📈 관측성 · 안정성 +- **로그 파이프라인**: Promtail → Loki 집계, Grafana 대시보드 시각화 +- **비동기 오프로딩**: 이미지 처리/AI 후처리/알림 발송은 Celery로 처리 지연 최소화 +- **헬스체크/리트라이**: 외부 API 실패 대비 타임아웃·재시도 정책 설계 +- **확장성 고려**: 캐시 계층(Redis)·수평 확장 전제, WAF/Rate Limit 구성 용이 + +--- + +## 🚀 차별점 & 확장성 +- **여정 전·중·후 전체를 아우르는 설계**: 추천 → 기록(인생네컷) → 리마인드의 폐루프(Closed Loop). +- **데이터 네트워크 효과**: 동반자/공유 기능으로 재참여·재방문 유도. +- **모듈화된 AI 파이프라인**: 모델 교체·프롬프트 튜닝·후처리 모듈을 분리하여 실험 비용 절감. +- **운영 친화성**: 로그/지표 표준화로 A/B 실험·성능 튜닝·오류 분석이 용이. + +--- + +## 🧾 커밋 컨벤션 +**Conventional Commits** 권장: +- `feat:` 기능 추가 +- `fix:` 버그 수정 +- `refactor:` 리팩터링(기능 변화 없음) +- `chore:` 빌드/도구/패키지 갱신 +- `docs:` 문서 수정 +- `test:` 테스트 추가/개선 + +> 기존 `MODIFY` 라벨은 `feat:` 또는 `refactor:` 등 표준 라벨로 대체를 권장합니다. + +--- + +## 👥 팀 - 백엔드 +- **임태근** ([@YimTaeKeun](https://github.com/YimTaeKeun)) — 총괄, 백엔드, 기획 +- **조시연** ([@jimmy914](https://github.com/jimmy914)) — 백엔드, 기획 + +--- + +## 📫 문의 +프로젝트 문의: **ytk030305@naver.com** + +--- + +## 📄 라이선스 +- 라이선스: `LICENSE` 파일 참조 diff --git a/authenticate/tests.py b/authenticate/tests.py index e052882..59460d4 100644 --- a/authenticate/tests.py +++ b/authenticate/tests.py @@ -1,15 +1,19 @@ -import unittest -from django.test import TestCase +import logging +import unittest + +from django.test import override_settings +from django.urls import reverse + from config.settings import ( - KAKAO_AUTH_CODE, # 임시 인가 코드를 가져옵니다. 테스트 실행시마다 .env 파일에서 매번 바꿔줘야합니다. - KAKAO_REFRESH_TOKEN, # 리프레시 토큰. 만료시 바꿔 사용 + KAKAO_AUTH_CODE, # 임시 인가 코드를 가져옵니다. 테스트 실행시마다 .env 파일에서 매번 바꿔줘야합니다. + # 리프레시 토큰. 만료시 바꿔 사용 APP_LOGGER, SKIP_TEST, - KAKAO_REST_API_KEY, + SIMPLE_JWT, + REFRESH_TOKEN, ) -import logging -from services.kakao_token_service import KakaoTokenService from tests.base import BaseTestCase + logger = logging.getLogger(APP_LOGGER) # Create your tests here. @@ -27,38 +31,51 @@ def test_login_callback(self): print(response.json()) self.assertEqual(response.status_code, 201) - @unittest.skipIf(SKIP_TEST == 'True', "Skip Login Refresh Test") - def test_refresh_token(self): + # python의 unpacking은 같은 값이 있다면 덮어쓰기로 진행이 됨. 따라서 BLACKLIST_AFTER_ROTATION값은 False로 됨 + # 아래 설정은 리프레시 토큰은 테스트 중에는 블랙리스트에 넣지 않도록 함 + @override_settings(SIMPLE_JWT={**SIMPLE_JWT, "BLACKLIST_AFTER_ROTATION": False}) + def test_refresh_token_success(self): """ - 해당 테스트는 카카오 리프레시 토큰이 제대로 날라오는지 확인하기 위한 테스트입니다. + 해당 테스트는 토큰이 제대로 refresh가 되는지 테스트합니다. """ - target_uri = '/auth/refresh/' - # TODO 리프레시 토큰으로서 유효기간이 만료되면 바꿔줘야합니다. + uri = reverse('refresh') data = { - 'refresh_token': KAKAO_REFRESH_TOKEN, + 'refresh_token': REFRESH_TOKEN, } - response = self.client.post(target_uri, data=data, content_type='application/json') - self.assertEqual(response.status_code, 201) - # 리프레시 토큰 정보를 body에서 발견하지 못한 경우를 테스트 합니다. + response = self.client.post(uri, data=data, content_type='application/json') + self.assertEqual(response.status_code, 200) + logger.debug('test_refresh_token_success result:' + str(response.json())) + + def test_refresh_token_failure_no_parameter(self): + """ + 해당 테스트는 refresh token 뷰에서 파라미터가 제대로 날라오지 않았을 경우를 테스트합니다. + """ + uri = reverse('refresh') data = { - # 일부러 틀린 정보를 넣습니다. - 'ref': '', + 'ref': '123sdf1', } - response = self.client.post(target_uri, data=data, content_type='application/json') + response = self.client.post(uri, data=data, content_type='application/json') self.assertEqual(response.status_code, 400) - # 토큰 정보 자체를 변환해서 테스트합니다. + logger.debug('refresh no parameter result:' + str(response.json())) + + def test_refresh_token_failure_invalid_token(self): + """ + 해당 테스트는 refresh token 뷰에서 토큰이 유효하지 않은 경우를 테스트합니다. + """ + uri = reverse('refresh') data = { 'refresh_token': '1pbZZHeOq9TsJBPQgA-URNdOUoDlhxp__AAAAAgo9cusAAAGUKtVQgeQ1KlcE_6bt', } - response = self.client.post(target_uri, data=data, content_type='application/json') + response = self.client.post(uri, data=data, content_type='application/json') self.assertEqual(response.status_code, 400) + logger.debug('refresh invalid token result:' + str(response.json())) + - @unittest.skipIf(SKIP_TEST == 'True', "Skip Login Test") - def test_login(self): + def test_login_success(self): """ 해당 함수는 flutter sdk로 발급받은 액세스 토큰과 아이디 토큰을 활용하여 로그인 혹은 회원가입 진행이 되는지 확인합니다. """ - end_point = '/auth/login/' + end_point = reverse('login_register') headers = { 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}' } @@ -66,19 +83,27 @@ def test_login(self): 'id_token': self.KAKAO_TEST_ID_TOKEN, } # register Test - response = self.client.post(end_point, headers=headers, data=data, content_type='application/json') + response = self.client.post(end_point, data=data, content_type='application/json') self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()['is_new'], True) # login Test response = self.client.post(end_point, headers=headers, data=data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(response.json()['is_new'], False) + logger.debug('login success result:' + str(response.json())) + def test_login_failure(self): + """ + 로그인 실패를 테스트합니다. + """ + end_point = reverse('login') + headers = { + 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}' + } # 400 Test data2 = { - 'id_token' : 'hsesefs' + 'id_token': 'hsesefs' } response = self.client.post(end_point, headers=headers, data=data2, content_type='application/json') self.assertEqual(response.status_code, 400) - + logger.debug('login failure result:' + str(response.json())) diff --git a/authenticate/urls.py b/authenticate/urls.py index 6f038bd..569437d 100644 --- a/authenticate/urls.py +++ b/authenticate/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import kakao_callback, KakaoRefreshTokens, LoginRegisterView, CustomTokenRefreshView -from rest_framework_simplejwt.views import TokenRefreshView + +from .views import kakao_callback, LoginRegisterView, CustomTokenRefreshView urlpatterns = [ path('login/', LoginRegisterView.as_view({ @@ -10,5 +10,5 @@ # path('refresh/', KakaoRefreshTokens.as_view({ # 'post': 'create', # }), name='refresh_tokens'), - path('refresh/', CustomTokenRefreshView.as_view()) + path('refresh/', CustomTokenRefreshView.as_view(), name='refresh'), ] \ No newline at end of file diff --git a/authenticate/views.py b/authenticate/views.py index 81a0990..105dc70 100644 --- a/authenticate/views.py +++ b/authenticate/views.py @@ -1,17 +1,17 @@ +import logging + from django.http import JsonResponse from rest_framework import status, viewsets 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.tokens import RefreshToken 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_REAL_NATIVE_API_KEY, KAKAO_REST_API_KEY, APP_LOGGER # 환경변수를 가져옵니다. +from services.exception_handler import * +from services.kakao_error_handler import KakaoRequestError +from services.kakao_token_service import KakaoTokenService +from usr.services import UserService, TestUserCreationService -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. @@ -89,8 +89,12 @@ def create(self, request): if id_token is None: # id token 정보가 없는 경우 return Response({"Error": "id_token 정보가 존재하지 않습니다."}, status=status.HTTP_400_BAD_REQUEST) try: - user_service = UserService(id_token) - user, is_new = user_service.get_or_register_user() # 로그인, 회원가입 처리 + if id_token == 'tester': # 테스터 로그인이라면 + user_service = TestUserCreationService() + user, is_new = user_service.get_or_create_test_user() + else: + user_service = UserService(id_token) + user, is_new = user_service.get_or_register_user() # 로그인, 회원가입 처리 except Exception as e: return Response({"Error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -107,6 +111,7 @@ def create(self, request): "profile_image_url": user.profile_image_url, "age_range": user.age_range, "gender": user.gender, + "privacy_policy_agree": user.privacy_policy_agree }, "tokens": { "access_token": accessToken, diff --git a/config/asgi.py b/config/asgi.py index 80e6521..59bc4da 100644 --- a/config/asgi.py +++ b/config/asgi.py @@ -8,7 +8,6 @@ """ import os -import django from django.core.asgi import get_asgi_application @@ -27,4 +26,4 @@ application = ProtocolTypeRouter({ "http": get_asgi_application(), "websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)), -}) +}) \ No newline at end of file diff --git a/config/celery.py b/config/celery.py index 5ad1047..3f5a6d4 100644 --- a/config/celery.py +++ b/config/celery.py @@ -1,8 +1,10 @@ +import logging import os from celery import Celery -import logging + from config.settings import APP_LOGGER + logger = logging.getLogger(APP_LOGGER) # 장고 환경 설정을 불러들입니다. diff --git a/config/settings.py b/config/settings.py index 70447d5..7b673bd 100644 --- a/config/settings.py +++ b/config/settings.py @@ -10,10 +10,11 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ """ -from pathlib import Path -import os, environ -from datetime import timedelta +import environ import logging +import os +from datetime import timedelta +from pathlib import Path # .env 파일을 읽기 위한 객체 생성 env = environ.Env() @@ -71,11 +72,11 @@ 'authenticate', 'usr', 'tour', - 'mission', 'rest_framework_simplejwt', 'rest_framework_simplejwt.token_blacklist', # 토큰 블랙리스트 위해 필요 'channels', 'storages', + 'django_filters', ] ASGI_APPLICATION = 'config.asgi.application' CHANNEL_LAYERS = { @@ -124,15 +125,6 @@ WSGI_APPLICATION = 'config.wsgi.application' -# Database -# https://docs.djangoproject.com/en/5.1/ref/settings/#databases - -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.sqlite3', -# 'NAME': BASE_DIR / 'db.sqlite3', -# } -# } # 기본 데이터 베이스를 mysql로 설정합니다. DATABASES = { 'default': { @@ -195,7 +187,8 @@ 'DEFAULT_AUTHENTICATION_CLASSES': ( 'authenticate.authentications.CustomAuthentication', ), - 'EXCEPTION_HANDLER': 'services.exception_handler.custom_exception_handler' + 'EXCEPTION_HANDLER': 'services.exception_handler.custom_exception_handler', + 'DEFAULT_FILTER_BACKENDS': ('django_filters.rest_framework.DjangoFilterBackend',), } # 아래는 celery setting을 담당합니다. @@ -236,8 +229,8 @@ # simple jwt setting SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), # 토큰 유효시간 설정 1시간으로 설정 - "REFRESH_TOKEN_LIFETIME": timedelta(days=5), # 리프레시 토큰 유효기간 설정 리프레시 토큰 유효기간은 5일로 설정 + "ACCESS_TOKEN_LIFETIME": timedelta(days=5), # 토큰 유효기간 5일로 설정 + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), # 리프레시 토큰 유효기간 설정 리프레시 토큰 유효기간은 5일로 설정 "ROTATE_REFRESH_TOKENS": True, # 리프레시 토큰도 같이 반환됩니다. "BLACKLIST_AFTER_ROTATION": True, # 이전 토큰 블랙리스트 적용, 사용시 설치앱에 rest_framework_simplejwt.token_blacklist 추가 필요 "UPDATE_LAST_LOGIN": False, # last_login field가 업데이트 됩니다. (커스텀 모델이라 X) @@ -246,7 +239,7 @@ "SIGNING_KEY": SECRET_KEY, # 장고 자체의 시크릿 키로 signing key 지정 "VERIFYING_KEY": "", "AUDIENCE": None, - "ISSUER": None, # 토큰 발급자 명시 + "ISSUER": "Conever", # 토큰 발급자 명시 "JSON_ENCODER": None, "JWK_URL": None, "LEEWAY": 0, @@ -291,9 +284,6 @@ 'format': '[{levelname}] | {asctime} | {message}', 'style': '{', }, - 'logstash': { - '()': 'logstash_formatter.LogstashFormatterV1', - }, }, 'handlers': { # 로그 핸들러 설정 'file': { @@ -305,13 +295,6 @@ 'when': 'midnight', # 자정마다 새 로그파일 생성 'backupCount': 7, # 일주일치만 저장 }, - 'logstash': { - 'level': 'INFO', - 'class': 'config.tcp_log_handler.TCPLogstashHandler', - 'host': env('LOGSTASH_HOST'), - 'port': 3306, - 'formatter': 'logstash' - } }, 'loggers': { # 로거 설정, 실제 get_logger를 이용하여 로그 설정 가져옴 'django': { # 실제 배포 환경에서 사용하는 로거 @@ -330,5 +313,19 @@ # 앱 기본 로거 설정 APP_LOGGER='django' -# YOLO 모델 디렉터리 설정 -MODEL_DIR = os.path.join(BASE_DIR, "mission", "yolomodels") \ No newline at end of file + + +# 캐시 설정 +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': f'redis://{env("CHANNEL_HOST")}:6379/1', + 'OPTION': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'TIMEOUT': 600, # cache 유효 기간을 10분으로 설정합니다. + } + } +} + +# 파일 최대 업로드 설정 +FILE_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024 # 100MB로 제한 \ No newline at end of file diff --git a/config/tcp_log_handler.py b/config/tcp_log_handler.py deleted file mode 100644 index 2e234fd..0000000 --- a/config/tcp_log_handler.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import logging -import socket - -class TCPLogstashHandler(logging.Handler): - def __init__(self, host, port): - super().__init__() - self.host = host - self.port = port - self.sock = None # 초기에는 연결하지 않음 - - def create_socket(self): - if self.sock is None: - self.sock = socket.create_connection((self.host, self.port)) - - def emit(self, record): - try: - self.create_socket() # emit 시점에 소켓 연결 - log_entry = self.format(record) - self.sock.sendall((log_entry + "\n").encode('utf-8')) - except Exception: - self.handleError(record) - - def close(self): - if self.sock: - try: - self.sock.close() - except Exception: - pass - super().close() diff --git a/config/urls.py b/config/urls.py index 28aba7f..ae45e60 100644 --- a/config/urls.py +++ b/config/urls.py @@ -22,5 +22,4 @@ path('auth/', include('authenticate.urls')), path('user/',include('usr.urls')), path('tour/', include('tour.urls')), - path('mission/', include('mission.urls')), ] diff --git a/config/wsgi.py b/config/wsgi.py index e232e56..a6bccce 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -13,4 +13,4 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') -application = get_wsgi_application() +application = get_wsgi_application() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 0f81e23..d639dc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,7 @@ services: db: image: mysql:latest restart: always + # 배포 땐 아래 포트 막을 것 # ports: # - "3306:3306" environment: @@ -24,8 +25,8 @@ services: - app_network celery_worker: - image: ytk030305/alpha_celery:0.4.0 - command: celery -A config worker -l INFO + image: ytk030305/alpha_celery:1.1.4 + command: celery -A config worker -l INFO --logfile=/code/logs/celery_server.log depends_on: - db - redis @@ -35,10 +36,12 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 networks: - app_network + volumes: + - log_volume:/code/logs celery_beat: - image: ytk030305/alpha_celery:0.4.0 - command: celery -A config beat -l INFO + image: ytk030305/alpha_celery:1.1.4 + command: celery -A config beat -l INFO --logfile=/code/logs/celery_server.log depends_on: - db - redis @@ -48,9 +51,11 @@ services: - CELERY_RESULT_BACKEND=redis://redis:6379/0 networks: - app_network + volumes: + - log_volume:/code/logs migrations: - image: ytk030305/alpha_be:1.0.8 + image: ytk030305/alpha_be:2.1.6 command: sh entrypoint.sh depends_on: - db @@ -62,7 +67,7 @@ services: - app_network conever_api: # api 컨테이너 - image: ytk030305/alpha_be:1.0.8 + image: ytk030305/alpha_be:2.1.6 command: daphne -b 0.0.0.0 -p 8000 config.asgi:application # ports: # - "8000:8000" diff --git a/middleware/request_logger.py b/middleware/request_logger.py index 0cf9b79..eef10d9 100644 --- a/middleware/request_logger.py +++ b/middleware/request_logger.py @@ -1,4 +1,5 @@ import logging + from config.settings import APP_LOGGER logger = logging.getLogger(APP_LOGGER) diff --git a/mission/__init__.py b/mission/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/mission/admin.py b/mission/admin.py deleted file mode 100644 index 60b31a0..0000000 --- a/mission/admin.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.contrib import admin -from .models import Mission - -# Register your models here. -admin.site.register(Mission) # 미션을 추가함으로써 장고 관리자 화면에서 미션을 임의로 추가할 수 있도록 합니다. \ No newline at end of file diff --git a/mission/apps.py b/mission/apps.py deleted file mode 100644 index 116aafc..0000000 --- a/mission/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class MissionConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'mission' diff --git a/mission/models.py b/mission/models.py deleted file mode 100644 index 087059e..0000000 --- a/mission/models.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.db import models - -# Create your models here. -class Mission(models.Model): # 미션 모델을 생성합니다. - # id: pk - content = models.TextField() # 미션 내용 글자 수 제한 해제 - - def __str__(self): # 미션 생성시 오브젝트가 아닌 미션 이름이 보이도록 수정 - return self.content \ No newline at end of file diff --git a/mission/serializers.py b/mission/serializers.py deleted file mode 100644 index 36174d3..0000000 --- a/mission/serializers.py +++ /dev/null @@ -1,7 +0,0 @@ -from rest_framework import serializers -from .models import Mission - -class MissionSerializer(serializers.ModelSerializer): - class Meta: - model = Mission - fields = '__all__' \ No newline at end of file diff --git a/mission/services.py b/mission/services.py deleted file mode 100644 index d51cebf..0000000 --- a/mission/services.py +++ /dev/null @@ -1,244 +0,0 @@ -""" - ISSUE - #36, ASSIGNEE - dbsthf04 - 이 파일에 CV 코드를 작성합니다. - 함수 혹은 클래스 형태로 코드를 작성하여 Views.py에서 클래스나 함수 형태로 사용하여 결과값을 받을 수 있도록 해주시기 바랍니다. - - <주의 사항> - 1. 모듈 설치시 'pip install <모듈 이름>'으로 설치 후 반드시 'pip freeze > requirements.txt' 명령어를 입력하여 주시기 바랍니다. - 2. 파이참 IDE 사용시 가상환경이 자동으로 설정되나, vscode와 같은 IDE를 사용하신다면 반드시 초기에 가상환경 설정 후 - 'pip install -r requirements.txt'를 해주시기 바랍니다. - 3. 반드시 git flow 전략을 따르도록 하며, feature/#<이슈 번호>-<브랜치 이름> 형식의 브랜치를 파서 진행해주세요 - 4. 풀 리퀘스트를 생성할 때 source branch를 develop, target branch를 작업 브랜치로 해서 풀리퀘 생성해주세요. - 5. 반드시 코드 리뷰어는 PM으로 해놓습니다. - TODO pip freeze > requirements.txt 명령어 반드시 입력 - - <추가 인지 사항> - 1. 미션에 해당하는, 개인이 촬영한 이미지는 TravelDaysAndPlaces.objects.get(id='여행 번호', mission='미션 번호')로 가져옵니다. - 2. 장소 예시 이미지는 PlaceImages.objects.get(place=Place.objects.get(id='여행 장소 번호')) - 3. 여행 장소 번호, 여행 번호, 미션 번호는 함수나 클래스의 '입력값' 즉, 인수로 사용됩니다. -""" -# 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) - - -class ImageSimilarity: - def __init__(self, travel_id, place_id, mission_id): - self.travel_id = travel_id - self.place_id = place_id - self.mission_id = mission_id - self.img1 = self.get_user_image() - self.img2 = self.get_reference_image() - - @staticmethod - def get_image_from_url(url): - """ URL로부터 이미지를 가져옵니다. """ - try: - # 이미지 URL로부터 데이터 가져오기 - response = requests.get(url) - # 응답이 정상적이면 이미지 데이터를 NumPy 배열로 변환 - if response.status_code == 200: - img_array = np.array(bytearray(response.content), dtype=np.uint8) - img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) # 이미지 디코딩 - return img - else: - raise FatalError(error_message="Failed Image Request") - except Exception as e: - 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) - # 이미지가 실제로 존재한다면, cv2를 사용하여 이미지 파일을 읽어들입니다. - if image_obj.mission_image: - image_path = image_obj.mission_image.url - return self.get_image_from_url(image_path) - else: - raise NoObjectException(error_message="There is no mission image") - except TravelDaysAndPlaces.DoesNotExist: - raise NoObjectException(error_message="Failed to get user image. Mission image does not exist.") - - def get_reference_image(self): - """ 장소의 예시 이미지를 가져옵니다. """ - try: - place_obj = Place.objects.get(id=self.place_id) - image_obj = PlaceImages.objects.get(place=place_obj) - image_url = image_obj.image_url # 이미지 URL 가져오기 - return self.get_image_from_url(image_url) - except (Place.DoesNotExist, PlaceImages.DoesNotExist): - raise FatalError(error_message="Failed to get reference image.") - return None - - def calculate_histogram_similarity(self): - """ RGB 히스토그램 유사도 계산 """ - if self.img1 is None or self.img2 is None: # 이미지가 None인 경우, 유사도 0 반환 - return 0 - - # 두 이미지의 RGB 히스토그램을 계산 - hist1 = cv2.calcHist([self.img1], [0], None, [256], [0, 256]) - hist2 = cv2.calcHist([self.img2], [0], None, [256], [0, 256]) - - # 히스토그램 정규화 - hist1 = cv2.normalize(hist1, hist1).flatten() - hist2 = cv2.normalize(hist2, hist2).flatten() - - # 두 히스토그램의 유사도를 비교하고, 상관계수를 반환 - return cv2.compareHist(hist1, hist2, cv2.HISTCMP_CORREL) - - @property - def calculate_ssim(self): - """ SSIM 유사도 계산 """ - if self.img1 is None or self.img2 is None: - return 0 - - # 이미지를 그레이스케일로 변환하여 구조적 유사도를 계산 - gray1 = cv2.cvtColor(self.img1, cv2.COLOR_BGR2GRAY) - gray2 = cv2.cvtColor(self.img2, cv2.COLOR_BGR2GRAY) - - # 두 이미지의 크기를 맞추기 위해 첫 번째 이미지(유저)를 두 번째 이미지(예시사진)의 크기로 리사이즈 - gray1 = cv2.resize(gray1, (gray2.shape[1], gray2.shape[0])) - - # ssim 함수는 두 이미지를 비교하여 구조적 유사도를 계산합니다. - similarity_index, _ = ssim(gray1, gray2, full=True) - return similarity_index - - 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 - - # 가중 평균 유사도 계산 - score = (weight_hist * hist_similarity) + (weight_ssim * ssim_similarity) - - return round(score * 100, 2) # 최종 유사도 반환(0~100 범위로 변환) - - def check_mission_success(self): - """ 유사도 40% 이상이면 미션 성공, 이하면 실패 """ - score = self.get_similarity_score() - return 1 if score >= 20 else 0 # 성공이면 1, 실패면 0 반환 - -"""테스트용 코드 """ -# if __name__ == "__main__": -# similarity_checker = ImageSimilarity() -# print(f"유사도 점수: {similarity_checker.get_similarity_score()}%") - -# print("미션 성공 여부:", "성공" if similarity_checker.check_mission_success() else "실패") - -""" - -위 테스트용 코드를 기반으로 이름을 정한다고 가정 -자세한 기능은 코드 참조 - -similarity_checker = ImageSimilarity() -'similarity_checker.check_mission_success()' 이거 실행하면 알아서 됨 - -1. 클래스의 객체가 생성되면서 - self.img1은 'get_user_image()' 호출 - self.img2는 get_reference_image() 호출 -2. get_user_image() 호출 - • 사용자가 올린 이미지를 가져오는 함수 -3. get_reference_image() 호출 - • 장소의 예시사진 url을 받아오는 함수 - • get_image_from_url()을 호출 -4. get_image_from_url() 호출 - • get_reference_image()에서의 url로 이미지를 다운하는 함수 - -similarity_checker.check_mission_success()을 실행시 차례대로 함수 호출하고 반환됨 - -5. check_mission_success() 호출 - • 사진 유사도 점수를 보고 미션 성공 여부 판단 - • get_similarity_score() 호출 -6. get_similarity_score() 호출 - • 최종 유사도 반환 - • 히스토그램과 ssim 방식의 각 유사도를 평균으로 합한 유사도 계산 - • calculate_histogram_similarity() 호출 - • calculate_ssim() 호출 - -7. calculate_histogram_similarity() 호출 - • 두 이미지의 히스토그램을 계산하여 유사도 측정 -8. calculate_ssim() 호출 - • 두 이미지를 전처리 후 구조적 유사도 측정 -저 역순으로 다시 값 return 하여 유사도 구함 - -""" - -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 deleted file mode 100644 index bf0f45e..0000000 --- a/mission/tests.py +++ /dev/null @@ -1,49 +0,0 @@ -from mission.models import Mission -from tour.models import Place,PlaceImages -from usr.models import User -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=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) - self.place2 = Place.objects.create(name="사진 X 장소2", mapX=127.002, mapY=37.502) - self.place3 = Place.objects.create(name="사진 있는 장소", mapX=127.003, mapY=37.503) - - def test_mission(self): - """ - 기본 미션 리스트 조회 테스트 - """ - end_point = '/mission/list/' - response = self.client.get(end_point) - self.assertEqual(response.status_code, 200) diff --git a/mission/urls.py b/mission/urls.py deleted file mode 100644 index 71d5d25..0000000 --- a/mission/urls.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.urls import path -from .views import MissionListView, MissionImageUploadView, RandomMissionCreateView, MissionCheckCompleteView, IsMissionCompleteView, MissionImageGetView, SaveMissionCompleteView - -urlpatterns = [ - path('list/', MissionListView.as_view({ - 'get': 'list', # get 메소드에만 매핑합니다. - }), name='mission_list'), - - path('image_upload/', MissionImageUploadView.as_view({ - 'post': 'create', - }), name='mission_image_upload'), - - path('random/',RandomMissionCreateView.as_view({ - 'post': 'create', - }), name = 'mission_random_create'), - 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 deleted file mode 100644 index 658b484..0000000 --- a/mission/views.py +++ /dev/null @@ -1,234 +0,0 @@ -from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework import status -from .models import Mission -from .serializers import MissionSerializer -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): - queryset = Mission.objects.all() - serializer_class = MissionSerializer - -class MissionImageUploadView(viewsets.ModelViewSet): - """ - 여행 일차별 방문 장소 id를 받습니다. - """ - permission_classes = [IsAuthenticated] # 로그인 한 사용자만 - def create(self, request, *args, **kwargs): - """ - post 요청시 들어오는 함수입니다. 여행 일차별 방문 장소 id와 이미지를 받습니다. - """ - 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: - 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: - raise NoObjectException(error_message="travel_days_id is not exist") - travel_days_and_places.mission_image = image - travel_days_and_places.save() - return Response({ - "message": "image upload success", - 'mission_image_url': travel_days_and_places.mission_image.url, # aws url 형식으로 줍니다. - }, status=status.HTTP_201_CREATED) - - -class MissionCheckCompleteView(viewsets.ViewSet): - permission_classes = [IsAuthenticated] - - def create(self, request, *args, **kwargs): - travel_id = request.data.get('travel_id') - place_id = request.data.get('place_id') - mission_id = request.data.get('mission_id') # object_detection 용일 경우 필요 - - if not travel_id or not place_id: - raise NoRequiredParameterException() - - try: - 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({ - "image_check_passed": image_pass, - "method_used": method, - "message": "이미지 판별 완료" - }, status=status.HTTP_200_OK) - - 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.ViewSet): - """ - 이미지가 없는 장소(place)에 대해 미리 등록된 Mission 중 랜덤으로 할당합니다. - TravelDaysAndPlaces에 mission 필드를 설정합니다. - """ - permission_classes = [IsAuthenticated] - - def create(self, request, *args, **kwargs): - places = request.data.get("places", None) - if not isinstance(places, list): - 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: - tdp_id = item.get("tdp_id", None) - image_url = item.get("image_url", "") - if tdp_id is None: - raise NoRequiredParameterException() - - if image_url == "": - try: - 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="장소 정보 혹은 해당 여행 경로 정보를 불러올 수 없습니다.") - - return Response({ - "message": "랜덤 미션 할당 완료", - "missions": created_missions - }, status=status.HTTP_201_CREATED) - - -class IsMissionCompleteView(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_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 deleted file mode 100644 index 7121f0d..0000000 Binary files a/mission/yolomodels/best.pt and /dev/null differ diff --git a/mission/yolomodels/yolov8n.pt b/mission/yolomodels/yolov8n.pt deleted file mode 100644 index 0db4ca4..0000000 Binary files a/mission/yolomodels/yolov8n.pt and /dev/null differ diff --git a/promtail-config.yml b/promtail-config.yml index f55bd71..efd1b59 100644 --- a/promtail-config.yml +++ b/promtail-config.yml @@ -9,12 +9,21 @@ clients: - url: http://loki:3100/loki/api/v1/push # Loki 서버 주소 (docker-compose 기준이면 서비스명 사용) scrape_configs: - - job_name: django-logs + - job_name: api-logs static_configs: - targets: - localhost labels: - job: django + job: apiLog app: conever-api - environment: dev - __path__: /var/log/django/*.log* + environment: prod + __path__: /var/log/django/app.log* + - job_name: celery-logs + static_configs: + - targets: + - localhost + labels: + job: celeryLog + app: celery_server + environment: prod + __path__: /var/log/django/celery_server.log* diff --git a/requirements.txt b/requirements.txt index 76cd49a..c06f642 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/services/exception_handler.py b/services/exception_handler.py index 1b2556e..e5b34c0 100644 --- a/services/exception_handler.py +++ b/services/exception_handler.py @@ -1,10 +1,12 @@ -import sys -import inspect, os -from config.settings import APP_LOGGER +import inspect import logging +import sys + from rest_framework.exceptions import APIException from rest_framework.views import exception_handler +from config.settings import APP_LOGGER + logger = logging.getLogger(APP_LOGGER) def get_my_function(depth=1): @@ -184,7 +186,6 @@ def get_full_details(self): return self.error_message - def custom_exception_handler(exc, context): """ DRF의 커스텀 핸들러를 설정하며, detail만 메시지가 갔던 기존 방식에 비해서 status code와 같은 부가 정보를 추가해 보냅니다. diff --git a/services/kakao_error_handler.py b/services/kakao_error_handler.py index 79f8c25..1601f4d 100644 --- a/services/kakao_error_handler.py +++ b/services/kakao_error_handler.py @@ -1,6 +1,6 @@ -from config.settings import APP_LOGGER import logging -import sys + +from config.settings import APP_LOGGER logger = logging.getLogger(APP_LOGGER) # 로그 설정 diff --git a/services/kakao_http_client.py b/services/kakao_http_client.py index fd24bc1..b4b32d7 100644 --- a/services/kakao_http_client.py +++ b/services/kakao_http_client.py @@ -1,19 +1,19 @@ 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 ) - +from .kakao_error_handler import ( + KakaoHttpClientException, + KakaoRequestError +) class KakaoHttpClient: diff --git a/services/kakao_token_service.py b/services/kakao_token_service.py index 9e46afd..615c0ef 100644 --- a/services/kakao_token_service.py +++ b/services/kakao_token_service.py @@ -1,18 +1,18 @@ +import logging +from dataclasses import dataclass 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 +from .kakao_http_client import ( + KakaoHttpClient +) logger = logging.getLogger(APP_LOGGER) diff --git a/services/public_data_portal_http_client.py b/services/public_data_portal_http_client.py new file mode 100644 index 0000000..e41f08f --- /dev/null +++ b/services/public_data_portal_http_client.py @@ -0,0 +1,38 @@ +import requests + +from services.exception_handler import UnExpectedException + + +class HttpRequestException(Exception): + def __init__(self, message): + self.message = message + def __str__(self): + return self.message + + +class PublicDataPortalHttpClient: + """ + 해당 클래스는 공공 데이터 포탈과 직접 소통하는 http 클라이언트 입니다. + """ + def __init__(self, service_key): + # 공공 데이터 포탈과 소통하기 위한 서비스 키입니다. + self.service_key = service_key + + def get_tour_api_response(self, path: str, **kwargs): + """ + 해당 함수는 한국관광공사_국문 관광정보 서비스_GW api와 직접 소통하는 함수 입니다. + :param path: 요청을 보낼 path를 의미합니다. + :param kwargs: 서비스 키를 제외한 요청을 보낼 body를 의미하며 dictionary 형태를 받습니다. + """ + base_url = 'http://apis.data.go.kr/B551011/KorService2' + kwargs['serviceKey'] = self.service_key + response = requests.get(base_url + path, params=kwargs) + if response.status_code == 200: + try: + return response.json() + except requests.exceptions.JSONDecodeError: + raise UnExpectedException( + error_code='API LIMIT', + error_message='관광데이터 포털 API 한도초과 혹은 일시적 오류입니다.' + ) + raise HttpRequestException(f'Public Data Portal API HTTP request failed with status code {response.status_code}') diff --git a/services/tour_api.py b/services/tour_api.py index b967773..275f234 100644 --- a/services/tour_api.py +++ b/services/tour_api.py @@ -1,6 +1,16 @@ -import requests -from enum import Enum import math +from enum import Enum + +import requests +from config.settings import APP_LOGGER + +import logging + +from services.exception_handler import UnExpectedException + +logger = logging.getLogger(APP_LOGGER) + + # from tour.models import Event class Area: @@ -173,7 +183,7 @@ class Arrange(Enum): # 한국 관광정보 api를 위한 베이스 URL -BASE_URL = 'http://apis.data.go.kr/B551011/KorService1' +BASE_URL = 'http://apis.data.go.kr/B551011/KorService2' class TourApi: """ @@ -223,16 +233,23 @@ def get_sigungu_code_list(self, areaCode=None): :param areaCode: 지역 번호를 의미합니다. AreaCode enum 사용가능 :return: 지역 코드 리스트를 반환합니다. """ - uri = '/areaCode1' + uri = '/areaCode2' parameters = self.__upload_required_params() parameters['numOfRows'] = 100 # 한번에 100개의 정보를 보여줍니다. if areaCode is not None: parameters['areaCode'] = areaCode.value if isinstance(areaCode, Enum) else areaCode response = requests.get(BASE_URL + uri, params=parameters) if response.status_code == 200: - if response.json()['response']['body']['totalCount'] == 0: # 컨텐츠가 없으면 빈 리스트 반환 - return [] - return response.json()['response']['body']['items']['item'] + logger.debug(f'결과: {response.text}') + try: + if response.json()['response']['body']['totalCount'] == 0: # 컨텐츠가 없으면 빈 리스트 반환 + return [] + return response.json()['response']['body']['items']['item'] + except requests.exceptions.JSONDecodeError: + raise UnExpectedException( + error_code='API LIMIT', + error_message='관광데이터 포털 API 한도초과 혹은 일시적 오류입니다.' + ) return None def get_sigungu_code(self, areaCode, targetName): @@ -282,7 +299,7 @@ def get_location_based_list(self, mapX, mapY, radius): :return: API 호출이 성공하면 Area 객체 형식의 결과를 반환하며, 실패 시 None을 반환합니다. """ - uri = '/locationBasedList1' + uri = '/locationBasedList2' parameters = self.__upload_required_params() parameters['mapX'] = mapX parameters['mapY'] = mapY @@ -324,7 +341,7 @@ def get_area_based_list(self, **kwargs): :return: API 호출이 성공하면 Area 객체 형식의 결과를 반환하며, 실패 시 None을 반환합니다. """ - uri = '/areaBasedList1' + uri = '/areaBasedList2' # not required parameters list = ['numOfRows', 'pageNo', @@ -360,7 +377,7 @@ def get_image_urls(self, contentId): :param contentId: 컨텐츠 아이디 (각 api로 얻은 관광지(지역) 고유 컨텐츠 아이디) :return: """ - uri = '/detailImage1' + uri = '/detailImage2' parameters = self.__upload_required_params() parameters['contentId'] = contentId parameters['subImageYN'] = 'Y' # 원본,썸네일이미지조회,공공누리 저작권유형정보조회 @@ -393,7 +410,7 @@ def get_category_code_list(self, **kwargs): :param kwargs: :return: JSON 형식으로, 분류코드에 해당하는 'code'와 그에 대응되는 이름 정보인 'name' 키 값이 포함 """ - uri = '/categoryCode1' + uri = '/categoryCode2' parameters = self.__upload_required_params() for each in kwargs.keys(): parameters[each] = kwargs[each].value if isinstance(kwargs[each], Enum) else kwargs[each] @@ -413,7 +430,7 @@ def get_festival_list(self, event_start_date, event_end_date, **kwargs): :param event_start_date: 이벤트 시작 날짜 (여행 시작 날짜, YYYYMMDD 형식) :param event_end_date: 이벤트 마감 날짜(여행 마감 날짜, YYYYMMDD 형식) """ - uri = '/searchFestival1' + uri = '/searchFestival2' parameters = self.__upload_required_params() parameters['eventStartDate'] = event_start_date parameters['eventEndDate'] = event_end_date diff --git a/services/tour_api_http_client.py b/services/tour_api_http_client.py new file mode 100644 index 0000000..23b6b85 --- /dev/null +++ b/services/tour_api_http_client.py @@ -0,0 +1,406 @@ +import inspect +from enum import Enum +from types import FrameType +from typing import Literal + +from config.settings import PUBLIC_DATA_PORTAL_API_KEY +from .public_data_portal_http_client import PublicDataPortalHttpClient + + +class Area: + """ + 지역에 관한 정보를 담고 있습니다. + Attributes: + areaCode: 지역코드(지역코드조회 참고) + sigunguCode: 시군구코드(지역코드조회 참고, areaCode 필수입력) + """ + def __init__(self, + areaCode: str = None, + sigunguCode: str = None + ): + self.areaCode = areaCode + self.sigunguCode = sigunguCode + self.__validate_parameters() + + def __validate_parameters(self): + if self.areaCode is None and self.sigunguCode is not None: # areaCode가 없는데 sigunguCode가 들어온 경우 + raise ValueError('지역 코드 없이 시군구 코드가 들어올 수 없습니다.') + +class Category: + """ + 카테고리에 관한 정보를 담고 있습니다. + Attributes: + cat1 (str): 대분류(서비스분류코드조회 참고) + cat2 (str): 중분류(서비스분류코드조회 참고, cat1 필수입력) + cat3 (str): 소분류(서비스분류코드조회 참고, cat1/cat2필수입력) + """ + + def __init__(self, + cat1: str = None, + cat2: str = None, + cat3: str = None): + self.cat1 = cat1 + self.cat2 = cat2 + self.cat3 = cat3 + self.__validate_parameters() + + def __validate_parameters(self): + if self.cat3 is not None: + if self.cat2 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + elif self.cat1 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + elif self.cat2 is not None: + if self.cat1 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + + +class lDong: + """ + 법정동 코드에 관한 클래스입니다. + Attributes: + lDongRegnCd (str): 법정동 시도 코드(법정동코드조회 참고) + lDongSigunguCd (str): 법정동 시군구 코드(법정동코드조회 참고, lDongRegnCd 필수입력) + """ + def __init__(self, + lDongRegnCd: str = None, + lDongSigunguCd: str = None): + self.lDongRegnCd = lDongRegnCd + self.lDongSigunguCd = lDongSigunguCd + self.__validate_parameters() + + def __validate_parameters(self): + if self.lDongSigunguCd is not None and self.lDongRegnCd is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + + + + +class lclsSystem: + """ + 분류체계에 관한 클래스 입니다. (분류체계 코드 조회 (get_lcls_system_code)참고) + Attributes: + lclsSystem1 (str): 분류체계 1Deth(분류체계코드조회 참고) + lclsSystem2 (str): 분류체계 2Deth(분류체계코드조회 참고, lclsSystm1 필수입력) + lclsSystem3 (str): 분류체계 3Deth(분류체계코드조회 참고, lclsSystm1/lclsSystm2 필수입력) + """ + def __init__(self, + lclsSystem1: str = None, + lclsSystem2: str = None, + lclsSystem3: str = None): + self.lclsSystem1 = lclsSystem1 + self.lclsSystem2 = lclsSystem2 + self.lclsSystem3 = lclsSystem3 + self.__validate_parameters() + + def __validate_parameters(self): + if self.lclsSystem3 is not None: + if self.lclsSystem2 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + elif self.lclsSystem1 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + elif self.lclsSystem2 is not None: + if self.lclsSystem1 is None: + raise ValueError('파라미터 요건 불충족. 해당 클래스의 사용법을 읽어보신 후 재사용 부탁드립니다.') + +class ContentType(Enum): + """ + ContentType을 의미하는 이넘 클래스이며 해당 클래스가 가지고 있는 속석은 다음과 같습니다. + 관광지: GwanGwangJi + 문화사실: CultureInfra + 축제공연행사: FestivalAndConcert + 여행코스: TourCourse + 레포츠: LeisureSports + 숙박시설: Sukbak + 쇼핑: Shopping + 음식점: Restaurant + """ + GwanGwangJi = '12' + CultureInfra = '14' + FestivalAndConcert = '15' + TourCourse = '25' + LeisureSports = '28' + Sukbak = '32' + Shopping = '38' + Restaurant = '39' + +class Arrange(Enum): + """ + 절렬을 의미하는 이넘이며, 해당 클래스가 가지고 있는 속성은 아래와 같습니다. + Attribute: + Title: 제목순 + Modify: 수정일순 + Create: 생성일순 + ImageTitle: 이미지 있는 제목순 + ImageModify: 이미지 있는 수정일순 + ImageCreate: 이미지 있는 생성일순 + """ + Title = 'A' + Modify = 'C' # 수정일 순 + Create = 'D' # 생성일 순 + ImageTitle = 'O' # 이미지 반드시 있는 제목 순 + ImageModify = 'Q' # 이미지 반드시 있는 수정일 순 + ImageCreate = 'R' # 아마자 반드시 있는 생성일 순 + DISTANCE = 'E' # 거리 순 (위치기반 관광정보 조회에서 사용) + +class TourAPIHTTPClient: + """ + 해당 서비스는 한국관광공사_국문 관광정보 서비스_GW에서 제공하는 관광정보를 얻기 위한 클래스로 한국관광 공사와 직접 소통하는 역할을 하는 클래스입니다. + """ + def __init__(self, service_key: str, + mobile_os: Literal['AND', 'IOS', 'WEB', 'ETC'] ='AND', + mobile_app: str = 'conever_tour_api_service', + response_type: Literal['json', 'xml'] = 'json', + num_of_rows: int = 100,): + self.serviceKey = service_key # 서비스 키를 받습니다. + self.MobileOS = mobile_os # mobile os 값을 받습니다. + self.MobileApp = mobile_app # 앱 이름을 파라미터로 받습니다. + self._type = response_type # 기본 응답 데이터를 json 형태로 고정하여 받습니다. + self.numOfRows = num_of_rows # 한 페이지 결과 수를 의미하며 기본으로 한 번에 100개의 데이터를 받습니다. + self.required_params = self.__upload_required_params() # 필수 파라미터를 받은 직후 코드 배치 + self.http_client = PublicDataPortalHttpClient(service_key) # 하나의 통신 클라이언트 객체를 생성합니다. + + def __upload_required_params(self): + return self.__dict__.copy() + + + def get_area_code(self, area_code: str = None): + """ + 지역코드목록을 지역,시군구 코드목록을 조회하는 기능입니다. + :param area_code: 지역 코드를 의미하며, 해당 시/도 내의 시군구 코드 목록을 조회하기 위해서는 해당 시/도에 해당하는 지역 코드를 입력해주셔야 합니다. + """ + path = '/areaCode2' + params = self.required_params.copy() + if area_code is None: + return self.http_client.get_tour_api_response(path, **params) + params['areaCode'] = area_code + return self.http_client.get_tour_api_response(path, **params) + + + + def get_detail_pet_tour(self, content_id: str = None): + """ + 타입별 반려동물 동반 여행 정보를 조회하는 기능입니다. + :param content_id: 해당 장소(컨텐츠)별 고유 아이디를 말하며, 미 기입시 전체 목록을 조회합니다. + """ + path = '/detailPetTour2' + params = self.required_params.copy() + if content_id is None: + return self.http_client.get_tour_api_response(path, **params) + params['contentId'] = content_id + return self.http_client.get_tour_api_response(path, **params) + + def get_category_code(self, + contentTypeId: ContentType = None, + category: Category = None, + ): + """ + 서비스분류코드목록을 대,중,소분류로 조회하는 기능 + :param contentTypeId: ContentType Enum 클래스를 사용하며, 자세한 필드 속성은 해당 클래스 주석을 참고 바랍니다. + :param category: 카테고리를 의미하며, 자세한 필드 속성은 해당 클래스 주석을 참고 바랍니다. + """ + path = '/categoryCode2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_area_based_list(self, + arrange: Arrange = None, + contentTypeId: ContentType = None, + area_info: Area = None, + category: Category = None, + modifiedtime: str = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None, + ): + """ + 지역기반 관광정보파라미터 타입에 따라서 제목순,수정일순,등록일순 정렬검색목록을 조회하는 기능 + + Args: + arrange (Arrange): 정렬 구분, 자세한 정렬 기준은 Arrange 이넘 클래스 참고 + contentTypeId (ContentType): 관광타입, 자세한 관광타입은 ContentType 이넘 클래스 참고 + area_info (Area): 지역 정보 (Area 클래스 주석 참고) + category (Category): 카테고리 정보 (카테고리 주석 참고) + modifiedtime (str): 수정일(형식 :YYYYMMDD) + ldong (lDong): 법정동 정보(lDong 클래스 주석 참고) + lclsSystem (lclsSystem): 법정 분류체계 (lclsySystem 주석 참고) + + Returns: + The API response containing the filtered list of resources retrieved from the + Tour API. + + Raises: + Any error that might occur during the API request process. + """ + path = '/areaBasedList2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_location_based_list(self, + mapX: str, + mapY: str, + radius: str, + arrange: Arrange = None, + contentTypeId: ContentType = None, + modifiedtime: str = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None, + area_info: Area = None, + category: Category = None, + ): + """ + 위치기반 관광정보파라미터 타입에 따라서 제목순,수정일순,등록일순,거리순 정렬검색목록을 조회하는 기능 + Args: + mapX (str): GPS X좌표(WGS84 경도좌표), required + mapY (str): GPS Y좌표(WGS84 경도좌표), required + radius (str): 거리반경(단위:m) , Max값 20000m=20Km, required + arrange (Arrange): 정렬 구분, 자세한 정렬 기준은 Arrange 이넘 클래스 참고 + contentTypeId (ContentType): 관광타입, 자세한 관광타입은 ContentType 이넘 클래스 참고 + area_info (Area): 지역 정보 (Area 클래스 주석 참고) + category (Category): 카테고리 정보 (카테고리 주석 참고) + modifiedtime (str): 수정일(형식 :YYYYMMDD) + ldong (lDong): 법정동 정보(lDong 클래스 주석 참고) + lclsSystem (lclsSystem): 법정 분류체계 (lclsySystem 주석 참고) + """ + path = '/locationBasedList2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_search_keyword(self, + keyword: str, + area_info: Area = None, + category: Category = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None + ): + """ + 키워드로 검색을하며 전체별 타입정보별 목록을 조회한다 + """ + path = '/searchKeyword2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_search_festival(self, + eventStartDate: str, + eventEndDate: str = None, + arrange: Arrange = None, + area_info: Area = None, + category: Category = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None, + modifiedtime: str = None, + ): + path = '/searchFestival2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_search_sukbak(self, + lDongRegnCd: str, + lDongSigunguCd: str = None, + arrange: Arrange = None, + area_info: Area = None, + category: Category = None, + lclsSystem: lclsSystem = None, + modifiedtime: str = None): + """ + 숙박정보 검색목록을 조회한다. 컨텐츠 타입이 ‘숙박’일 경우에만 유효하다. + """ + path = '/searchStay2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + # def get_detail_common(self): + # """ + # 타입별공통 정보기본정보,약도이미지,대표이미지,분류정보,지역정보,주소정보,좌표정보,개요정보,길안내정보,이미지정보,연계관광정보목록을 조회하는 기능 + # """ + # path = '/detailCommon2' + # params = self.__upload_all_parameters(inspect.currentframe()) + # return self.http_client.get_tour_api_response(path, **params) + + # def get_detail_intro(self): + # """ + # 상세소개 쉬는날, 개장기간 등 내역을 조회하는 기능 + # """ + # path = '/detailIntro2' + # params = self.__upload_all_parameters(inspect.currentframe()) + # return self.http_client.get_tour_api_response(path, **params) + + # def get_detail_info(self): + # """ + # 추가 관광정보 상세내역을 조회한다. 상세반복정보를 안내URL의 국문관광정보 상세 매뉴얼 문서를 참고하시기 바랍니다. + # """ + # path = '/detailInfo2' + # params = self.__upload_all_parameters(inspect.currentframe()) + # return self.http_client.get_tour_api_response(path, **params) + + # def get_detail_image(self): + # """ + # 관광정보에 매핑되는 서브이미지목록 및 이미지 자작권 공공누리유형을 조회하는 기능 + # """ + # path = '/detailImage2' + # params = self.__upload_all_parameters(inspect.currentframe()) + # return self.http_client.get_tour_api_response(path, **params) + + def get_lcls_system_code(self, lclsSystem: lclsSystem = None, lclsSystemListYn: Literal['Y', 'N'] = 'Y'): + """ + 분류체계코드목록을 1Deth, 2Deth, 3Deth 코드별 조회하는 기능 + """ + path = '/lclsSystemCode2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_area_based_sync_list(self, + showflag: str = None, + arrange: Arrange = None, + contentTypeId: ContentType = None, + area_info: Area = None, + category: Category = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None, + modifiedtime: str = None, + oldContentId: str = None,): + """ + 지역기반 관광정보파라미터 타입에 따라서 제목순,수정일순,등록일순 정렬검색목록을 조회하는 기능 + """ + path = '/areaBasedSyncList2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def get_ldong_code(self, + lDongRegnCd: str = None, + lDongListYn: Literal['Y', 'N'] = 'Y', + ): + """ + 법정동코드 목록을 시도,시군구 코드별 조회하는 기능 + Args: + lDongRegnCd (str): 법정동 시도코드 ( lDongRegnCd 해당되는 법정동 시군구코드 조회 , 입력이 없을시 전체 시도목록 호출 ) + lDongListYn (str): 법정동 목록조회 여부(N:코드조회 , Y:전체목록조회) + """ + path = 'ldongCode2' + params = self.__upload_all_parameters(inspect.currentframe()) + return self.http_client.get_tour_api_response(path, **params) + + def __upload_all_parameters(self, frame: FrameType): + """ + 모든 파라미터 업로드 진행 후 요청 보낼 최종 파라미터를 뽑아냅니다. + """ + arg_info = inspect.getargvalues(frame) + params = self.required_params.copy() + for arg in arg_info.args[1:]: # 1번 부터 시작 (self 제거) + if arg_info.locals[arg] is not None: + if type(arg_info.locals[arg]).__module__ != 'builtins' and not isinstance(arg_info.locals[arg], Enum): # 클래스 인스턴스라면 + for each in arg_info.locals[arg].__dict__: + if arg_info.locals[arg].__dict__[each] is not None: + params[each] = arg_info.locals[arg].__dict__[each] + else: + params[arg] = arg_info.locals[arg] if not isinstance(arg_info.locals[arg], Enum) else arg_info.locals[arg].value + return params + + + +if __name__ == '__main__': + tour_api_service = TourAPIHTTPClient(PUBLIC_DATA_PORTAL_API_KEY) + print(tour_api_service.get_area_based_list(contentTypeId=ContentType.Sukbak, arrange=Arrange.ImageTitle)) + # area = Area() + # area.areaCode = 'sdf' + # print(area.__dict__) + # print(area.areaCode) \ No newline at end of file diff --git a/services/tour_api_service.py b/services/tour_api_service.py new file mode 100644 index 0000000..ab8d81c --- /dev/null +++ b/services/tour_api_service.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass + +from config.settings import PUBLIC_DATA_PORTAL_API_KEY +from services.public_data_portal_http_client import HttpRequestException +from .tour_api_http_client import * + +area_codes = { + '1': '서울', + '2': '인천', + '3': '대전', + '4': '대구', + '5': '광주', + '6': '부산', + '7': '울산', + '8': '세종특별자치시', + '31': '경기도', + '32': '강원특별자치도', + '33': '충청북도', + '34': '충청남도', + '35': '경상북도', + '36': '경상남도', + '37': '전북특별자치도', + '38': '전라남도', + '39': '제주특별자치도' +} + +@dataclass +class Place: + """ + 장소 정보를 저장하는 데이터 클래스 입니다. + """ + addr1: str = None # 상세주소 + addr2: str = None # 지역코드 + areacode: str = None # 대분류코드 + cat1: str = None # 중분류코드 + cat2: str = None # 소분류코드 + cat3: str = None # 상세주소 + contentid: str = None # 콘텐츠ID + contenttypeid: str = None # 관광타입(관광지, 숙박등) ID + created_time: str = None # 콘텐츠최초등록일 + firstimage: str = None # 원본대표이미지 + cpyrhtDivCd: str = None # Type1:제1유형(출처표시-권장) Type3:제3유형(제1유형 + 변경금지) + mapx: str = None # GPS X좌표(WGS84 경도좌표) 응답 + mapy: str = None # GPS Y좌표(WGS84 경도좌표) 응답 + mlevel: str = None # Map Level 응답 + modifiedtime: str = None # 콘텐츠수정일 + sigungucode: str = None # 시군구코드 + tel: str = None # 전화번호 + title: str = None # 콘텐츠제목 + zipcode: str = None # 우편번호 + lDongRegnCd: str = None # 법정동 시도 코드 + lDongSignguCd: str = None # 법정동 시군구 코드 + lclsSystm1: str = None # 분류체계 대분류 + lclsSystm2: str = None # 분류체계 중분류 + lclsSystm3: str = None # 분류체계 소분류 + + +class TourAPIService: + """ + 해당 클래스는 tour_api_http_client로 받은 raw 데이터 정보를 가공하여 데이터를 제공하는 역할을 합니다. + """ + def __init__(self, service_key): + self.service_key = service_key + self.tour_api_http_client = TourAPIHTTPClient(service_key) + + def get_area_based_list(self, + arrange: Arrange = None, + contentTypeId: ContentType = None, + area_info: Area = None, + category: Category = None, + modifiedtime: str = None, + ldong: lDong = None, + lclsSystem: lclsSystem = None + ) -> list[Place]: + """ + 해당 함수는 장소 정보를 리스트 형식으로 받아옵니다. + 각 장소 정보가 담긴 Place 객체 리스트로 받아옵니다. + """ + raw_data = self.tour_api_http_client.get_area_based_list( + arrange=arrange, + contentTypeId=contentTypeId, + area_info=area_info, + category=category, + modifiedtime=modifiedtime, + ldong=ldong, + lclsSystem=lclsSystem, + ) + items = [] + try: + items = raw_data['response']['body']['items']['item'] + except KeyError: + raise HttpRequestException('tour api server exception') + # 올바르게 데이터가 넘어왔다고 가정. + + places = [] + for each in items: + # 각 each는 특정 장소 정보가 담긴 dictionary 형식입니다. + place = Place() + for key, value in each.items(): + if hasattr(place, key): + setattr(place, key, value) # 속성 저장 + places.append(place) + return places + + def get_sigungu_code_as_name(self, area_code: str, target_sigungu_name: str): + """ + 해당 함수는 전국 17개 시/도 안에 포함된 특정 지역 시군구 코드를 이름을 통해서 얻고자 할 때 사용합니다. + ex) 강남 -> 1, 아산 -> 12 (예시일 뿐이며 실제 데이터 값과 다를 수 있습니다.) + """ + raw_data = self.tour_api_http_client.get_area_code(area_code=area_code) + items = raw_data['response']['body']['items']['item'] + for item in items: + if target_sigungu_name in item['name']: + return item['code'] + return None + + def get_sigungu_name_by_code(self, area_code:str, target_sigungu_code): + raw_data = self.tour_api_http_client.get_area_code(area_code=area_code) + items = raw_data['response']['body']['items']['item'] + for item in items: + if target_sigungu_code == item['code']: + return item['name'] + return None + + def get_location_based_list(self, mapX:str, mapY:str, radius:str): + """ + 해당 함수는 위도, 경도 좌표가 주어졌을 떄 관광지 정보를 조회할 때 사용합니다. + 데이터가 없으면 빈 리스트를 반환하고, 그렇지 않다면 dictionary가 원소인 리스트가 결과 값이 반환됩니다. + """ + raw_data = self.tour_api_http_client.get_location_based_list( + mapX=mapX, + mapY=mapY, + radius=radius, + arrange=Arrange.DISTANCE # 거리 순 조회 + ) + cnt = raw_data['response']['body']['totalCount'] + if cnt == 0: + return [] + return raw_data['response']['body']['items']['item'] + + + + +if __name__ == '__main__': + tour_api_service = TourAPIService(PUBLIC_DATA_PORTAL_API_KEY) + print(tour_api_service.get_sigungu_code_as_name('1', '강남')) diff --git a/services/utils.py b/services/utils.py new file mode 100644 index 0000000..c5ed9b9 --- /dev/null +++ b/services/utils.py @@ -0,0 +1,25 @@ +import math +def haversine(map_y1, map_x1, map_y2, map_x2): + """ + 두 지점의 위도와 경도 정보가 주어질 때 두 지점 사이의 거리를 구하는 함수입니다. + :param map_x1: 경도 좌표 1 + :param map_y1: 위도 좌표 1 + :param map_x2: 경도 좌표 2 + :param map_y2: 위도 좌표 2 + """ + + R = 6378 # 지구 반지름 (단위: km) + + # 위도 및 경도를 라디안 단위로 변환 + map_x1, map_y1, map_x2, map_y2 = map(math.radians, [map_x1, map_y1, map_x2, map_y2]) + + # 위도, 경도의 차이 계산 + d_y = map_y2 - map_y1 + d_x = map_x2 - map_x1 + + # haversine 공식 적용 + a = math.sin(d_y / 2) ** 2 + math.cos(map_y1) * math.cos(map_y2) * math.sin(d_x / 2) ** 2 + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + distance = R * c # 거리 계산 + return distance \ No newline at end of file diff --git a/tests/base.py b/tests/base.py index 2367530..08502a1 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,10 +1,15 @@ +import logging + from django.test import TestCase -from services.kakao_token_service import KakaoTokenService -from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY, REFRESH_TOKEN from rest_framework_simplejwt.tokens import RefreshToken +from config.settings import APP_LOGGER +from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY, REFRESH_TOKEN +from services.kakao_token_service import KakaoTokenService from usr.models import User +logger = logging.getLogger(APP_LOGGER) + class BaseTestCase(TestCase): is_issued_token = False # 토큰 발급을 하였는가 @@ -16,21 +21,26 @@ def setUpClass(cls): cls.is_issued_token = True super().setUpClass() token_service = KakaoTokenService(KAKAO_REST_API_KEY) + # PyJWT 버전이 2.10.0 이상부터는 sub가 반드시 String 형식이어야함 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 + logger.debug('ACCESS_TOKEN: ' + str(cls.KAKAO_TEST_ACCESS_TOKEN)) + logger.debug('ID_TOKEN: ' + str(cls.KAKAO_TEST_ID_TOKEN)) @classmethod def setUpTestData(cls): if not cls.is_created_user: - user = User.objects.create( + user, created = User.objects.get_or_create( sub=3928446869, - username='TestUser', - gender='male', - age_range='1-9', - profile_image_url='https://example.org' + defaults={ + 'username': 'TestUser', + 'gender': 'male', + 'age_range': '1-9', + 'profile_image_url': 'https://example.org' + } ) user.set_password('test_password112') user.save() diff --git a/tour/admin.py b/tour/admin.py index 9d459ac..a9b2d66 100644 --- a/tour/admin.py +++ b/tour/admin.py @@ -1,9 +1,13 @@ from django.contrib import admin -from .models import Place, Travel, TravelDaysAndPlaces, PlaceImages, Event + +from .models import Place, Travel, TravelDaysAndPlaces, PlaceImages, Event, SnapshotImages, UserTourImage, RelationPlace # Register your models here. 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 +admin.site.register(Event) +admin.site.register(SnapshotImages) +admin.site.register(UserTourImage) +admin.site.register(RelationPlace) \ No newline at end of file diff --git a/tour/consumers.py b/tour/consumers.py index 90dc5ef..2a9ab7f 100644 --- a/tour/consumers.py +++ b/tour/consumers.py @@ -1,55 +1,92 @@ import json +import urllib.parse + from channels.generic.websocket import AsyncWebsocketConsumer +from django.core.cache import cache + from config.celery import app -from config.settings import PUBLIC_DATA_PORTAL_API_KEY, APP_LOGGER +from config.settings import PUBLIC_DATA_PORTAL_API_KEY from services.tour_api import * -import urllib.parse -import logging +from tour.sigungu import SIGUNGU_DATA + logger = logging.getLogger(APP_LOGGER) class TaskConsumer(AsyncWebsocketConsumer): + needCachingCategories = [] + sigunguName = '' + areaCode = 1 + result_dic = dict() + """ + '관광지': '12', + '문화시설': '14', + '축제공연행사': '15', + '레포츠': '28', + '숙박': '32', + '쇼핑': '38', + '음식점': '39' + { + "12": [ + { + "address": "서울특별시 종로구 사직로 161 (세종로)", + "areaCode": "1", + }, + ] + } + """ async def connect(self): """ ##query_string## - user_id: string (required) - areaCode: string (required) - - sigunguName: string (required) - - contentTypeId: string (required) + - sigunguName: string (optional) + - categoryName: string (comma-separated, required) + - unique_code: string (optional) """ - 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 + query_string = self.scope['query_string'].decode() + params = urllib.parse.parse_qs(query_string) + self.user_id = params.pop('user_id', [None])[0] + self.unique_code = params.pop('unique_code', [""])[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() # 웹소켓 연결 + await self.channel_layer.group_add(self.user_id, self.channel_name) + await self.accept() + + # 파라미터 파싱 + areaCode = params.pop('areaCode', [None])[0] + self.areaCode = areaCode + sigunguName = params.pop('sigunguName', [None])[0] + self.sigunguName = sigunguName + categoryName = params.pop('categoryName', [None])[0] - # 요청을 celery task로 보냅니다. - areaCode = params.pop('areaCode', [None])[0] # area_code 가져옴 - sigunguName = params.pop('sigunguName', [None])[0] # 시군구 이름 가져옴 - if areaCode is None or days is None: # areaCode가 존재하지 않는다면 + if areaCode is None or categoryName is None: await self.send(text_data=json.dumps({ 'state': 'ERROR', 'Message': '필수 파라미터 중 일부가 없습니다.' }, ensure_ascii=False)) return + + # 시군구 코드 파싱 tour = TourApi(MobileOS=MobileOS.ANDROID, MobileApp='AlphaProject2025', service_key=PUBLIC_DATA_PORTAL_API_KEY) - sigunguCode = None sigunguCodes = None - if sigunguName is not None: + if sigunguName: sigunguNames = sigunguName.split(',') sigunguCodes = [] for each in sigunguNames: - sigunguCode = tour.get_sigungu_code(areaCode, each) # 시군구 이름에 대응되는 코드를 가져옵니다. - if sigunguCode is None: # 시군구 코드가 없다면 + areas = SIGUNGU_DATA.get(int(areaCode)) + sigunguCode = None + for area in areas: + if area['name'] == each or (each in area['name']): + sigunguCode = int(area['code']) + break + + # sigunguCode = tour.get_sigungu_code(areaCode, each) + if sigunguCode is None: await self.send(text_data=json.dumps({ 'state': 'ERROR', 'Message': '해당 시군구 이름에 대응되는 코드를 가져올 수 없습니다. 시군구 이름을 다시 한번 확인 바랍니다.' @@ -57,50 +94,86 @@ async def connect(self): return sigunguCodes.append(sigunguCode) - task_result = app.send_task('tour.tasks.get_recommended_tour_based_area', args=[self.user_id, # 채널 레이어 그룹 특정을 위해 보냅니다. - areaCode, days, Arrange.TITLE_IMAGE.value, sigunguCodes]) - await self.send(text_data=json.dumps({ - 'state': 'OK', - 'Message': { - 'task_id': task_result.task_id, - } - })) + # categoryName → 리스트로 변환 후 task 호출 + categoryNames = categoryName.split(',') + self.needCachingCategories = [] + self.result_dic = dict() + # 캐싱된 데이터는 미리 빼놓고, 캐싱되지 않은 데이터만 AI 요청을 보냅니다. + # celery 코드를 보아하니 카테고리별로 장소들을 가지고 오는 행태를 보이므로 최소한의 카테고리만 보내는 것이 효율적일 것으로 보임 + for each in categoryNames: + value = cache.get(f"{self.areaCode}&{self.sigunguName}&{each}") + if value is None: + self.needCachingCategories.append(each) + else: + logger.debug('key: ' + f'{self.areaCode}&{self.sigunguName}&{each}\n' + + f'value: {value}\ncache 사용됨') + self.result_dic[each] = value + logger.debug('Need AI list: ' + str(self.needCachingCategories)) + if len(self.needCachingCategories) != 0: + task_result = app.send_task( + 'tour.tasks.get_recommended_place_by_category_task', + args=[self.user_id, areaCode, categoryNames, sigunguCodes, Arrange.TITLE_IMAGE.value, self.user_id] # ← 추가됨 + ) + await self.send(text_data=json.dumps({ + 'state': 'OK', + 'Message': { + 'task_id': task_result.task_id, + } + }, ensure_ascii=False)) + else: + await self.send(text_data=json.dumps({ + 'state': 'CACHE_HIT', + 'result': self.result_dic, + }, ensure_ascii=False)) async def disconnect(self, close_code): await self.channel_layer.group_discard(self.user_id, self.channel_name) async def task_update(self, event): + + data = event["message"] + # cache에 데이터를 저장합니다. + for each in self.needCachingCategories: + cache.set( + f'{self.areaCode}&{self.sigunguName}&{each}', + data['result'][each] + ) + self.result_dic[each] = data['result'][each] + logger.debug('key: ' + f'{self.areaCode}&{self.sigunguName}&{each}\n' + + f'value: {self.result_dic[each]}\n이 cache에 저장됨') + data['result'] = self.result_dic # celery 컨테이너에서 보낸 메시지를 클라이언트로 전송 - await self.send(text_data=json.dumps(event["message"], ensure_ascii=False)) + await self.send(text_data=json.dumps(data, ensure_ascii=False)) async def receive(self, text_data=None, bytes_data=None): """ - 재시도를 위한 메시지 입니다. + 재시도를 위한 메시지입니다. """ data = json.loads(text_data) user_id = data.get("user_id", None) areaCode = data.get("areaCode", None) sigunguName = data.get("sigunguName", 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: - # 데이터가 없다면 예외 처리 + unique_code = data.get("unique_code", "") # 웹소켓 통신을 위한 고유 번호를 가져옵니다. + categoryName = data.get("categoryName", None) + user_id = user_id + '_' + unique_code if unique_code else user_id + + if user_id is None or areaCode is None or categoryName is None: await self.send(text_data=json.dumps({ 'state': 'ERROR', 'Message': '필수 파라미터 중 일부가 없거나 잘못되었습니다.' }, ensure_ascii=False)) return + # 시군구 코드 파싱 tour = TourApi(MobileOS=MobileOS.ANDROID, MobileApp='AlphaProject2025', service_key=PUBLIC_DATA_PORTAL_API_KEY) sigunguCodes = None - if sigunguName is not None: - sigunguCodes = [] + if sigunguName: sigunguNames = sigunguName.split(',') + sigunguCodes = [] for each in sigunguNames: - sigunguCode = tour.get_sigungu_code(areaCode, each) # 시군구 이름에 대응되는 코드를 가져옵니다. - if sigunguCode is None: # 시군구 코드가 없다면 + sigunguCode = tour.get_sigungu_code(areaCode, each) + if sigunguCode is None: await self.send(text_data=json.dumps({ 'state': 'ERROR', 'Message': '해당 시군구 이름에 대응되는 코드를 가져올 수 없습니다. 시군구 이름을 다시 한번 확인 바랍니다.' @@ -108,12 +181,17 @@ async def receive(self, text_data=None, bytes_data=None): return 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]) + # 카테고리 파라미터를 리스트로 변환 + categoryNames = categoryName.split(',') + + task_result = app.send_task( + 'tour.tasks.get_recommended_place_by_category_task', + args=[user_id, areaCode, categoryNames, sigunguCodes, Arrange.TITLE_IMAGE.value, user_id] # ← 추가됨 + ) + await self.send(text_data=json.dumps({ 'state': 'OK', 'Message': { 'task_id': task_result.task_id, } - })) \ No newline at end of file + })) diff --git a/tour/models.py b/tour/models.py index c99e290..6b70d2e 100644 --- a/tour/models.py +++ b/tour/models.py @@ -1,6 +1,7 @@ from django.db import models +from django.db.models import ForeignKey from usr.models import User -from mission.models import Mission + # Create your models here. @@ -8,19 +9,37 @@ class Travel(models.Model): # id: pk user = models.ManyToManyField(User) # 유저 제거시 해당 여행도 제거 tour_name = models.CharField(max_length=255) # 여행 이름 필드 추가 - start_date = models.DateField() # 여행 시작 날짜 - end_date = models.DateField() # 여행 마감 날짜 + tour_date = models.DateField() # 여행 날짜 def __str__(self): return self.tour_name class Place(models.Model): # id: pk - name = models.CharField(max_length=100) # 장소 이름, 글자 수 제한 + name = models.CharField(max_length=100, db_index=True) # 장소 이름, 글자 수 제한 mapX = models.FloatField() # 소수점 표현 mapY = models.FloatField() # 소수점 표현 - road_address = models.TextField(blank=True, null=True) # 도로명 주소 - address = models.TextField(blank=True, null=True) # 지번 주소 + road_address = models.TextField(blank=True, null=True) # 도로명 주소, 프론트로부터 + address = models.TextField(blank=True, null=True) # 지번 주소, 프론트 혹은 백의 비동기 작업으로부터 + cat1 = models.TextField(blank=True, null=True) # 소분류 + cat2 = models.TextField(blank=True, null=True) # 중분류 + cat3 = models.TextField(blank=True, null=True) # 대분류 + place_image = models.URLField(blank=True, null=True) + areacode = models.CharField(max_length=255, blank=True, db_index=True, null=True) + sigungucode = models.CharField(max_length=255, blank=True, db_index=True, null=True) + contentid = models.CharField(max_length=255, blank=True, unique=True, db_index=True, null=True) + contenttypeid = models.CharField(blank=True, db_index=True, max_length=255, null=True) + zipcode = models.CharField(blank=True, max_length=255, null=True) + lDongRegnCd = models.CharField(blank=True, null=True, max_length=255) + lDongSignguCd = models.CharField(blank=True, null=True, max_length=255) + lclsSystm1 = models.CharField(blank=True, null=True, max_length=255) + lclsSystm2 = models.CharField(blank=True, null=True, max_length=255) + lclsSystm3 = models.CharField(blank=True, null=True, max_length=255) + tel = models.TextField(blank=True, null=True) + + showflag = models.CharField(max_length=255, db_default="1") # 기존 데이터는 표출 정보로 표시 + + updated_at = models.DateField(auto_now=True) def __str__(self): return self.name @@ -29,13 +48,9 @@ class TravelDaysAndPlaces(models.Model): # id: pk travel = models.ForeignKey(Travel, on_delete=models.CASCADE) # 여행 제거시 해당 일차도 제거 place = models.ForeignKey(Place, on_delete=models.CASCADE) # 장소 제거시 해당 일차도 제거 - 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) + return self.travel.tour_name + " " + self.place.name + " " + str(self.travel.tour_date) class PlaceImages(models.Model): # id: pk @@ -60,4 +75,52 @@ class Event(models.Model): def __str__(self): return self.title +class SnapshotImages(models.Model): + # id: pk + tour = ForeignKey(Travel, on_delete=models.CASCADE) + user = ForeignKey(User, on_delete=models.CASCADE) + image = models.ImageField(upload_to='', blank=True, null=True) # 이미지 필드를 추가합니다. + + def __str__(self): + return f"{self.tour.tour_name} - {self.tour.tour_date} - snapshots" + + +class UserTourImage(models.Model): + # id: pk + tour = ForeignKey(Travel, on_delete=models.CASCADE) + user = ForeignKey(User, on_delete=models.CASCADE) + image = models.ImageField(upload_to='', blank=True, null=True) # 이미지 필드를 추가합니다. + + def __str__(self): + return f"{self.tour.tour_name} - {self.tour.tour_date} - userImage" + + class Meta: + db_table = 'tour_images' + +class RelationPlace(models.Model): + """ + 연관 관광지 정보를 나타냅니다. + """ + place_name = models.CharField(max_length=1000, default='No place name') # 장소 이름 + place_area_cd = models.CharField(max_length=255) # 지역 코드 (tour api 코드와 다름) + place_area_name = models.CharField(max_length=255) # 지역 이름 + place_sigungu_cd = models.CharField(max_length=255) # 시군구 코드 (tour api 코드와 다름) + place_sigungu_name = models.CharField(max_length=255) # 시군구 이름 + + related_place_name = models.CharField(max_length=1000, default='No related place name') # 연관 장소 이름 + related_place_area_cd = models.CharField(max_length=255) # 지역 코드 (tour api 코드와 다름) + related_place_area_name = models.CharField(max_length=255) # 지역 이름 + related_place_sigungu_cd = models.CharField(max_length=255) # 시군구 코드 (tour api 코드와 다름) + related_place_sigungu_name = models.CharField(max_length=255) # 시군구 이름 + related_place_cat1_name = models.CharField(max_length=255) # 연관 장소 카테고리 대분류 이름 + related_place_cat2_name = models.CharField(max_length=255) # 연관 장소 카테고리 중분류 이름 + related_place_cat3_name = models.CharField(max_length=255) # 연관 장소 카테고리 소분류 이름 + + + place = models.ForeignKey(Place, on_delete=models.CASCADE, null=True, blank=True) + related_place = models.ForeignKey(Place, on_delete=models.CASCADE, related_name='related_place', null=True, blank=True) + rank = models.IntegerField() # 순위 (얼마나 두 장소가 연관이 있나 지표) + + def __str__(self): + return f'{self.place_name} - {self.related_place_name}' \ No newline at end of file diff --git a/tour/poses.py b/tour/poses.py new file mode 100644 index 0000000..324e66a --- /dev/null +++ b/tour/poses.py @@ -0,0 +1,79 @@ +POSE_MAP = { + "A0101": ["나무나 꽃을 배경으로 양팔을 활짝 벌리고 환하게 웃기", + "산이나 바다를 바라보며 뒤돌아보는 옆모습 포즈", + "바닥에 앉아 두 손으로 얼굴을 받치고 하늘 바라보기"], + "A0102": ["랜드마크나 조형물 앞에서 엄지척 포즈", + "건물이나 구조물을 손바닥 위에 올린 듯한 착시 포즈", + "안내판이나 간판을 가리키며 미소 짓기"], + "A0201": ["전통 건물 앞에서 두 손을 모아 공손히 서기", + "옛 성문이나 성벽 위에 기대어 바라보기", + "한 손으로 모자를 눌러쓰며 고개를 살짝 숙이기"], + "A0202": ["선베드나 의자에 누워 선글라스 착용 후 여유롭게 손 흔들기", + "바닷가나 호숫가에서 두 팔을 머리 위로 올려 스트레칭", + "시원한 음료를 들고 한쪽 다리를 들어 기쁘게 포즈"], + "A0203": ["체험 도구를 들고 활짝 웃기", + "직접 만든 작품을 들고 카메라를 바라보기", + "체험 중인 모습을 옆에서 찍히는 듯 자연스럽게 연출"], + "A0204": ["공장이나 발전소 배경으로 양손 허리에 올리고 당당한 표정", + "헬멧 착용 후 한 손을 들어 올려 파워풀 포즈", + "설비나 기계 앞에서 설명하는 듯 손짓하기"], + "A0205": ["조형물을 손바닥 위에 올린 착시 포즈", + "건물 기둥이나 벽에 등을 기대고 팔짱 끼기", + "조형물 곡선을 따라 팔이나 다리를 똑같이 모양 맞추기"], + "A0206": ["전시물 앞에서 손으로 턱 괴고 생각하는 표정", + "전시장 입구에서 티켓을 들고 미소", + "액자나 조형물 틈 사이로 얼굴 내밀기"], + "A0207": ["현수막이나 무대 앞에서 손하트", + "친구와 어깨동무하며 활짝 웃기", + "축제 소품(풍선·꽃 등)을 들고 점프샷"], + "A0208": ["무대 배경으로 브이(V) 포즈", + "박수 치며 환하게 웃는 순간", + "조명 아래에서 양팔 벌려 포즈"], + "A0301": ["운동 장비(배트·라켓·헬멧 등)를 들고 파워풀하게 팔 들어 올리기", + "준비운동 스트레칭 포즈", + "경기장이나 트랙 배경으로 엄지척"], + "A0302": ["달리기 시작 전 출발 자세", + "양손에 운동 도구(공, 라켓 등)를 들고 환하게 웃기", + "점프하며 손을 하늘로 뻗는 동작"], + "A0303": ["구명조끼를 입고 한 손으로 물을 가리키며 미소", + "물 위에서 두 손을 들어 파도타기 포즈", + "오리발이나 패들 들고 활짝 웃기"], + "A0304": ["팔을 양쪽으로 벌려 비행기 날개처럼 흉내 내기", + "헬멧이나 고글을 착용하고 하늘 가리키기", + "이륙 준비 자세로 무릎 굽혀 앉기"], + "A0305": ["다양한 장비를 양손에 들고 브이(V) 포즈", + "여러 경기장 배경에서 점프샷", + "팀원과 함께 포옹하거나 하이파이브"], + "A0401": ["쇼핑백을 한 손에 들고 브이(V) 포즈", + "쇼윈도 앞에서 한 손을 턱에 대고 고민하는 표정", + "양손 가득 쇼핑백을 들고 활짝 웃기"], + "A0502": ["맛있는 음식을 한 손에 들고 엄지척", + "포크나 젓가락을 들고 카메라를 향해 환하게 웃기", + "음식 앞에서 두 손을 모아 기도하듯 “잘 먹겠습니다” 포즈"], + "B0201": ["호텔·펜션 입구 앞에서 캐리어를 끌며 손 흔들기", + "침대에 앉아 두 손으로 얼굴 받치고 웃기", + "창가나 발코니에서 커피잔 들고 여유로운 포즈"], + "C0112": ["가족과 손을 맞잡고 카메라를 향해 함께 웃기", + "아이를 번쩍 들어 올리며 즐거운 표정", + "모두 함께 브이(V) 포즈로 단체샷"], + "C0113": ["한 손으로 셀카 찍으며 미소", + "가방을 메고 길 위를 걸으며 뒤돌아보기", + "카페 창가에 앉아 커피잔 들고 여유롭게 찍기"], + "C0114": ["잔디나 나무 그늘 아래서 눈 감고 깊게 숨쉬기", + "하늘을 향해 두 팔 벌려 스트레칭", + "의자에 기대어 책을 읽는 여유로운 포즈"], + "C0115": ["트레킹 스틱을 들고 한 발 내딛는 동작", + "길가 안내 표지판을 가리키며 웃기", + "배낭을 메고 먼 산을 바라보는 뒷모습"], + "C0116": ["텐트 앞에서 양손을 머리 위로 올려 브이(V)", + "캠프파이어 앞에서 손을 모아 불 쬐기", + "의자에 앉아 머그컵을 들고 웃기"], + "C0117": ["음식 한 입 크게 베어물며 즐거운 표정", + "여러 가지 음식을 양손에 들고 브이(V)", + "식탁에 차려진 음식 앞에서 두 팔 벌리기"], + "None": [ + "브이 한 채로 사진 찍기", + "유행하는 밈을 활용한 포즈", + "손 하트 한 채로 사진 찍기" + ] +} \ No newline at end of file diff --git a/tour/poses_url.py b/tour/poses_url.py new file mode 100644 index 0000000..78dc105 --- /dev/null +++ b/tour/poses_url.py @@ -0,0 +1,131 @@ +from django.conf import settings + +s3_base_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}" + +POSE_URL_MAP = { + "A0101": [ + f"{s3_base_url}/image_8_1.png", + f"{s3_base_url}/image_9_1.png", + f"{s3_base_url}/image_10_1.png", + ], + "A0102": [ + f"{s3_base_url}/image_11.png", + f"{s3_base_url}/image_12.png", + f"{s3_base_url}/image_13.png", + ], + "A0201": [ + f"{s3_base_url}/image_14.png", + f"{s3_base_url}/image_15.png", + f"{s3_base_url}/image_16.png", + ], + "A0202": [ + f"{s3_base_url}/image_17.png", + f"{s3_base_url}/image_18.png", + f"{s3_base_url}/image_19.png", + ], + "A0203": [ + f"{s3_base_url}/image_20.png", + f"{s3_base_url}/image_20.png", + f"{s3_base_url}/image_21.png", + ], + "A0204": [ + f"{s3_base_url}/image_22.png", + f"{s3_base_url}/image_23.png", + f"{s3_base_url}/image_24.png", + ], + "A0205": [ + f"{s3_base_url}/image_25.png", + f"{s3_base_url}/image_26.png", + f"{s3_base_url}/image_27.png", + ], + "A0206": [ + f"{s3_base_url}/image_28.png", + f"{s3_base_url}/image_29.png", + f"{s3_base_url}/image_30.png", + ], + "A0207": [ + f"{s3_base_url}/image_31.png", + f"{s3_base_url}/image_32.png", + f"{s3_base_url}/image_33.png", + ], + "A0208": [ + f"{s3_base_url}/image_34.png", + f"{s3_base_url}/image_35.png", + f"{s3_base_url}/image_36.png", + ], + "A0301": [ + f"{s3_base_url}/image_37.png", + f"{s3_base_url}/image_38.png", + f"{s3_base_url}/image_39.png", + ], + "A0302": [ + f"{s3_base_url}/image_40.png", + f"{s3_base_url}/image_41.png", + f"{s3_base_url}/image_42.png", + ], + "A0303": [ + f"{s3_base_url}/image_43.png", + f"{s3_base_url}/image_44.png", + f"{s3_base_url}/image_45.png", + ], + "A0304": [ + f"{s3_base_url}/image_46.png", + f"{s3_base_url}/image_47.png", + f"{s3_base_url}/image_48.png", + ], + "A0305": [ + f"{s3_base_url}/image_81_1.png", + f"{s3_base_url}/image_49.png", + f"{s3_base_url}/image_50.png", + ], + "A0401": [ + f"{s3_base_url}/image_51.png", + f"{s3_base_url}/image_52.png", + f"{s3_base_url}/image_53.png", + ], + "A0502": [ + f"{s3_base_url}/image_54.png", + f"{s3_base_url}/image_55.png", + f"{s3_base_url}/image_56.png", + ], + "B0201": [ + f"{s3_base_url}/image_57.png", + f"{s3_base_url}/image_58.png", + f"{s3_base_url}/image_59.png", + ], + "C0112": [ + f"{s3_base_url}/image_60.png", + f"{s3_base_url}/image_61_1.png", + f"{s3_base_url}/image_62.png", + ], + "C0113": [ + f"{s3_base_url}/image_63.png", + f"{s3_base_url}/image_64.png", + f"{s3_base_url}/image_65.png", + ], + "C0114": [ + f"{s3_base_url}/image_66.png", + f"{s3_base_url}/image_67.png", + f"{s3_base_url}/image_68.png", + ], + "C0115": [ + f"{s3_base_url}/image_69.png", + f"{s3_base_url}/image_70.png", + f"{s3_base_url}/image_71.png", + ], + "C0116": [ + f"{s3_base_url}/image_72.png", + f"{s3_base_url}/image_73.png", + f"{s3_base_url}/image_74.png", + ], + "C0117": [ + f"{s3_base_url}/image_75.png", + f"{s3_base_url}/image_76.png", + f"{s3_base_url}/image_77.png", + ], + "None": [ + f"{s3_base_url}/image_78.png", + f"{s3_base_url}/image_79.png", + f"{s3_base_url}/image_80.png", + ], +} diff --git a/tour/routing.py b/tour/routing.py index 813be5f..f093098 100644 --- a/tour/routing.py +++ b/tour/routing.py @@ -1,4 +1,5 @@ from django.urls import path + from tour.consumers import TaskConsumer websocket_urlpatterns = [ diff --git a/tour/serializers.py b/tour/serializers.py index ff944bf..bff73b9 100644 --- a/tour/serializers.py +++ b/tour/serializers.py @@ -1,6 +1,14 @@ +import logging + from rest_framework import serializers -from .models import Travel, Place, Event, TravelDaysAndPlaces + +from config.settings import APP_LOGGER, PUBLIC_DATA_PORTAL_API_KEY from usr.serializers import UserSerializer +from .models import Travel, Place, Event, TravelDaysAndPlaces, PlaceImages, SnapshotImages, UserTourImage, RelationPlace +from services.tour_api_service import area_codes, TourAPIService +from .sigungu import SIGUNGU_DATA + +logger = logging.getLogger(APP_LOGGER) class TravelSerializer(serializers.ModelSerializer): @@ -10,16 +18,62 @@ class Meta: read_only_fields = ('user',) def to_representation(self, instance): + logger.debug('Tour 시리얼라이저 to_representation 실행') data = super().to_representation(instance) data['user'] = UserSerializer(instance.user.all(), many=True).data - # data['user'] = instance.user.all().values_list('username', flat=True) # 사용자 username만 가져옵니다. + data['places'] = TravelDaysAndPlacesSerializer(TravelDaysAndPlaces.objects.filter(travel_id=instance.id), many=True).data + return data + +class TravelListSerializer(serializers.ModelSerializer): + area_info = serializers.SerializerMethodField(allow_null=True) + thumbnail = serializers.SerializerMethodField(allow_null=True) + + class Meta: + model = Travel + fields = '__all__' + read_only_fields = ('user',) + + def to_representation(self, instance): + data = super().to_representation(instance) + data.pop('user') return data + def get_area_info(self, obj): + # 가장 첫번째 장소 정보만 가져옵니다. 여러 여행지를 여행하지 않는다 가정 + place = Place.objects.filter(traveldaysandplaces__travel_id=obj.id).first() + area_name = None + sigungu_name = None + if place: + area_name = area_codes.get(place.areacode) + if area_name: + areas = SIGUNGU_DATA.get(int(place.areacode)) + for sigungu in areas: + if sigungu['code'] == place.sigungucode: + sigungu_name = sigungu['name'] + break + if area_name is None and sigungu_name is None: return None + elif sigungu_name is None: return f'{area_name}' + + return f'{area_name} {sigungu_name}' + + def get_thumbnail(self, obj): + # 가장 첫번째 사진만 가져옵니다. + image = UserTourImage.objects.filter(tour_id=obj.id).first() + if image is None: return None + return image.image.url + + + class EventSerializer(serializers.ModelSerializer): class Meta: model = Event fields = '__all__' +class PlaceMiniSerializer(serializers.ModelSerializer): + class Meta: + model = Place + fields = ('id', 'name', 'mapX', 'mapY', 'road_address', 'address', 'contenttypeid', 'place_image') + class PlaceSerializer(serializers.ModelSerializer): """ 해당 시리얼라이저는 장소 정보를 불러오거나 추가, 삭제를 진행할 때 사용합니다. @@ -29,8 +83,83 @@ class Meta: fields = '__all__' class TravelDaysAndPlacesSerializer(serializers.ModelSerializer): - place = PlaceSerializer() # 장소 정보는 시리얼라이저를 통해 반환합니다. class Meta: model = TravelDaysAndPlaces fields = '__all__' + + def to_representation(self, instance): + data = super().to_representation(instance) + data['tdp_id'] = data.pop('id') + place_id = data.pop('place') + data.pop('travel') + data['place'] = PlaceMiniSerializer(Place.objects.get(id=place_id)).data + return data + +class PlaceImageSerializer(serializers.ModelSerializer): + class Meta: + model = PlaceImages + fields = '__all__' + +class TourSnapshotsSerializer(serializers.ModelSerializer): + class Meta: + model = SnapshotImages + fields = '__all__' + + def to_representation(self, instance): + # 사진 날짜 보여주기 + data = super().to_representation(instance) + data['tour_date'] = instance.tour.tour_date + return data + +class UserTourImageSerializer(serializers.ModelSerializer): + tour_date = serializers.CharField(source='tour.tour_date', read_only=True) + class Meta: + model = UserTourImage + fields = '__all__' + + class TravelMiniSerializer(serializers.ModelSerializer): + class Meta: + model = Travel + fields = ('id', 'tour_name') + + def to_representation(self, instance): + # 사진 날짜 보여주기 + data = super().to_representation(instance) + data['tour'] = UserTourImageSerializer.TravelMiniSerializer(instance=instance.tour).data + data['user'] = UserSerializer(instance.user).data + return data + +class PoseRecommendSerializer(serializers.Serializer): + place_id = serializers.CharField(max_length=255) + poses = serializers.ListField(child=serializers.CharField(max_length=1000)) + images = serializers.ListField(child=serializers.URLField(max_length=1000)) + +class TodayTravelSerializer(serializers.Serializer): + """ + 당일 여행에 대한 정보를 위한 시리얼라이저 입니다. + 지역, 여행 인원수, 여행날짜, 사진 업로드 정보, 여행 장소 갯수, 관광타입정보 + """ + # 여행 이름 + tour_name = serializers.CharField(max_length=1000) + # 여행날짜 + tour_date = serializers.DateField() + # 여행 인원수 + people_cnt = serializers.IntegerField() + # 사진 업로드 갯수 + image_cnt = serializers.IntegerField() + # 여행 장소 갯수 + place_cnt = serializers.IntegerField() + # 관광타입정보 + category_list = serializers.ListField(child=serializers.IntegerField()) + # 여행 지역 정보 (다수일 수 있으므로 리스트 형태로 제공) + tour_area_info = serializers.ListField(child=serializers.CharField(max_length=1000)) + # 여행 아이디 + tour_id = serializers.IntegerField() + + +class MiniRelationPlaceSerializer(serializers.ModelSerializer): + related_place_detail_info = PlaceMiniSerializer(source='related_place', read_only=True) + class Meta: + model = RelationPlace + exclude = ('place_name', 'place_area_cd', 'place_area_name', 'place_sigungu_cd', 'place_sigungu_name') diff --git a/tour/services.py b/tour/services.py index b615d53..c007843 100644 --- a/tour/services.py +++ b/tour/services.py @@ -1,11 +1,20 @@ -import requests import json +import logging -from requests import RequestException +import requests -from config.settings import KAKAO_REST_API_KEY, APP_LOGGER, GEOCODER_API_KEY -import logging -from services import tour_api +from config.settings import APP_LOGGER, GEOCODER_API_KEY, PUBLIC_DATA_PORTAL_API_KEY +from typing import Dict, List, Optional +from django.db import transaction +from .models import Travel, Place, TravelDaysAndPlaces, SnapshotImages, UserTourImage +from .serializers import TravelSerializer, TravelDaysAndPlacesSerializer, PlaceSerializer, TodayTravelSerializer +from usr.models import User +from services.exception_handler import * +from django.utils import timezone +from services.tour_api_service import TourAPIService, area_codes +from tour.sigungu import SIGUNGU_DATA +import difflib +from services.utils import haversine logger = logging.getLogger(APP_LOGGER) @@ -14,6 +23,7 @@ class PlaceService: """ 장소와 관련된 여러 서비스를 구현 합니다. """ + metadata = None def __init__(self, service_key=None): self.service_key = service_key @@ -103,3 +113,373 @@ def __get_geocoder_response(self, **kwargs): raise Exception('Geocoder API Error') return response.json()['response'] + +class TravelCreationService: + """ + 여행 생성과 관련된 모든 로직을 담당하는 서비스 입니다. + """ + + def __init__(self, place_service: PlaceService): + self.place_service = place_service # 클래스 내에 하나의 객체만 사용토록 하여 여러 메서드에 공유하여 사용합니다. + self.LON_DIF_PER_10M = 0.00547202 # 경도 차이 (10m) + self.LAT_DIF_PER_10M = 0.00009 # 위도 차이 (10m) + + @transaction.atomic + def create_travel_with_places(self, travel_data: Dict, places_data: Dict, user_sub: int) -> Travel: + """ + 여행과 장소를 잇는 tdp를 생성하는 로직입니다. + """ + # 1. 여행 생성 + travel = self._create_travel(travel_data, user_sub) + + # 2. tdp 생성 + self._process_places(travel.id, places_data) + + return travel + + @staticmethod + def _create_travel(travel_data: Dict, user_sub: int) -> Travel: + """여행 객체 생성""" + serializer = TravelSerializer(data=travel_data) + serializer.is_valid(raise_exception=True) + travel = serializer.save() + travel.user.add(User.objects.get(sub=user_sub)) + return travel + + def _process_places(self, travel_id: int, places_data: Dict) -> None: + """장소 데이터 처리 (기존 장소, 추가 정보, 커스텀 장소)""" + # 각 데이터를 분리하여 각 데이터에 맞는 핸들러에 전달하여 처리합니다. + place_handlers = [ + (places_data.get('place_ids'), self._handle_existing_places), + (places_data.get('additional_info'), self._handle_additional_info), + (places_data.get('custom_places'), self._handle_custom_places) + ] + + for data, handler in place_handlers: + if data: + handler(travel_id, data) + + def _handle_existing_places(self, travel_id: int, place_ids: List[int]) -> None: + """기존 DB에 있던 장소들 처리""" + for place_id in place_ids: + # 여행-장소 관계 생성 + self._create_travel_place_relation(travel_id, place_id) + + # 주소 정보 업데이트 + place = Place.objects.get(pk=place_id) + self._update_place_address(place) + + @staticmethod + def _handle_additional_info(travel_id: int, additional_info: List[Dict]) -> None: + """기존 DB 장소들의 추가 정보 업데이트""" + for info in additional_info: + place_id: Optional[int] = info.pop('place_id', None) # place_id를 정적 분석에 실패하여 if문 아래로 도달하지 못한다 판단하여 타입 힌트로 해결 + if not place_id: + raise NoRequiredParameterException(error_message='place_id 누락') + + try: + place = Place.objects.get(id=int(place_id)) + except Place.DoesNotExist: + raise NoObjectException(error_code='NotFoundInAddIn') + + # 장소 정보 업데이트 + serializer = PlaceSerializer(instance=place, data=info, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + def _handle_custom_places(self, travel_id: int, custom_places: List[Dict]) -> None: + """사용자 추가 장소들 처리""" + for place_data in custom_places: + mapX = place_data.get('mapX') + mapY = place_data.get('mapY') + + if not mapX or not mapY: + raise NoRequiredParameterException() + + # 근처 장소 검색 또는 새로 생성 + place = self._get_or_create_place(place_data) + + # 중복 체크 후 관계 생성 + self._create_travel_place_relation_if_not_exists(travel_id, place.id) + + def _get_or_create_place(self, place_data: Dict) -> Place: + """근처 장소 검색 또는 새로운 장소 생성""" + mapX, mapY = float(place_data['mapX']), float(place_data['mapY']) + name = place_data.get('name') + if name is None: NoRequiredParameterException() + + # 이름이 일치하고, 그 일치하는 장소들 중에서 1km 내에 있는 장소 정보를 가져옵니다. + place = self.__get_place_by_name_equal(name, mapX, mapY) + if place is not None: return place + + # 이름 불일치 시 + # 10m 내 기존 장소 검색 + existing_place = self._find_nearest_place(name, mapX, mapY) + if existing_place: + return existing_place + + # 새 장소 생성 + return self._create_new_place(place_data, mapX, mapY) + + def _find_nearest_place(self, original_place_name:str, x: float, y: float) -> Optional[Place]: + """50m 내에 있는 가장 가까운 장소 찾기""" + from django.db.models import FloatField + from django.db.models.functions import Cast + + near_places = Place.objects.annotate( + mapX_float=Cast('mapX', FloatField()), + mapY_float=Cast('mapY', FloatField()) + ).filter( + mapX_float__gte=(x - (self.LON_DIF_PER_10M * 10)), + mapX_float__lte=(x + (self.LON_DIF_PER_10M * 10)), + mapY_float__gte=(y - (self.LAT_DIF_PER_10M * 10)), + mapY_float__lte=(y + (self.LAT_DIF_PER_10M * 10)) + ) + + near_places_after_similarity = [] + for place in near_places: + # 이름 유사도 검사 + if self._check_name_similarity(original_place_name, place.name): + near_places_after_similarity.append(place) + + if len(near_places_after_similarity) == 1: return near_places_after_similarity.pop() # 하나면 그냥 반환 + + closest_place = None + min_distance = None + + for place in near_places_after_similarity: + distance = haversine(x, y, float(place.mapX), float(place.mapY)) + if min_distance is None or distance < min_distance: + closest_place = place + min_distance = distance + + + return closest_place + + def _check_name_similarity(self, name1, name2): + # 장소 이름 유사도 검사를 실시합니다. + CUTLINE = 0.8 # 유사도 80% 이상 시 통과 + answer_bytes = bytes(name1, 'utf-8') + input_bytes = bytes(name2, 'utf-8') + answer_bytes_list = list(answer_bytes) + input_bytes_list = list(input_bytes) + + sm = difflib.SequenceMatcher(None, answer_bytes_list, input_bytes_list) + similar = sm.ratio() + if similar >= CUTLINE: + return True + else: + return False + + + def _create_new_place(self, place_data: Dict, mapX: float, mapY: float) -> Place: + """새로운 장소 생성""" + name = place_data.get('name') + if not name: + raise NoRequiredParameterException() + + # 주소 정보 가져오기 + address_kakao, road_address_kakao = self.place_service.get_parcel_and_road_address(mapX, mapY) + + return Place.objects.create( + name=name, + mapX=str(mapX), + mapY=str(mapY), + road_address=place_data.get('road_address', road_address_kakao), + address=address_kakao, + ) + + @staticmethod + def _create_travel_place_relation(travel_id: int, place_id: int) -> None: + """tdp 생성""" + data = {"place": place_id, "travel": travel_id} + serializer = TravelDaysAndPlacesSerializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + + def _create_travel_place_relation_if_not_exists(self, travel_id: int, place_id: int) -> None: + """중복이 아닌 경우에만 여행-장소 관계 생성""" + try: + TravelDaysAndPlaces.objects.get(travel_id=travel_id, place_id=place_id) + except TravelDaysAndPlaces.DoesNotExist: + self._create_travel_place_relation(travel_id, place_id) + + def _update_place_address(self, place: Place) -> None: + """장소의 주소 정보 업데이트""" + address, road_address = self.place_service.get_parcel_and_road_address( + float(place.mapX), float(place.mapY) + ) + serializer = PlaceSerializer(instance=place, data={"address": address}, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + @staticmethod + def __get_place_by_name_equal(name, mapX, mapY): + db_places = Place.objects.filter(name=name) + for db_place in db_places: + if haversine(db_place.mapX, db_place.mapY, mapX, mapY) > 1: # 1km보다 크다면 + continue + return db_place + return None + + +class TravelUpdateService: + """여행 업데이트와 관련된 로직을 담당하는 서비스""" + + def __init__(self, creation_service: TravelCreationService): + self.creation_service = creation_service + + @transaction.atomic + def update_travel_with_places(self, travel_id: int, travel_data: Dict, places_data: Dict = None) -> Travel: + """여행 정보와 장소를 함께 업데이트""" + # 1. 여행 기본 정보 업데이트 + if travel_data: + self._update_travel_info(travel_id, travel_data) + + # 2. 장소 관련 업데이트 + if places_data: + self._process_place_updates(travel_id, places_data) + + return Travel.objects.get(id=travel_id) + + def _update_travel_info(self, travel_id: int, travel_data: Dict) -> None: + """여행 기본 정보 업데이트""" + travel = Travel.objects.get(id=travel_id) + serializer = TravelSerializer(travel, data=travel_data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + def _process_place_updates(self, travel_id: int, places_data: Dict) -> None: + """장소 관련 업데이트 처리""" + # 장소 삭제 + delete_places = places_data.get('delete_places') + if delete_places: + self._delete_travel_places(travel_id, delete_places) + + # 새로운 장소 추가/업데이트 + self.creation_service._process_places(travel_id, places_data) + + def _delete_travel_places(self, travel_id: int, place_ids: List[int]) -> None: + """여행에서 장소들 제거""" + for place_id in place_ids: + try: + tdp = TravelDaysAndPlaces.objects.get(travel_id=travel_id, place_id=place_id) + tdp.delete() + except TravelDaysAndPlaces.DoesNotExist: + raise NoObjectException(error_message='해당 여행 장소에 맞는 여행 정보를 찾을 수 없습니다.') + +class TodayTravelService: + def __init__(self): + self.tour_api_service = TourAPIService(service_key=PUBLIC_DATA_PORTAL_API_KEY) + + def get_today_tour_by_user(self, user): + """ + 유저 정보를 통해 오늘의 여행 정보를 얻습니다. + """ + response_list = [] + # 모든 당일 여행 정보를 가져옵니다. + self.__get_today_tour_list_by_user_data(user=user) + for tour in self.tour_list: + # 시리얼라이저에 들어갈 데이터를 획득합니다. + data = self.__get_today_tour_by_user_data(tour=tour) + response_list.append(data) + # 시리얼라이저를 통해 필드를 '검증합니다.' + serializer = self.__validate_field(data=response_list) + # 검증된 시리얼라이저를 반환합니다. + return serializer + + def __get_today_tour_list_by_user_data(self, user): + self.tour_list = Travel.objects.filter(user=user, tour_date=timezone.now()) + if len(self.tour_list) == 0: raise NoObjectException(error_message='오늘의 여행 정보를 찾을 수 없습니다.') + + def __get_today_tour_by_user_data(self, tour): + # 여행을 인스턴스 객체로 등록합니다. + self.tour = tour + + # 시리얼라이저에 대응하는 데이터를 핸들러를 통해 가져옵니다. + serializer_data_handler_list = [ + ('tour_name', self.__get_tour_name), + ('tour_date', self.__get_tour_date), + ('people_cnt', self.__get_people_cnt), + ('image_cnt', self.__get_image_cnt), + ('place_cnt', self.__get_place_cnt), + ('category_list', self.__get_category_list), + ('tour_area_info', self.__get_tour_area_info), + ('tour_id', self.__get_tour_id) + ] + # 핸들러를 실행하여 반환 객체에 담습니다. + data = dict() + + for target, handler in serializer_data_handler_list: + data[target] = handler() + + return data + + def __get_tour_id(self): + return self.tour.id + + def __get_tour_name(self): + return self.tour.tour_name + + def __get_tour_date(self): + return self.tour.tour_date + + def __get_people_cnt(self): + return self.tour.user.count() + + def __get_image_cnt(self): + return UserTourImage.objects.filter(tour=self.tour).count() + + def __get_place_cnt(self): + return TravelDaysAndPlaces.objects.filter(travel=self.tour).count() + + def __get_category_list(self): + # 모든 여행 장소들에 대한 카테고리 정보를 수집합니다. + category_set = set() # 중복되면 안되므로 set 형식입니다. + for tdp in TravelDaysAndPlaces.objects.filter(travel=self.tour): + info = tdp.place.contenttypeid + if info is not None and info != "": + category_set.add(int(tdp.place.contenttypeid)) + return sorted(list(category_set)) # 정렬된 리스트를 보냅니다. + + def __get_tour_area_info(self) -> list[str]: + # 모든 여행 장소들에 대한 지역 정보를 수집합니다. + # 모든 장소들에 대해 중복없이 지역코드를 추출합니다. + area_code_list = self.__get_area_code_list_from_places() # ('시/도', '시군구') + # 해당 지역코드를 tour_api 서비스를 이용해 지역 코드를 가져옵니다. + area_list_str = self.__convert_area_code_list_to_area_str(area_code_list) + return area_list_str + + def __get_area_code_list_from_places(self): + # (17개 시/도, 세부 시군구) 형식으로 데이터를 제공합니다. + area_code_set = set() + places = Place.objects.filter(traveldaysandplaces__travel=self.tour) + for place in places: + if place.areacode is not None: + area_code_set.add((place.areacode, place.sigungucode)) + return sorted(list(area_code_set)) + + + + + def __convert_area_code_list_to_area_str(self, area_code_list): + ans_list = [] + for area_code, sigungu_code in area_code_list: + ans = f'{area_codes.get(str(area_code))} {self.__convert_sigungu_code_to_str(area_code, sigungu_code)}' + ans_list.append(ans) + return ans_list + + def __convert_sigungu_code_to_str(self, area_code, sigungu_code): + sigungus = SIGUNGU_DATA.get(int(area_code)) + for each in sigungus: + if each.get('code') == str(sigungu_code): + return each.get('name') + raise UnExpectedException(error_message='시군구 코드 없음') + + + + @staticmethod + def __validate_field(data): + serializer = TodayTravelSerializer(data=data, many=True) + serializer.is_valid(raise_exception=True) + return serializer \ No newline at end of file diff --git a/tour/sido.py b/tour/sido.py new file mode 100644 index 0000000..5140c95 --- /dev/null +++ b/tour/sido.py @@ -0,0 +1,108 @@ +from django.conf import settings + +s3_base_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}" + +SIDO_LIST = [ + { + "rnum": 1, + "code": "1", + "name": "서울", + "image": f"{s3_base_url}/image_82.png" + }, + { + "rnum": 2, + "code": "2", + "name": "인천", + "image": f"{s3_base_url}/image_83.png" + }, + { + "rnum": 3, + "code": "3", + "name": "대전", + "image": f"{s3_base_url}/image_84.png" + }, + { + "rnum": 4, + "code": "4", + "name": "대구", + "image": f"{s3_base_url}/image_85.png" + }, + { + "rnum": 5, + "code": "5", + "name": "광주", + "image": f"{s3_base_url}/image_86.png" + }, + { + "rnum": 6, + "code": "6", + "name": "부산", + "image": f"{s3_base_url}/image_87.png" + }, + { + "rnum": 7, + "code": "7", + "name": "울산", + "image": f"{s3_base_url}/image_88.png" + }, + { + "rnum": 8, + "code": "8", + "name": "세종", + "image": f"{s3_base_url}/image_89.png" + }, + { + "rnum": 9, + "code": "31", + "name": "경기", + "image": f"{s3_base_url}/image_90.png" + }, + { + "rnum": 10, + "code": "32", + "name": "강원", + "image": f"{s3_base_url}/image_91.png" + }, + { + "rnum": 11, + "code": "33", + "name": "충북", + "image": f"{s3_base_url}/image_92.png" + }, + { + "rnum": 12, + "code": "34", + "name": "충남", + "image": f"{s3_base_url}/image_93.png" + }, + { + "rnum": 13, + "code": "35", + "name": "경북", + "image": f"{s3_base_url}/image_94.png" + }, + { + "rnum": 14, + "code": "36", + "name": "경남", + "image": f"{s3_base_url}/image_95.png" + }, + { + "rnum": 15, + "code": "37", + "name": "전북", + "image": f"{s3_base_url}/image_96.png" + }, + { + "rnum": 16, + "code": "38", + "name": "전남", + "image": f"{s3_base_url}/image_97.png" + }, + { + "rnum": 17, + "code": "39", + "name": "제주", + "image": f"{s3_base_url}/image_98.png" + }, +] \ No newline at end of file diff --git a/tour/sigungu.py b/tour/sigungu.py new file mode 100644 index 0000000..e5db05d --- /dev/null +++ b/tour/sigungu.py @@ -0,0 +1,103 @@ +from django.conf import settings + +SIGUNGU_DATA = { + 1: [ + {"rnum": 1, "code": "1", "name": "강남구"}, {"rnum": 2, "code": "2", "name": "강동구"}, {"rnum": 3, "code": "3", "name": "강북구"}, {"rnum": 4, "code": "4", "name": "강서구"}, {"rnum": 5, "code": "5", "name": "관악구"}, + {"rnum": 6, "code": "6", "name": "광진구"}, {"rnum": 7, "code": "7", "name": "구로구"}, {"rnum": 8, "code": "8", "name": "금천구"}, {"rnum": 9, "code": "9", "name": "노원구"}, {"rnum": 10, "code": "10", "name": "도봉구"}, + {"rnum": 11, "code": "11", "name": "동대문구"}, {"rnum": 12, "code": "12", "name": "동작구"}, {"rnum": 13, "code": "13", "name": "마포구"}, {"rnum": 14, "code": "14", "name": "서대문구"}, {"rnum": 15, "code": "15", "name": "서초구"}, + {"rnum": 16, "code": "16", "name": "성동구"}, {"rnum": 17, "code": "17", "name": "성북구"}, {"rnum": 18, "code": "18", "name": "송파구"}, {"rnum": 19, "code": "19", "name": "양천구"}, {"rnum": 20, "code": "20", "name": "영등포구"}, + {"rnum": 21, "code": "21", "name": "용산구"}, {"rnum": 22, "code": "22", "name": "은평구"}, {"rnum": 23, "code": "23", "name": "종로구"}, {"rnum": 24, "code": "24", "name": "중구"}, {"rnum": 25, "code": "25", "name": "중랑구"}, + ], + 2: [ + {"rnum": 1, "code": "1", "name": "강화군"}, {"rnum": 2, "code": "2", "name": "계양구"}, {"rnum": 3, "code": "3", "name": "미추홀구"}, {"rnum": 4, "code": "4", "name": "남동구"}, {"rnum": 5, "code": "5", "name": "동구"}, + {"rnum": 6, "code": "6", "name": "부평구"}, {"rnum": 7, "code": "7", "name": "서구"}, {"rnum": 8, "code": "8", "name": "연수구"}, {"rnum": 9, "code": "9", "name": "옹진군"}, {"rnum": 10, "code": "10", "name": "중구"}, + ], + 3: [ + {"rnum": 1, "code": "1", "name": "대덕구"}, {"rnum": 2, "code": "2", "name": "동구"}, {"rnum": 3, "code": "3", "name": "서구"}, {"rnum": 4, "code": "4", "name": "유성구"}, {"rnum": 5, "code": "5", "name": "중구"}, + ], + 4: [ + {"rnum": 1, "code": "1", "name": "남구"}, {"rnum": 2, "code": "2", "name": "달서구"}, {"rnum": 3, "code": "3", "name": "달성군"}, {"rnum": 4, "code": "4", "name": "동구"}, {"rnum": 5, "code": "5", "name": "북구"}, + {"rnum": 6, "code": "6", "name": "서구"}, {"rnum": 7, "code": "7", "name": "수성구"}, {"rnum": 8, "code": "8", "name": "중구"}, {"rnum": 9, "code": "9", "name": "군위군"}, + ], + 5: [ + {"rnum": 1, "code": "1", "name": "광산구"}, {"rnum": 2, "code": "2", "name": "남구"}, {"rnum": 3, "code": "3", "name": "동구"}, {"rnum": 4, "code": "4", "name": "북구"}, {"rnum": 5, "code": "5", "name": "서구"}, + ], + 6: [ + {"rnum": 1, "code": "1", "name": "강서구"}, {"rnum": 2, "code": "2", "name": "금정구"}, {"rnum": 3, "code": "3", "name": "기장군"}, {"rnum": 4, "code": "4", "name": "남구"}, {"rnum": 5, "code": "5", "name": "동구"}, + {"rnum": 6, "code": "6", "name": "동래구"}, {"rnum": 7, "code": "7", "name": "부산진구"}, {"rnum": 8, "code": "8", "name": "북구"}, {"rnum": 9, "code": "9", "name": "사상구"}, {"rnum": 10, "code": "10", "name": "사하구"}, + {"rnum": 11, "code": "11", "name": "서구"}, {"rnum": 12, "code": "12", "name": "수영구"}, {"rnum": 13, "code": "13", "name": "연제구"}, {"rnum": 14, "code": "14", "name": "영도구"}, {"rnum": 15, "code": "15", "name": "중구"}, + {"rnum": 16, "code": "16", "name": "해운대구"}, + ], + 7: [ + {"rnum": 1, "code": "1", "name": "중구"}, {"rnum": 2, "code": "2", "name": "남구"}, {"rnum": 3, "code": "3", "name": "동구"}, {"rnum": 4, "code": "4", "name": "북구"}, {"rnum": 5, "code": "5", "name": "울주군"}, + ], + 8: [ + {"rnum": 1, "code": "1", "name": "세종특별자치시"}, + ], + 31: [ + {"rnum": 1, "code": "1", "name": "가평군"}, {"rnum": 2, "code": "2", "name": "고양시"}, {"rnum": 3, "code": "3", "name": "과천시"}, {"rnum": 4, "code": "4", "name": "광명시"}, {"rnum": 5, "code": "5", "name": "광주시"}, + {"rnum": 6, "code": "6", "name": "구리시"}, {"rnum": 7, "code": "7", "name": "군포시"}, {"rnum": 8, "code": "8", "name": "김포시"}, {"rnum": 9, "code": "9", "name": "남양주시"}, {"rnum": 10, "code": "10", "name": "동두천시"}, + {"rnum": 11, "code": "11", "name": "부천시"}, {"rnum": 12, "code": "12", "name": "성남시"}, {"rnum": 13, "code": "13", "name": "수원시"}, {"rnum": 14, "code": "14", "name": "시흥시"}, {"rnum": 15, "code": "15", "name": "안산시"}, + {"rnum": 16, "code": "16", "name": "안성시"}, {"rnum": 17, "code": "17", "name": "안양시"}, {"rnum": 18, "code": "18", "name": "양주시"}, {"rnum": 19, "code": "19", "name": "양평군"}, {"rnum": 20, "code": "20", "name": "여주시"}, + {"rnum": 21, "code": "21", "name": "연천군"}, {"rnum": 22, "code": "22", "name": "오산시"}, {"rnum": 23, "code": "23", "name": "용인시"}, {"rnum": 24, "code": "24", "name": "의왕시"}, {"rnum": 25, "code": "25", "name": "의정부시"}, + {"rnum": 26, "code": "26", "name": "이천시"}, {"rnum": 27, "code": "27", "name": "파주시"}, {"rnum": 28, "code": "28", "name": "평택시"}, {"rnum": 29, "code": "29", "name": "포천시"}, {"rnum": 30, "code": "30", "name": "하남시"}, + {"rnum": 31, "code": "31", "name": "화성시"}, + ], + 32: [ + {"rnum": 1, "code": "1", "name": "강릉시"}, {"rnum": 2, "code": "2", "name": "고성군"}, {"rnum": 3, "code": "3", "name": "동해시"}, {"rnum": 4, "code": "4", "name": "삼척시"}, {"rnum": 5, "code": "5", "name": "속초시"}, + {"rnum": 6, "code": "6", "name": "양구군"}, {"rnum": 7, "code": "7", "name": "양양군"}, {"rnum": 8, "code": "8", "name": "영월군"}, {"rnum": 9, "code": "9", "name": "원주시"}, {"rnum": 10, "code": "10", "name": "인제군"}, + {"rnum": 11, "code": "11", "name": "정선군"}, {"rnum": 12, "code": "12", "name": "철원군"}, {"rnum": 13, "code": "13", "name": "춘천시"}, {"rnum": 14, "code": "14", "name": "태백시"}, {"rnum": 15, "code": "15", "name": "평창군"}, + {"rnum": 16, "code": "16", "name": "홍천군"}, {"rnum": 17, "code": "17", "name": "화천군"}, {"rnum": 18, "code": "18", "name": "횡성군"}, + ], + 33: [ + {"rnum": 1, "code": "1", "name": "괴산군"}, {"rnum": 2, "code": "2", "name": "단양군"}, {"rnum": 3, "code": "3", "name": "보은군"}, {"rnum": 4, "code": "4", "name": "영동군"}, {"rnum": 5, "code": "5", "name": "옥천군"}, + {"rnum": 6, "code": "6", "name": "음성군"}, {"rnum": 7, "code": "7", "name": "제천시"}, {"rnum": 8, "code": "8", "name": "진천군"}, {"rnum": 9, "code": "9", "name": "청원군"}, {"rnum": 10, "code": "10", "name": "청주시"}, + ], + 34: [ + {"rnum": 1, "code": "1", "name": "공주시"}, {"rnum": 2, "code": "2", "name": "금산군"}, + {"rnum": 3, "code": "3", "name": "논산시"}, {"rnum": 4, "code": "4", "name": "당진시"}, + {"rnum": 5, "code": "5", "name": "보령시"}, + {"rnum": 6, "code": "6", "name": "부여군"}, {"rnum": 7, "code": "7", "name": "서산시"}, + {"rnum": 8, "code": "8", "name": "서천군"}, {"rnum": 9, "code": "9", "name": "아산시"}, + {"rnum": 10, "code": "11", "name": "예산군"}, + {"rnum": 11, "code": "12", "name": "천안시"}, {"rnum": 12, "code": "13", "name": "청양군"}, + {"rnum": 13, "code": "14", "name": "태안군"}, {"rnum": 14, "code": "15", "name": "홍성군"}, + {"rnum": 15, "code": "16", "name": "계룡시"}, + ], + 35: [ + {"rnum": 1, "code": "1", "name": "경산시"}, {"rnum": 2, "code": "2", "name": "경주시"}, + {"rnum": 3, "code": "3", "name": "고령군"}, {"rnum": 4, "code": "4", "name": "구미시"}, + {"rnum": 5, "code": "6", "name": "김천시"}, + {"rnum": 6, "code": "7", "name": "문경시"}, {"rnum": 7, "code": "8", "name": "봉화군"}, + {"rnum": 8, "code": "9", "name": "상주시"}, {"rnum": 9, "code": "10", "name": "성주군"}, + {"rnum": 10, "code": "11", "name": "안동시"}, + {"rnum": 11, "code": "12", "name": "영덕군"}, {"rnum": 12, "code": "13", "name": "영양군"}, + {"rnum": 13, "code": "14", "name": "영주시"}, {"rnum": 14, "code": "15", "name": "영천시"}, + {"rnum": 15, "code": "16", "name": "예천군"}, + {"rnum": 16, "code": "17", "name": "울릉군"}, {"rnum": 17, "code": "18", "name": "울진군"}, + {"rnum": 18, "code": "19", "name": "의성군"}, {"rnum": 19, "code": "20", "name": "청도군"}, + {"rnum": 20, "code": "21", "name": "청송군"}, + {"rnum": 21, "code": "22", "name": "칠곡군"}, {"rnum": 22, "code": "23", "name": "포항시"}, + ], + 36: [ + {"rnum": 1, "code": "1", "name": "거제시"}, {"rnum": 2, "code": "2", "name": "거창군"}, {"rnum": 3, "code": "3", "name": "고성군"}, {"rnum": 4, "code": "4", "name": "김해시"}, {"rnum": 5, "code": "5", "name": "남해군"}, + {"rnum": 6, "code": "6", "name": "마산시"}, {"rnum": 7, "code": "7", "name": "밀양시"}, {"rnum": 8, "code": "8", "name": "사천시"}, {"rnum": 9, "code": "9", "name": "산청군"}, {"rnum": 10, "code": "10", "name": "양산시"}, + {"rnum": 11, "code": "12", "name": "의령군"}, {"rnum": 12, "code": "13", "name": "진주시"}, {"rnum": 13, "code": "14", "name": "진해시"}, {"rnum": 14, "code": "15", "name": "창녕군"}, {"rnum": 15, "code": "16", "name": "창원시"}, + {"rnum": 16, "code": "17", "name": "통영시"}, {"rnum": 17, "code": "18", "name": "하동군"}, {"rnum": 18, "code": "19", "name": "함안군"}, {"rnum": 19, "code": "20", "name": "함양군"}, {"rnum": 20, "code": "21", "name": "합천군"}, + ], + 37: [ + {"rnum": 1, "code": "1", "name": "고창군"}, {"rnum": 2, "code": "2", "name": "군산시"}, {"rnum": 3, "code": "3", "name": "김제시"}, {"rnum": 4, "code": "4", "name": "남원시"}, {"rnum": 5, "code": "5", "name": "무주군"}, + {"rnum": 6, "code": "6", "name": "부안군"}, {"rnum": 7, "code": "7", "name": "순창군"}, {"rnum": 8, "code": "8", "name": "완주군"}, {"rnum": 9, "code": "9", "name": "익산시"}, {"rnum": 10, "code": "10", "name": "임실군"}, + {"rnum": 11, "code": "11", "name": "장수군"}, {"rnum": 12, "code": "12", "name": "전주시"}, {"rnum": 13, "code": "13", "name": "정읍시"}, {"rnum": 14, "code": "14", "name": "진안군"}, + ], + 38: [ + {"rnum": 1, "code": "1", "name": "강진군"}, {"rnum": 2, "code": "2", "name": "고흥군"}, {"rnum": 3, "code": "3", "name": "곡성군"}, {"rnum": 4, "code": "4", "name": "광양시"}, {"rnum": 5, "code": "5", "name": "구례군"}, + {"rnum": 6, "code": "6", "name": "나주시"}, {"rnum": 7, "code": "7", "name": "담양군"}, {"rnum": 8, "code": "8", "name": "목포시"}, {"rnum": 9, "code": "9", "name": "무안군"}, {"rnum": 10, "code": "10", "name": "보성군"}, + {"rnum": 11, "code": "11", "name": "순천시"}, {"rnum": 12, "code": "12", "name": "신안군"}, {"rnum": 13, "code": "13", "name": "여수시"}, {"rnum": 14, "code": "16", "name": "영광군"}, {"rnum": 15, "code": "17", "name": "영암군"}, + {"rnum": 16, "code": "18", "name": "완도군"}, {"rnum": 17, "code": "19", "name": "장성군"}, {"rnum": 18, "code": "20", "name": "장흥군"}, {"rnum": 19, "code": "21", "name": "진도군"}, {"rnum": 20, "code": "22", "name": "함평군"}, + {"rnum": 21, "code": "23", "name": "해남군"}, {"rnum": 22, "code": "24", "name": "화순군"}, + ], + 39: [ + {"rnum": 1, "code": "1", "name": "남제주군"}, {"rnum": 2, "code": "2", "name": "북제주군"}, {"rnum": 3, "code": "3", "name": "서귀포시"}, {"rnum": 4, "code": "4", "name": "제주시"}, + ], +} diff --git a/tour/tests.py b/tour/tests.py index 12ebd7f..6f169a8 100644 --- a/tour/tests.py +++ b/tour/tests.py @@ -1,5 +1,13 @@ -from django.test import TestCase -from config.settings import PUBLIC_DATA_PORTAL_API_KEY, KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY # 공공 데이터 포탈 앱 키 +import logging +from unittest.mock import patch +from urllib.parse import urlencode + +from channels.testing import WebsocketCommunicator +from django.test import override_settings +from django.urls import reverse + +from config.settings import APP_LOGGER +from config.settings import PUBLIC_DATA_PORTAL_API_KEY # 공공 데이터 포탈 앱 키 from services.tour_api import ( TourApi, MobileOS, @@ -8,27 +16,63 @@ Category1Code, ContentTypeId, ) -from usr.models import User -from .models import Travel -from services.kakao_token_service import KakaoTokenService from tests.base import BaseTestCase +from tour.consumers import TaskConsumer +from usr.models import User +from .models import Travel, Place +from django.utils import timezone + +logger = logging.getLogger(APP_LOGGER) + # Create your tests here. class TestTour(BaseTestCase): def setUp(self): - # 유저 정보 임의 생성 - # 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 + self.headers = { + 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', + } + # 테스트 위한 장소 생성 + self.place = Place.objects.create( + name='명원박물관', + mapX='126.9999927956', + mapY='37.6111883307', + areacode='31', + sigungucode='2', + contentid='2930970', + contenttypeid='38', + ) + self.data = { + "tour_name": "태근이의 여행", + "tour_date": timezone.localdate(timezone.now()).strftime('%Y-%m-%d'), + "places": { + "place_ids": [self.place.id], + "additional_info": [ + { + "place_id": self.place.id, + "road_address": "테스트지롱", + "place_image": "http://naver.com" + } + ], + "custom_places": [ + { + "name": "아산 공세리성당", + "mapX": "126.9134070332", + "mapY": "36.8833377411", + "road_address": "충청남도 아산시 인주면 공세리성당길 10" + }, + { + "name": "성북구립미술관", + "mapX": "126.9949020554", + "mapY": "37.594890134", + "road_address": "서울특별시 성북구 성북로 134 (성북동)" + }, + ] + } + + } + + # 유저 정보 임의 생성 - 친구 추가를 위한 추가 유저 user2 = User.objects.create( sub=1, username='TestUser2', @@ -38,147 +82,183 @@ def setUp(self): ) user2.set_password('test_password112') user2.save() - def test_tour_api_module(self): + + + + + def test_tour_create_success(self): """ - 해당 테스트는 module/tour_api를 테스트하기 위해 작성된 테스트 코드 입니다. + 해당 테스트는 여행이 제대로 잘 만들어지는지 확인하는 테스트입니다. """ - tour = TourApi(MobileOS=MobileOS.ANDROID, MobileApp='AlphaTest') - tour.set_serviceKey(PUBLIC_DATA_PORTAL_API_KEY) - # 지역 기반 관광지 가져오기 1 - area = tour.get_area_based_list(areaCode=AreaCode.SEOUL, - sigunguCode=tour.get_sigungu_code(areaCode=AreaCode.SEOUL, targetName='성북')) - self.assertNotEqual(area, None) + uri = reverse('create-tour') - # 지역 기반 관광지 가져오기 2 - data = { - 'areaCode': AreaCode.SEOUL, - 'sigunguCode': tour.get_sigungu_code(areaCode=AreaCode.SEOUL, targetName='종로'), - 'arrange': Arrange.TITLE_IMAGE, - 'contentTypeId': ContentTypeId.GWANGWANGJI - } - area = tour.get_area_based_list(**data) - self.assertNotEqual(area, None) + # create test + response = self.client.post(uri, self.data, headers=self.headers, content_type='application/json') + self.assertEqual(response.status_code, 201) + # self.assertEqual(Place.objects.count(), 3) + logger.debug('tour create test result: ' + str(response.json())) - # 카테고리 코드 가져오기 테스트 - categories = tour.get_category_code_list(cat1=Category1Code.HUMANITIES, cat2='A0201') - self.assertNotEqual(categories, None) + def test_tour_get_list_success(self): + """ + 해당 테스트는 여행 등록 api의 GET 메소드가 제대로 실행되는지 확인하는 테스트입니다. + """ + self.test_tour_create_success() + uri = reverse('create-tour') + response = self.client.get(uri, headers=self.headers) + self.assertEqual(response.status_code, 200) + logger.debug('tour get list test result: ' + str(response.json())) - # 위치 기반 관광지 가져오기 - data = { - 'areaCode': AreaCode.SEOUL, - 'arrange': Arrange.TITLE_IMAGE, - 'contentTypeId': ContentTypeId.GWANGWANGJI - } - response = tour.get_location_based_list(126.3547412438, 34.4354594945, 20000) - self.assertNotEqual(response, None) - - # 행사 정보 가져오기 - data.pop('contentTypeId') - response = tour.get_festival_list('20250315', '20250318', **data) - self.assertNotEqual(response, None) - # for each in response: - # print(each.get_eventStartDate(), each.get_eventEndDate()) - - def test_travel_api(self): - uri = '/tour/' - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - data = { - 'tour_name': '태근이의 여행', - 'start_date': '2025-03-10', - 'end_date': '2025-03-15', - } - # 빈 데이터 list get Test - response = self.client.get(uri, headers=headers) + def test_tour_get_detail_success(self): + """ + 해당 테스트는 여행 등록 api의 GET (상세보기, retrieve) 메소드가 제대로 실행되는지 확인하는 테스트입니다. + """ + self.test_tour_create_success() + uri = reverse('travel-detail', kwargs={'pk': Travel.objects.first().pk}) + response = self.client.get(uri, headers=self.headers) self.assertEqual(response.status_code, 200) + logger.debug('tour get detail test result: ' + str(response.json())) - # create test - response = self.client.post(uri, data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 201) + def test_tour_delete_success(self): + """ + 해당 테스트는 여행이 정상적으로 삭제 되는지 확인하기 위한 테스트입니다. + """ + self.test_tour_create_success() # 여행 생성 + uri = reverse('travel-detail', kwargs={'pk': Travel.objects.first().pk}) + response = self.client.delete(uri, headers=self.headers) + self.assertEqual(response.status_code, 204) + + def test_tour_delete_fail(self): + """ + 해당 테스트는 없는 여행 번호를 삭제하고자 할 떄 확인하는 테스트입니다. + """ + uri = reverse('travel-detail', kwargs={'pk': '123141'}) + response = self.client.delete(uri, headers=self.headers) + self.assertEqual(response.status_code, 404) + logger.debug('tour delete fail test result: ' + str(response.json())) - # create test - Exception Test + def test_tour_exception_test(self): + """ + 해당 테스트는 정확한 오류코드가 발생되는지 검사하기 위한 테스트입니다. + """ + # No Required Parameter Exception + uri = reverse('create-tour') exception_data = { 'id': 1, 'start_date': '2025-0310', } - response = self.client.post(uri, exception_data, headers=headers, content_type='application/json') + response = self.client.post(uri, exception_data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 400) + logger.debug('tour No Required Exception test result: ' + str(response.json())) - # 인스턴스 임의로 하나 더 생성 - data['tour_name'] = '태근이의 여행2' - response = self.client.post(uri, data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 201) - - # list get Test - response = self.client.get(uri, headers=headers) - self.assertEqual(response.status_code, 200) - - # detail get Test - id = Travel.objects.get(tour_name='태근이의 여행').id - uri_detail = f'/tour/{id}/' # 아이디 1번 - response = self.client.get(uri_detail, headers=headers) - self.assertEqual(response.status_code, 200) + serializer_exception_data = self.data.copy() + serializer_exception_data.pop('tour_date') + response = self.client.post(uri, serializer_exception_data, headers=self.headers, content_type='application/json') + self.assertEqual(response.status_code, 400) + logger.debug('tour Serializer Exception test result: ' + str(response.json())) - # delete Test - response = self.client.delete(uri_detail, headers=headers) - self.assertEqual(response.status_code, 204) - response = self.client.get(uri, headers=headers) - self.assertEqual(response.status_code, 200) + def test_tour_update_success(self): + """ + 해당 테스트는 여행이 정상적으로 수정이 되는지 확인하기 위한 테스트입니다. + """ + self.place = Place.objects.create( + name='명원박물관2', + mapX='126', + mapY='37', + ) + self.data = { + "tour_name": "태근이의 여행", + "tour_date": "2025-07-07", + "places": { + "place_ids": [self.place.id], + "additional_info": [ + { + "place_id": self.place.id, + "road_address": "테스트지롱2", + "place_image": "http://naver.com" + } + ], + "custom_places": [ + { + "name": "아산 공세리성당", + "mapX": "126.9134070332", + "mapY": "36.8833377411", + "road_address": "충청남도 아산시 인주면 공세리성당길 10" + }, + { + "name": "성북구립미술관", + "mapX": "126.9949020554", + "mapY": "37.594890134", + "road_address": "서울특별시 성북구 성북로 134 (성북동)" + }, + ] + } - # put Test - Exception Test - put_data = { - 'tour_name': '시연이의 여행' } - response = self.client.put(uri_detail, put_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 404) - - # put Test - id2 = Travel.objects.get(tour_name='태근이의 여행2').id - uri_detail = f'/tour/{id2}/' # 아이디 2번 - response = self.client.put(uri_detail, put_data, headers=headers, content_type='application/json') + self.test_tour_create_success() + uri = reverse('travel-detail', kwargs={'pk': Travel.objects.first().pk}) + patch_data = { + 'tour_name': '시연이의 여행', + 'tour_date': '2025-07-08', + 'places': { + 'delete_places': [self.place.id] + } + } + response = self.client.patch(uri, patch_data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 200) + logger.debug('tour Update success test result: ' + str(response.json())) - # get Test - Exception Test - uri_detail = f'/tour/{id}/' - response = self.client.get(uri_detail, headers=headers) + def test_tour_update_fail(self): + """ + 해당 테스트는 여행이 수정이 되지 못할 때 즉, 여행 장소 정보가 없을 때 발생하는 오류를 테스트합니다. + """ + self.test_tour_create_success() # 여행 생성 + uri = reverse('travel-detail', kwargs={'pk': Travel.objects.first().pk}) + patch_data = { + 'places': { + 'delete_places': [1123124] + } + } + response = self.client.patch(uri, patch_data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 404) + logger.debug('tour Update fail test result: ' + str(response.json())) - def test_add_traveler(self): - end_point = '/tour/' - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } + def test_add_traveler_success(self): + """ + 해당 테스트는 한 여행에 친구 추가가 제대로 되는지 테스트 합니다. + """ + self.test_tour_create_success() # 여행 추가 + uri = reverse('add_traveler') data = { - 'tour_name': '태근이의 여행', - 'start_date': '2025-03-10', - 'end_date': '2025-03-15', + 'add_traveler_sub': User.objects.first().sub, + 'travel_id': Travel.objects.first().id, } - response = self.client.post(end_point, headers=headers, data=data, content_type='application/json') - + response = self.client.post(uri, data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 201) - end_point = '/tour/add_traveler/' + logger.debug('tour Add Traveler success test result: ' + str(response.json())) + + def test_add_traveler_fail(self): + """ + 해당 테스트는 한 여행에 친구 추가가 제대로 안되었을 때 제대로 된 에러 코드가 날라오는지 테스트합니다. + """ + uri = reverse('add_traveler') data = { 'add_traveler_sub': 1, 'travel_id': 1, } - # Normal POST Test - response = self.client.post(end_point, data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 201) - - # Exception Test strange_data = { 'aadd_traveler_sub': 1, 'travel_id': 1, } - response = self.client.post(end_point, strange_data, headers=headers, content_type='application/json') + response = self.client.post(uri, strange_data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 400) + logger.debug('tour Add Traveler No Required Parameter test result: ' + str(response.json())) data['add_traveler_sub'] = 324 # 없는 데이터 - response = self.client.post(end_point, data, headers=headers, content_type='application/json') + response = self.client.post(uri, data, headers=self.headers, content_type='application/json') self.assertEqual(response.status_code, 400) + logger.debug('tour Add Traveler fail test result: ' + str(response.json())) - def test_get_area_list(self): + def test_get_area_list_success(self): """ 해당 테스트는 시군구 코드를 정확하게 가져오는지 테스트합니다. """ @@ -187,260 +267,142 @@ def test_get_area_list(self): response = self.client.get(end_point) self.assertEqual(response.status_code, 200) - #404 Test - end_point = '/tour/get_area_list/?area_code=234' - response = self.client.get(end_point) - self.assertEqual(response.status_code, 404) - # sido_list Test end_point = '/tour/get_sido_list/' response = self.client.get(end_point) self.assertEqual(response.status_code, 200) - - def test_save_course(self): + def test_get_sido_list_fail(self): """ - 해당 테스트는 /tour/course/ 경로 저장 API가 정상적으로 작동하는지 검증합니다. + 해당 테스트는 시군구 코드 가져오는 것을 실패했을 때를 테스트합니다. """ + #404 Test + end_point = '/tour/get_area_list/?area_code=234' + response = self.client.get(end_point) + self.assertEqual(response.status_code, 404) - # 1️⃣ 여행 생성 - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - travel_data = { - 'tour_name': '테스트 여행', - 'start_date': '2025-04-01', - 'end_date': '2025-04-05' + # tour/consumers.py에 있는 app.send_task 함수를 Mock() 객체로 교체 + # Mock 객체를 테스트 함수 인자로 넘김 + @patch('tour.consumers.app.send_task') + @override_settings(CHANNEL_LAYERS={ + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer" } - create_response = self.client.post('/tour/', data=travel_data, headers=headers, content_type='application/json') - self.assertEqual(create_response.status_code, 201) - tour_id = create_response.json()['id'] - - # 2️⃣ 정상적인 코스 저장 요청 - course_data = { - "tour_id": tour_id, - "date": "2025-04-02", - "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) # ✅ 정상적으로 저장되었는지 확인 - self.assertEqual(response.json()['date'], "2025-04-02") - self.assertEqual(len(response.json()['places']), 2) - - # 예외 케이스: 날짜 범위 오류 - course_data['date'] = '2025-04-06' - response = self.client.post('/tour/course/', data=course_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 400) - - # 3️⃣ 예외 케이스: 필수 필드 누락 (date 없음) - bad_data = { - "tour_id": tour_id, - "places": [ - { - "name": "남산타워", - "mapX": "126.9882", - "mapY": "37.5512", - "image_url": "https://image.example.com/namsan.jpg" - } - ] + }, + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'test-cache' + } } - response = self.client.post('/tour/course/', data=bad_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 400) - - # 4️⃣ 예외 케이스: 존재하지 않는 여행 ID - wrong_data = { - "tour_id": 9999, - "date": "2025-04-03", - "places": [ - { - "name": "북촌한옥마을", - "mapX": "126.9870", - "mapY": "37.5825", - "image_url": "https://image.example.com/bukchon.jpg" - } - ] + ) + async def test_tour_recommender(self, mock_send_task): + """ + 해당 테스트는 웹소켓 통신을 테스트합니다. + """ + mock_send_task.return_value.task_id = 'mocked-task-id' + params = { + 'areaCode': '34', + 'user_id': '111', + 'unique_code': '1', + 'sigunguName': '아산', + 'categoryName': '39', } - response = self.client.post('/tour/course/', data=wrong_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 404) + query_string = urlencode(params) + communicator = WebsocketCommunicator( + TaskConsumer.as_asgi(), + f'/tour/recommend/?{query_string}', + ) + connected, subprotocol = await communicator.connect(20) + self.assertTrue(connected) + response = await communicator.receive_json_from(10) + logger.debug('tour Recommender test result: ' + str(response)) + ans = response.get('state') == 'OK' or response.json().get('state') == 'CACHE_HIT' + self.assertTrue(ans) + + def testSnapshot_post_success(self): + """ + 인생네컷 사진 업로드 성공 테스트 + """ + pass - def test_delete_tour_course(self): + def testSnapshot_post_fail(self): """ - 해당 테스트는 내 여행 경로 삭제 API를 검증합니다. + 인생네컷 사진 업로드 실패 테스트 """ - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } + pass - # 여행 생성 - create_endpoint = '/tour/' - travel_data = { - 'tour_name': '삭제 테스트 여행', - 'start_date': '2025-04-10', - 'end_date': '2025-04-15', - } - create_response = self.client.post(create_endpoint, data=travel_data, headers=headers, - content_type='application/json') - self.assertEqual(create_response.status_code, 201) - - # 생성된 여행의 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) # ✅ 정상적으로 저장되었는지 확인 + def testSnapshot_list_success(self): + """ + 인생네컷 사진 리스트 가져오기 성공 테스트 + """ + pass - # 삭제 요청 - delete_endpoint = f'/tour/course/{tour_id}/' - 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) + def testSnapshot_list_fail(self): + """ + 인생네컷 사진 리스트 가져오기 실패 테스트 + """ + pass - get_response = self.client.get(f'/tour/course/{tour_id}/', headers=headers) - print(get_response.json()) + def testSnapshot_retrieve_success(self): + """ + 인생네컷 사진 상세 가져오기 성공 테스트 + """ + pass - def test_retrieve_course(self): + def testSnapshot_retrieve_fail(self): """ - 해당 테스트는 /tour/course// 경로 조회 API가 정상적으로 작동하는지 검증합니다. + 인생네컷 사진 상세 가져오기 실패 테스트 """ + pass - # 1️⃣ 여행 생성 - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - travel_data = { - 'tour_name': '조회용 여행', - 'start_date': '2025-04-01', - 'end_date': '2025-04-05' - } - create_response = self.client.post('/tour/', data=travel_data, headers=headers, content_type='application/json') - self.assertEqual(create_response.status_code, 201) - tour_id = create_response.json()['id'] - - # 2️⃣ 경로 저장 - course_data = { - "tour_id": tour_id, - "date": "2025-04-02", - "places": [ - { - "name": "덕수궁", - "mapX": "126.9751", - "mapY": "37.5658", - "image_url": "https://image.example.com/deoksugung.jpg" - }, - { - "name": "경복궁", - "mapX": "126.9769", - "mapY": "37.5796", - "image_url": "https://image.example.com/gyeongbok.jpg" - } - ] - } - save_response = self.client.post('/tour/course/', data=course_data, headers=headers, - content_type='application/json') - self.assertEqual(save_response.status_code, 201) + def test_tour_image_post_success(self): + """ + 여행 사진 업로드 성공 테스트 + """ + pass - # 3️⃣ 저장한 경로 조회 요청 - retrieve_uri = f'/tour/course/{tour_id}/' - response = self.client.get(retrieve_uri, headers=headers) + def test_pose_recommend_retrieve_for_no_cat_success(self): + """ + 카테고리가 없는 장소에 대한 포즈 추천 성공 테스트 + """ + # 장소가 존재하지 않을 경우를 대비해 장소를 만듭니다. + place = Place.objects.create( + name='명원박물관 테스트', + mapX='127', + mapY='37.8111883307', + ) + uri = f'{reverse('pose_recommend')}?place_id={place.id}' + response = self.client.get(uri) self.assertEqual(response.status_code, 200) + logger.debug('tour Recommend for no cat test result: ' + str(response.json())) - course_list = response.json() - self.assertEqual(len(course_list), 1) - self.assertEqual(course_list[0]['date'], "2025-04-02") - self.assertEqual(len(course_list[0]['places']), 2) - self.assertEqual(course_list[0]['places'][0]['name'], "덕수궁") - - # 4️⃣ 예외 케이스: 존재하지 않는 tour_id - wrong_uri = '/tour/course/99999/' - response = self.client.get(wrong_uri, headers=headers) - self.assertEqual(response.status_code, 403) - - def test_get_tour_course_list(self): + def test_pose_recommend_retrieve_for_cat_success(self): """ - 해당 테스트는 여행 경로들을 리스트로 가져오는지 테스트합니다. + 카테고리가 존재하는 장소에 대한 포즈 추천 성공 테스트 """ + # 장소가 존재하지 않을 경우를 대비해 장소를 만듭니다. + place = Place.objects.create( + name='명원박물관 테스트', + mapX='127', + mapY='37.8111883307', + cat2='B0201' + ) + uri = f'{reverse('pose_recommend')}?place_id={place.id}' + response = self.client.get(uri) + self.assertEqual(response.status_code, 200) + logger.debug('tour Recommend for cat test result: ' + str(response.json())) - # 여행 생성 - create_endpoint = '/tour/' - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - travel_data = { - 'tour_name': '경로 테스트 여행', - 'start_date': '2025-04-01', - 'end_date': '2025-04-05', - } - create_response = self.client.post(create_endpoint, data=travel_data, headers=headers, - content_type='application/json') - self.assertEqual(create_response.status_code, 201) - - # 200 Test - endpoint = '/tour/course/' - response = self.client.get(endpoint, headers=headers) + def test_get_today_tour_success(self): + """ + 당일 여행 가져오기 성공 테스트 + """ + self.test_tour_create_success() + uri = reverse('get_today_tour') + response = self.client.get(uri, headers=self.headers) self.assertEqual(response.status_code, 200) + logger.debug('get_today_tour_success Test result: ' + str(response.json())) + - self.assertIn('travels', response.json()) - travels = response.json()['travels'] - self.assertIsInstance(travels, list) - # 데이터가 어케 날아오는지 확인하는 코드 ? - if travels: - self.assertIn('tour_id', travels[0]) - self.assertIn('start_date', travels[0]) - self.assertIn('end_date', travels[0]) - self.assertIn('places', travels[0]) diff --git a/tour/urls.py b/tour/urls.py index 4abef0a..84e85e2 100644 --- a/tour/urls.py +++ b/tour/urls.py @@ -1,15 +1,27 @@ from django.urls import path -from .views import TravelViewSet, NearEventView, AddTravelerView, GetAreaList, Sido_list, CourseView + +from .views import ( + NearEventView, + AddTravelerView, + GetAreaList, + Sido_list, + NewTourAddView, + TourSnapshotsView, + CategoryListView, UserTourImageView, + PoseRecommendView, + TodayTravelViewSet, + RelationPlaceView +) urlpatterns = [ - path('', TravelViewSet.as_view({ + path('', NewTourAddView.as_view({ 'get': 'list', 'post': 'create' - }), name='travel-list-create'), + }), name='create-tour'), - path('/', TravelViewSet.as_view({ + path('/', NewTourAddView.as_view({ 'get': 'retrieve', - 'put': 'partial_update', + 'patch': 'partial_update', # 메소드를 일부 업데이트인 patch로 변경 'delete': 'destroy' }), name='travel-detail'), @@ -28,15 +40,34 @@ path('get_sido_list/', Sido_list.as_view({ 'get': 'retrieve' })), + path('snapshot/', TourSnapshotsView.as_view({ + 'get': 'list', + 'post': 'create', + })), + path('snapshot//', TourSnapshotsView.as_view({ + 'get': 'retrieve', + 'delete': 'destroy', + })), - path('course/', CourseView.as_view({ - 'post': 'create', # 저장 - 'get': 'list' # 전체 조회 - }), name='course-list-create'), - - path('course//', CourseView.as_view({ - 'get': 'retrieve', # 개별 조회 - 'delete': 'destroy' # 삭제 - }), name='course-detail'), + path('category/', CategoryListView.as_view({ + 'get': 'retrieve' # 카테고리 리스트 조회 + }), name='category-list'), + path('image/', UserTourImageView.as_view({ + 'get': 'list', + 'post': 'create', + })), + path('image//', UserTourImageView.as_view({ + 'get': 'retrieve', + 'delete': 'destroy' + })), + path('pose-rec/', PoseRecommendView.as_view({ + 'get': 'retrieve', + }), name='pose_recommend'), + path('today/', TodayTravelViewSet.as_view({ + 'get': 'list' + }), name='get_today_tour'), + path('relation_info/', RelationPlaceView.as_view({ + 'get': 'list' + })) ] diff --git a/tour/views.py b/tour/views.py index dbe48b8..50c3a61 100644 --- a/tour/views.py +++ b/tour/views.py @@ -1,47 +1,36 @@ +import logging + from django.core.exceptions import ValidationError from rest_framework import viewsets, status -from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response -from usr.models import User -from .serializers import TravelSerializer, PlaceSerializer, TravelDaysAndPlacesSerializer from config.settings import SEOUL_PUBLIC_DATA_SERVICE_KEY, PUBLIC_DATA_PORTAL_API_KEY, KAKAO_REST_API_KEY, APP_LOGGER -from .serializers import EventSerializer -from services.tour_api import TourApi, NearEventInfo -from .services import PlaceService -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__) - - -class TravelViewSet(viewsets.ModelViewSet): - queryset = Travel.objects.all() - serializer_class = TravelSerializer - permission_classes = [IsAuthenticated] # 로그인한 사용자만 api를 승인합니다. - - def create(self, request, *args, **kwargs): # 새로운 여행 등록 API - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 - - # request.data를 변경 가능한 딕셔너리로 변환 후 user 추가 - travel_data = dict(request.data).copy() - # travel_data["user"] = user_sub # 다대일 관계시 유저 추가 - - serializer = self.get_serializer(data=travel_data) # 수정된 데이터로 serializer 초기화 - serializer.is_valid(raise_exception=True) - travel = serializer.save() # ORM을 이용해 저장 - travel.user.add(User.objects.get(sub=user_sub)) # 다대 다 관계시 유저 추가 - data = self.get_serializer(travel).data - - # json 응답을 반환 - return Response(data, status=status.HTTP_201_CREATED) +from services.tour_api import TourApi, NearEventInfo +from usr.models import User +from .models import Travel, Place, Event, SnapshotImages, UserTourImage, TravelDaysAndPlaces, RelationPlace +from .serializers import EventSerializer, UserTourImageSerializer, PoseRecommendSerializer, \ + MiniRelationPlaceSerializer +from .serializers import TravelSerializer, PlaceSerializer, TravelDaysAndPlacesSerializer, \ + TravelListSerializer, TourSnapshotsSerializer +from .services import PlaceService, TravelCreationService, TravelUpdateService, TodayTravelService +from services.utils import haversine +from django_filters.rest_framework import DjangoFilterBackend +from django.db.models.functions import Cast +from django.db.models import FloatField +from tour.poses import POSE_MAP +from tour.poses_url import POSE_URL_MAP +from tour.sido import SIDO_LIST +from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination +from tour.sigungu import SIGUNGU_DATA + +logger = logging.getLogger(APP_LOGGER) class NearEventView(viewsets.ModelViewSet): @@ -60,7 +49,6 @@ def list(self, request, *args, **kwargs): if mapX is None or mapY is None: # 필수 파라미터 검증 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") # 해당 오류는 서버 오류에 가깝기 때문에 로그를 남깁니다. @@ -121,267 +109,246 @@ class GetAreaList(viewsets.ViewSet): def list(self, request, *args, **kwargs): area_code = request.GET.get('area_code', None) - response_data = {} - tour = TourApi(service_key=PUBLIC_DATA_PORTAL_API_KEY) - # 전국을 다 보냅니다. - area_list = tour.get_sigungu_code_list() if area_code is None: - for each in area_list: - response_data[each['code']] = tour.get_sigungu_code_list(int(each['code'])) - else: - code_list = [] - for each in area_list: - code_list.append(int(each['code'])) + return Response(SIGUNGU_DATA, status=status.HTTP_200_OK) + + try: area_code = int(area_code) - if area_code not in code_list: - 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) + except ValueError: + return Response( + {"error": "area_code는 숫자여야 합니다."}, + status=status.HTTP_400_BAD_REQUEST + ) + + response_data = SIGUNGU_DATA.get(area_code, None) + if not response_data: + raise NoObjectException(error_message='올바른 시군구 데이터가 없습니다.') + + return Response({str(area_code): response_data}, status=status.HTTP_200_OK) class Sido_list(viewsets.ViewSet): def retrieve(self, request): - tour = TourApi(service_key=PUBLIC_DATA_PORTAL_API_KEY) - sido_list = tour.get_sigungu_code_list() - return Response(sido_list, status=status.HTTP_200_OK) + return Response(SIDO_LIST, status=status.HTTP_200_OK) +class NewTourAddView(viewsets.ModelViewSet): + """ + 해당 뷰는 새로운 여행을 추가하는 뷰를 담당합니다. + 구현 API: + 여행 등록 + 사용자 여행 리스트 조회 + 해당 여행 상세 조회 + 여행 정보 수정(장소 정보 포함) + 여행 삭제 + """ + permission_classes = [IsAuthenticated] + queryset = Travel.objects.all() # 여행 모델에 대한 정보만 가지고 옵니다. + serializer_class = TravelSerializer + filter_backends = (DjangoFilterBackend, ) + filterset_fields = ('id',) -class CourseView(viewsets.ViewSet): + def __init__(self, **kwargs): + super().__init__(**kwargs) + place_service = PlaceService(KAKAO_REST_API_KEY) + self.travel_creation_service = TravelCreationService(place_service) + self.travel_update_service = TravelUpdateService(self.travel_creation_service) - def __validate_parameters_in_post(self, tour_id, date, places, user_sub) -> tuple[int, str]: - """ - 해당 함수는 post 요청이 들어왔을 때 정상적으로 파라미터가 왔는지 검사히기 위한 로직입니다. - 1. places가 리스트 형식인지 확인 - 2. 필수 파라미터가 존재하는지 확인 - 3. 파라미터 중, date 형식이 맞는지 확인 - 4. 실제로 여행 id가 존재하는지 확인 - """ - if not isinstance(places, list): return 400, 'places는 리스트 형태이어야 합니다.' # places가 리스트 형식이 아니라면 - if not tour_id or not date or len(places) == 0: return 400, '필수 파라미터 중 일부 혹은 전체가 없습니다. tour_id, date, places를 확인해주세요.' # 파라미터를 잘못 주었을 때 - try: - tour_date = datetime.datetime.strptime(date, "%Y-%m-%d") - except ValueError: - logger.info(f'date: {date} is not date format') # 클라이언트가 잘못 요청 보낸 것이므로 - return 400, "date의 형식이 올바르지 않습니다." + def get_queryset(self): + return self.queryset.filter(user__sub=self.request.user.sub) - # 실제로 Travel이 존재하는지 확인합니다. - travel = None + def create(self, request, *args, **kwargs): + """여행 상세등록 API - 최적화된 버전""" + logger.debug("/tour/ create 메소드 실행") + + # 파라미터 유효성 검사 + places_data = request.data.get('places') + if not places_data: + raise NoRequiredParameterException("No Object", "places 정보가 없습니다.") + + # 여행 데이터 준비 + travel_data = request.data.copy() + travel_data.pop('places') + + # 서비스를 통한 여행 생성 + travel = self.travel_creation_service.create_travel_with_places( + travel_data=travel_data, + places_data=places_data, + user_sub=request.user.sub + ) + + # 응답 반환 + serializer = TravelSerializer(travel) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, *args, **kwargs): + """여행 정보 수정 - 최적화된 버전""" + travel_id = int(kwargs.get('pk')) + + # 장소 정보가 없는 경우 기본 업데이트 + places_data = request.data.get('places') + if not places_data: + return super().partial_update(request, *args, **kwargs) + + # 여행 데이터 준비 + travel_data = request.data.copy() + travel_data.pop('places') + + # 서비스를 통한 업데이트 + travel = self.travel_update_service.update_travel_with_places( + travel_id=travel_id, + travel_data=travel_data if travel_data else None, + places_data=places_data + ) + + serializer = TravelSerializer(travel) + return Response(serializer.data, status=status.HTTP_200_OK) + + def retrieve(self, request, *args, **kwargs): + """여행 상세정보 조회 API""" + travel_id = int(kwargs.get('pk')) try: - travel = Travel.objects.get(id=int(tour_id), user__sub=user_sub) - except Travel.DoesNotExist: # travel이 존재하지 않는다면 - logger.warning(f'travel id: {tour_id} is not exist in DB.') - return 404, '해당 여행이 존재하지 않습니다.' - - end_date = datetime.datetime.strptime(str(travel.end_date), "%Y-%m-%d") - start_date = datetime.datetime.strptime(str(travel.start_date), "%Y-%m-%d") - if end_date < tour_date or start_date > tour_date: # tour_date가 등록된 여행 날짜 외라면 - logger.warning(f'등록 범위 외 날짜 여행 등록 시도') - return 400, '해당 여행은 등록된 날짜의 여행 날짜 범위 외 날짜 입니다.' - return 200, 'Validate' - - def create(self, request, *args, **kwargs): # 여행 경로 저장 API - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 - - # request.data를 변경 가능한 딕셔너리로 변환 - # 필수 파라미터 추출 - course_data = request.data.copy() - tour_id = course_data.get('tour_id', None) # 여행 id - date = course_data.get('date', None) # 여행 날짜 - places = course_data.get('places', []) # 장소 정보들 가져오기 - - # 파라미터 validate - status_code, message = self.__validate_parameters_in_post(tour_id, date, places, user_sub) - if status_code != 200: - return Response({ - "error": status_code, - "message": message - }, status=status_code) - - travel = Travel.objects.get(id=int(tour_id), user__sub=user_sub) - - place_results = [] - - for place_data in places: - name = place_data.get('name', None) - mapX = place_data.get('mapX', None) - mapY = place_data.get('mapY', None) - image_url = place_data.get('image_url', None) - road_address = place_data.get('road_address', None) # 도로명 주소를 받아옵니다. - parcel_address = None # 지번 주소를 받아옵니다. - - # 장소 필수 정보 누락 시 해당 장소는 스킵 - if not name or not mapX or not mapY: - logger.info(f'필수 정보 누락 (place name: {name}, mapX: {mapX}, mapY: {mapY})') # 클라이언트 잘못이므로 info - continue - - # 장소 저장 (중복 시 get) - place_service = PlaceService(service_key=KAKAO_REST_API_KEY) - if road_address is None: parcel_address, road_address = place_service.get_parcel_and_road_address(float(mapX), float(mapY)) - else: parcel_address = place_service.get_parcel(float(mapX), float(mapY)) - place, _ = Place.objects.get_or_create( - name=name, - mapX=mapX, - mapY=mapY, - road_address=road_address, - address=parcel_address + travel = Travel.objects.get(id=travel_id) + serializer = TravelSerializer(travel) + return Response(serializer.data, status=status.HTTP_200_OK) + except Travel.DoesNotExist: + raise NoObjectException( + error_message='해당 여행 id에 해당하는 여행이 존재하지 않습니다.' ) - # 날짜별 장소 연결 저장 - tdp, _ = TravelDaysAndPlaces.objects.get_or_create( - travel=travel, - place=place, - date=date - ) + def list(self, request, *args, **kwargs): + self.serializer_class = TravelListSerializer + return super().list(request, *args, **kwargs) + + +class BaseImageSaveView(viewsets.ModelViewSet): + """ + 해당 클래스는 여행 이미지, 인생네컷 등 사진 데이터를 저장하는 뷰로 활용됩니다. + """ + + permission_classes = [IsAuthenticated] # 로그인 사용자를 디폴트로 + + def get_queryset(self): + return self.queryset.filter(tour__user__sub=self.request.user.sub) + + def create(self, request, *args, **kwargs): + """ + 사진 저장 API + """ + image = request.FILES.get('image', None) + if image is None: + raise NoRequiredParameterException(error_message='사진은 필수 입니다.') + + data = request.data.copy() + data['user'] = request.user.sub + data['tour'] = data.pop('tour_id', None) + logger.debug('tour: ' + str(data['tour'])) + if data['tour'] is None: + raise NoRequiredParameterException() + data['tour'] = int(data['tour'][0]) + + logger.debug('사진 저장 시작') + logger.debug('request: ' + str(data)) + + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) - # 이미지가 있을 경우 별도 저장 - if image_url: - PlaceImages.objects.get_or_create( - place=place, - image_url=image_url - ) - - place_results.append({ - "name": name, - "mapX": mapX, - "mapY": mapY, - "image_url": image_url, - "road_address": road_address, - "parcel_address": parcel_address, - 'place_id': place.id, - 'tdp_id': tdp.id, - }) - - # 최종 응답 반환 - return Response({ - "date": date, - "places": place_results - }, status=status.HTTP_201_CREATED) - - def retrieve(self, request, pk=None): # 여행 경로 가져오기 API - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 - tour_id = pk - - # 여행 존재 여부 및 권한 확인 + def destroy(self, request, *args, **kwargs): + obj_id = kwargs.get('pk') + # 사진 S3에서도 삭제 try: - travel = Travel.objects.get(id=int(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": "403", - "message": "해당 여행이 존재하지 않거나 접근 권한이 없습니다." - }, status=status.HTTP_403_FORBIDDEN) - - # 해당 여행에 연결된 날짜별 장소 정보 조회 - travel_days = TravelDaysAndPlaces.objects.filter(travel=travel).order_by('date') - if not travel_days.exists(): - logger.warning(f'travel id: {tour_id} && sub: {user_sub} has no travel days.') - return Response({ - "message": "저장된 여행 경로 정보가 없습니다.", - "tour_id": tour_id, - "courses": [] - }, status=status.HTTP_200_OK) - - result = {} # date 별로 그룹화 - - for entry in travel_days: - date_str = str(entry.date) - - if date_str not in result: - result[date_str] = [] - - image_url = "" - image_obj = PlaceImages.objects.filter(place=entry.place).first() - if image_obj: - image_url = image_obj.image_url - - result[date_str].append({ - "name": entry.place.name, - "mapX": entry.place.mapX, - "mapY": entry.place.mapY, - "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": [...] }, ...] - response_data = [ - { - "date": date, - "places": places - } for date, places in result.items() + queryset_object = self.get_queryset().get(id=int(obj_id)) + if queryset_object.user != request.user: + raise PermissionDenied(detail='본인의 사진만 저장할 수 있습니다.') + # 사진 삭제 + queryset_object.image.delete() + except SnapshotImages.DoesNotExist: + raise NoObjectException(error_message='해당 id에 해당하는 사진이 없습니다.') + return super().destroy(request, *args, **kwargs) + +class TourSnapshotsView(BaseImageSaveView): + serializer_class = TourSnapshotsSerializer + queryset = SnapshotImages.objects.all() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ('tour',) + + +class UserTourImageView(BaseImageSaveView): + serializer_class = UserTourImageSerializer + queryset = UserTourImage.objects.all() + filter_backends = (DjangoFilterBackend,) + filterset_fields = ('tour',) + + +class CategoryListView(viewsets.ViewSet): + def retrieve(self, request, *args, **kwargs): + """ + Tour API의 contentTypeId 기반 카테고리 리스트를 반환합니다. + """ + category_list = [ + {"contentTypeId": 12, "name": "관광지"}, + {"contentTypeId": 14, "name": "문화시설"}, + {"contentTypeId": 15, "name": "축제/공연/행사"}, + {"contentTypeId": 28, "name": "레포츠"}, + {"contentTypeId": 32, "name": "숙박"}, + {"contentTypeId": 38, "name": "쇼핑"}, + {"contentTypeId": 39, "name": "음식점"}, ] + return Response(category_list, status=status.HTTP_200_OK) - return Response(response_data, status=status.HTTP_200_OK) - 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() +class PoseRecommendView(viewsets.ViewSet) : + def retrieve(self, request, *args, **kwargs): + place_id = request.GET.get('place_id', None) + if place_id is None: raise NoRequiredParameterException() try: - tour_date = datetime.datetime.strptime(del_date, "%Y-%m-%d") - except ValueError: - raise ValueException( - error_message=f'date: {del_date} is not date format' - ) + place = Place.objects.get(id=int(place_id)) + except Place.DoesNotExist: + raise NoObjectException(error_message='place_id에 해당하는 장소를 찾을 수 없습니다.') + + poses = POSE_MAP.get(str(place.cat2)) # list 형태, 카테고리가 없는 경우, "None"이 키 값으로 들어갑니다. + logger.debug(f'poses: {poses}') + images = POSE_URL_MAP.get(str(place.cat2)) + data = { + 'place_id': place_id, + 'poses': poses, + 'images': images + } + + serializer = PoseRecommendSerializer(data=data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data, status=status.HTTP_200_OK) + +class TodayTravelViewSet(viewsets.ModelViewSet): + """ + 당일 여행에 대한 정보를 주는 API 뷰셋입니다. + 구현 메소드: GET + 들어가야 할 정보: 지역, 여행 인원수, 여행날짜, 사진 업로드 정보, 여행 장소 갯수, 관광타입정보, 여행 이름 + """ + # 유저를 가져오기 위한 로그인 여부 판단 + permission_classes = [IsAuthenticated,] # 로그인이 된 사용자만 접근을 허용합니다. + def list(self, request, *args, **kwargs): + # 1. 당일 여행에 대한 정보를 계산한다. + service = TodayTravelService() + serializer = service.get_today_tour_by_user(request.user) + # 2. 시리얼라이저 데이터를 반환한다. + return Response(serializer.data, status=status.HTTP_200_OK) - 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 - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 - # 사용자가 해당하는 여행 경로들을 모두 조회 - try: - travels = Travel.objects.filter(user__sub=user_sub) # 해당 user의 여행 경로들 - except Travel.DoesNotExist: - raise NoObjectException( - 'No Travel object exists.', - f'sub: {user_sub}의 여행이 존재하지 않습니다.' - ) +class LargeResultsSetPagination(PageNumberPagination): + page_size = 25 + page_size_query_param = 'page_size' + max_page_size = 50 # 최대 50개로 - # 여행 경로들에 대한 결과 리스트 생성 - travel_results = [] - - for travel in travels: - # 여행 경로에 포함된 장소들 조회 - travel_days_and_places = TravelDaysAndPlaces.objects.filter(travel=travel) - - # 장소 리스트 생성 - places = [] - for travel_day_place in travel_days_and_places: - place = travel_day_place.place - places.append({ - "name": place.name, - "mapX": place.mapX, - "mapY": place.mapY, - "image_url": place.placeimages_set.first().image_url if place.placeimages_set.exists() else None - }) - - # 여행 경로 데이터 포맷 - travel_results.append({ - "tour_id": travel.id, - "tour_name": travel.tour_name, - "start_date": str(travel.start_date), - "end_date": str(travel.end_date), - "places": places - }) - - # 최종 응답 반환 - return Response({ - "travels": travel_results - }, status=status.HTTP_200_OK) +class RelationPlaceView(viewsets.ModelViewSet): + queryset = RelationPlace.objects.all() + serializer_class = MiniRelationPlaceSerializer + filter_backends = (DjangoFilterBackend,) + filterset_fields = ('place_name',) + pagination_class = LargeResultsSetPagination \ No newline at end of file diff --git a/usr/admin.py b/usr/admin.py index ecaf9bd..db79b58 100644 --- a/usr/admin.py +++ b/usr/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin -from usr.models import User +from usr.models import User, FCMToken # Register your models here. -admin.site.register(User) \ No newline at end of file +admin.site.register(User) +admin.site.register(FCMToken) \ No newline at end of file diff --git a/usr/models.py b/usr/models.py index 5779628..e0d98ea 100644 --- a/usr/models.py +++ b/usr/models.py @@ -1,5 +1,5 @@ -from django.db import models from django.contrib.auth.models import AbstractUser +from django.db import models # Create your models here. @@ -12,8 +12,23 @@ class User(AbstractUser): profile_image_url = models.URLField() # 프로필 이미지 링크입니다. username = models.CharField(max_length=100) + # 개인정보 취급 동의 시간 + privacy_policy_agree_time = models.DateTimeField(null=True, blank=True) + # 개인정보 취급 동의 여부 + privacy_policy_agree = models.BooleanField(default=False) + # 개인정보 취급 동의서 버전 + privacy_policy_version = models.CharField(max_length=255, null=True, blank=True) + USERNAME_FIELD = 'sub' REQUIRED_FIELDS = ['username'] def __str__(self): # 모델 자체에 이름을 부여합니다. - return self.username \ No newline at end of file + return self.username + +class FCMToken(models.Model): + # id: pk + user = models.ForeignKey(User, on_delete=models.CASCADE) + fcm_token = models.CharField(max_length=500, null=True, blank=True) # 알림을 위한 클라이언트 측 fcm 토큰을 저장합니다. + + def __str__(self): + return f'{self.user.username} - {self.id}' \ No newline at end of file diff --git a/usr/serializers.py b/usr/serializers.py index c8f7244..98c6594 100644 --- a/usr/serializers.py +++ b/usr/serializers.py @@ -1,9 +1,14 @@ from rest_framework import serializers -from .models import User +from .models import User, FCMToken class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['sub', 'username', 'profile_image_url'] + +class FCMTokenSerializer(serializers.ModelSerializer): + class Meta: + model = FCMToken + fields = '__all__' diff --git a/usr/services.py b/usr/services.py index f4059f2..0bd07c9 100644 --- a/usr/services.py +++ b/usr/services.py @@ -1,12 +1,15 @@ -from services.exception_handler import ( - get_my_function, - get_error_line, - ValidationException, - ExceptionHandler, - NoAttributeException -) +import base64 +import json +import logging -from .models import User +import jwt +import requests +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from authenticate.models import OIDC +from config.settings import APP_LOGGER from config.settings import ( KAKAO_TEST_REST_API_KEY, KAKAO_TEST_NATIVE_API_KEY, @@ -14,18 +17,14 @@ KAKAO_REAL_NATIVE_API_KEY, KAKAO_REAL_JAVASCRIPT_KEY, ) +from services.exception_handler import ( + ValidationException, + ExceptionHandler, + NoAttributeException +) from services.kakao_http_client import KakaoHttpClient -import requests -import jwt -import base64 -import json -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization -from authenticate.models import OIDC +from .models import User -from config.settings import APP_LOGGER -import logging logger = logging.getLogger(APP_LOGGER) @@ -216,3 +215,26 @@ def __upload_user(self, raw_data): logger.info(f"회원가입 완료. 회원명: {user.username} (sub:{self.sub})") return user + +class TestUserCreationService: + """ + 테스트 유저 생성을 위한 클래스입니다. + """ + sub = 231215489 # 고정 값입니다. + + def get_or_create_test_user(self): + user = None + is_new = False + try: + user = User.objects.get(sub=self.sub) + except User.DoesNotExist: + user = self.__create_test_user() + is_new = True + + return user, is_new + + def __create_test_user(self): + return User.objects.create( + sub=self.sub, + username='Tester', + ) diff --git a/usr/tests.py b/usr/tests.py index 7945776..5ebbad8 100644 --- a/usr/tests.py +++ b/usr/tests.py @@ -1,9 +1,8 @@ -from django.test import TestCase -from .services import UserService -from config.settings import KAKAO_REFRESH_TOKEN, KAKAO_REST_API_KEY -from .models import User -from services.kakao_token_service import KakaoTokenService from tests.base import BaseTestCase +from .models import User +from .services import UserService + + # Create your tests here. class TestService(BaseTestCase): @@ -11,16 +10,6 @@ def setUp(self): """ 테스트 환경에서 꼭 필요한 데이터를 업로드 하기 위한 메소드 입니다. """ - # 유저 정보 임의 생성 - # 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, @@ -59,6 +48,9 @@ def test_who(self): self.assertEqual(response.status_code, 200) # 200이 맞는지 확인합니다. def test_user_list(self): + """ + 해당 테스트는 쿼리에 포함된 user_name에 검색 결과를 제대로 반환하는지 테스트합니다. + """ end_point = '/user/?user_name=TestUser2' headers = { 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}' diff --git a/usr/urls.py b/usr/urls.py index f9be0f5..73a1f19 100644 --- a/usr/urls.py +++ b/usr/urls.py @@ -1,5 +1,6 @@ from django.urls import path -from .views import Who, UserListView + +from .views import Who, UserListView, UploadFcmTokenView, AgreePrivacyPolicyView urlpatterns = [ path('', UserListView.as_view({ @@ -8,4 +9,10 @@ path('me/',Who.as_view({ 'get' : 'retrieve', }), name='who'), # 토큰 이용한 내 정보 get 매핑 + path('fcm/', UploadFcmTokenView.as_view({ + 'post' : 'create', + })), + path('privacy_policy/', AgreePrivacyPolicyView.as_view({ + 'post': 'create' + })) ] \ No newline at end of file diff --git a/usr/views.py b/usr/views.py index 4986197..8f7cfc2 100644 --- a/usr/views.py +++ b/usr/views.py @@ -1,12 +1,14 @@ -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response +import pytz from rest_framework import status, viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ViewSet -from services.exception_handler import ExceptionHandler, UnExpectedException -from .serializers import UserSerializer - -from usr.models import User +from services.exception_handler import UnExpectedException, NoRequiredParameterException +from usr.models import User, FCMToken +from .serializers import UserSerializer, FCMTokenSerializer +from django.utils import timezone +import datetime class Who(ViewSet): @@ -40,3 +42,54 @@ def get_queryset(self): if user_name is not None: return not_admin_user.filter(username__icontains=user_name) return not_admin_user + +class UploadFcmTokenView(ViewSet): + permission_classes = [IsAuthenticated] + serializer_class = FCMTokenSerializer + + def create(self, request): + """ + 해당 메서드는 사용자의 fcm token을 저장합니다. + """ + try: + fcm = FCMToken.objects.get(fcm_token=request.data['fcm_token']) + # 이미 요청된 fcm 토큰 존재하는 경우 등록 절차 생략 + return Response(status=status.HTTP_201_CREATED) + except FCMToken.DoesNotExist: + pass + fcm_token = request.data.get('fcm_token', None) + if not fcm_token: + raise NoRequiredParameterException( + error_message='fcm_token is required' + ) + data = request.data.copy() + data['user'] = request.user.sub + serializer = FCMTokenSerializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() + """ + fcm token은 매우 중요한 개인정보이기에 유저 정보 자체를 보내지 않습니다. + """ + return Response(status=status.HTTP_201_CREATED) + +class AgreePrivacyPolicyView(ViewSet): + permission_classes = [IsAuthenticated] + + def create(self, request): + privacy_policy_agree_time = timezone.now() + privacy_policy_agree = request.data.get('privacy_policy_agree') + privacy_policy_version = request.data.get('privacy_policy_version') + user = request.user + + seoul_tz = pytz.timezone("Asia/Seoul") + + user.privacy_policy_agree = privacy_policy_agree + user.privacy_policy_agree_time = privacy_policy_agree_time.astimezone(seoul_tz) + user.privacy_policy_version = privacy_policy_version + user.save() + + return Response({ + 'privacy_policy_agree': user.privacy_policy_agree, + 'privacy_policy_agree_time': user.privacy_policy_agree_time, + 'privacy_policy_version': user.privacy_policy_version + },status=status.HTTP_201_CREATED)