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)