diff --git a/config/settings.py b/config/settings.py index 70447d5..3b5c02a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -236,8 +236,8 @@ # simple jwt setting SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), # 토큰 유효시간 설정 1시간으로 설정 - "REFRESH_TOKEN_LIFETIME": timedelta(days=5), # 리프레시 토큰 유효기간 설정 리프레시 토큰 유효기간은 5일로 설정 + "ACCESS_TOKEN_LIFETIME": timedelta(hours=5), # 토큰 유효시간 설정 1시간으로 설정 + "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) diff --git a/docker-compose.yml b/docker-compose.yml index 0f81e23..e11de43 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,9 @@ services: db: image: mysql:latest restart: always -# ports: -# - "3306:3306" + # 배포 땐 아래 포트 막을 것 + ports: + - "3306:3306" environment: MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_DATABASE: ${DB_NAME} diff --git a/mission/views.py b/mission/views.py index 658b484..6b201e2 100644 --- a/mission/views.py +++ b/mission/views.py @@ -18,6 +18,12 @@ get_my_function, NoAttributeException, NoRequiredParameterException, ValueException, UnExpectedException ) +from config.settings import ( + APP_LOGGER +) +import logging + +logger = logging.getLogger(APP_LOGGER) # Create your views here. class MissionListView(viewsets.ModelViewSet): @@ -104,6 +110,10 @@ def create(self, request, *args, **kwargs): similarity_score = None method = "object_detection" + if image_pass == False: # 미션 실패시 S3 사진 삭제 + logger.debug('사진 삭제') + travel_place.mission_image.delete() + return Response({ "image_check_passed": image_pass, "method_used": method, diff --git a/tests/base.py b/tests/base.py index 2367530..d82439a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -5,6 +5,11 @@ from usr.models import User +from config.settings import APP_LOGGER +import logging + +logger = logging.getLogger(APP_LOGGER) + class BaseTestCase(TestCase): is_issued_token = False # 토큰 발급을 하였는가 @@ -21,6 +26,8 @@ def setUpClass(cls): 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): diff --git a/tour/admin.py b/tour/admin.py index 9d459ac..3a892f6 100644 --- a/tour/admin.py +++ b/tour/admin.py @@ -1,9 +1,10 @@ from django.contrib import admin -from .models import Place, Travel, TravelDaysAndPlaces, PlaceImages, Event +from .models import Place, Travel, TravelDaysAndPlaces, PlaceImages, Event, UserTourImages # 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(UserTourImages) \ No newline at end of file diff --git a/tour/models.py b/tour/models.py index c99e290..1ce9783 100644 --- a/tour/models.py +++ b/tour/models.py @@ -1,4 +1,6 @@ from django.db import models +from django.db.models import ForeignKey + from usr.models import User from mission.models import Mission @@ -8,8 +10,9 @@ 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() # 여행 마감 날짜 + # start_date = models.DateField() # 여행 시작 날짜 + # end_date = models.DateField() # 여행 마감 날짜 + tour_date = models.DateField() # 여행 날짜 def __str__(self): return self.tour_name @@ -29,13 +32,13 @@ 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() # 여행 날짜 + # 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 +63,12 @@ class Event(models.Model): def __str__(self): return self.title +class UserTourImages(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}" diff --git a/tour/serializers.py b/tour/serializers.py index ff944bf..fd5d8ec 100644 --- a/tour/serializers.py +++ b/tour/serializers.py @@ -1,6 +1,10 @@ from rest_framework import serializers -from .models import Travel, Place, Event, TravelDaysAndPlaces +from .models import Travel, Place, Event, TravelDaysAndPlaces, PlaceImages, UserTourImages from usr.serializers import UserSerializer +from config.settings import APP_LOGGER +import logging + +logger = logging.getLogger(APP_LOGGER) class TravelSerializer(serializers.ModelSerializer): @@ -10,9 +14,25 @@ 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만 가져옵니다. + # instance는 DB 객체가 들어옴 + # 여행 id, tour_name, tour_date만 들어왔음 + data['places'] = PlaceSerializer( + Place.objects.filter(traveldaysandplaces__travel=instance.id), many=True).data + return data + +class TravelListSerializer(serializers.ModelSerializer): + 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 class EventSerializer(serializers.ModelSerializer): @@ -29,8 +49,24 @@ class Meta: fields = '__all__' class TravelDaysAndPlacesSerializer(serializers.ModelSerializer): - place = PlaceSerializer() # 장소 정보는 시리얼라이저를 통해 반환합니다. + # place = PlaceSerializer() # 장소 정보는 시리얼라이저를 통해 반환합니다. class Meta: model = TravelDaysAndPlaces fields = '__all__' + +class PlaceImageSerializer(serializers.ModelSerializer): + class Meta: + model = PlaceImages + fields = '__all__' + +class TourSnapshotsSerializer(serializers.ModelSerializer): + class Meta: + model = UserTourImages + fields = '__all__' + + def to_representation(self, instance): + # 사진 날짜 보여주기 + data = super().to_representation(instance) + data['tour_date'] = instance.tour.tour_date + return data \ No newline at end of file diff --git a/tour/tests.py b/tour/tests.py index 12ebd7f..79f3903 100644 --- a/tour/tests.py +++ b/tour/tests.py @@ -9,7 +9,7 @@ ContentTypeId, ) from usr.models import User -from .models import Travel +from .models import Travel, Place, PlaceImages from services.kakao_token_service import KakaoTokenService from tests.base import BaseTestCase @@ -17,16 +17,6 @@ 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 user2 = User.objects.create( @@ -86,8 +76,29 @@ def test_travel_api(self): } data = { 'tour_name': '태근이의 여행', - 'start_date': '2025-03-10', - 'end_date': '2025-03-15', + 'tour_date': '2025-07-07', + 'places': [ + { + "name": "아산 공세리성당", + "mapX": "126.9134070332", + "mapY": "36.8833377411", + "image_url": "http://tong.visitkorea.or.kr/cms/resource/17/3095817_image2_1.jpg", + "road_address": "충청남도 아산시 인주면 공세리성당길 10" + }, + { + "name": "아산 공세리", + "mapX": "126.9134070332", + "mapY": "36.8833377411", + "image_url": "", + }, + { + "name": "피나클랜드 수목원", + "mapX": "126.9263450490", + "mapY": "36.8725197718", + "road_address": "충청남도 아산시 영인면 월선길 20-42" + }, + + ] } # 빈 데이터 list get Test response = self.client.get(uri, headers=headers) @@ -96,6 +107,7 @@ def test_travel_api(self): # create test response = self.client.post(uri, data, headers=headers, content_type='application/json') self.assertEqual(response.status_code, 201) + self.assertEqual(Place.objects.count(), 3) # create test - Exception Test exception_data = { @@ -104,12 +116,6 @@ def test_travel_api(self): } response = self.client.post(uri, exception_data, headers=headers, content_type='application/json') self.assertEqual(response.status_code, 400) - - # 인스턴스 임의로 하나 더 생성 - 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) @@ -126,23 +132,45 @@ def test_travel_api(self): response = self.client.get(uri, headers=headers) self.assertEqual(response.status_code, 200) - # put Test - Exception Test - put_data = { - 'tour_name': '시연이의 여행' + # patch Test - Exception Test + patch_data = { + 'tour_name': '시연이의 여행', + 'tour_date': '2025-07-08', + 'places': [ + { + 'id': Place.objects.get(name='아산 공세리성당').id, + 'name': '아산 공세리성당2', + 'image_url': 'https://sports-phinf.pstatic.net/team/kbo/default/LG.png' + }, + { + 'id': Place.objects.get(name='아산 공세리').id, + 'road_address': '도로명주소', + 'address': '충남 아산시' + }, + { + 'id': Place.objects.get(name='피나클랜드 수목원').id, + 'name': '레일', + 'mapX': '126.8673145212', + 'mapY': '36.7610121401', + 'road_address': '도로명주소' + }, + + ] } - response = self.client.put(uri_detail, put_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 404) + # 삭제 된 데이터 다시 넣기 + response = self.client.post(uri, data, headers=headers, content_type='application/json') + self.assertEqual(response.status_code, 201) - # put Test - id2 = Travel.objects.get(tour_name='태근이의 여행2').id + # patch Test + id2 = Travel.objects.get(tour_name='태근이의 여행').id uri_detail = f'/tour/{id2}/' # 아이디 2번 - response = self.client.put(uri_detail, put_data, headers=headers, content_type='application/json') + response = self.client.patch(uri_detail, patch_data, headers=headers, content_type='application/json') self.assertEqual(response.status_code, 200) - # get Test - Exception Test - uri_detail = f'/tour/{id}/' - response = self.client.get(uri_detail, headers=headers) - self.assertEqual(response.status_code, 404) + # No place Parameter Test + patch_data['places'][0].pop('id') + response = self.client.patch(uri_detail, patch_data, headers=headers, content_type='application/json') + self.assertEqual(response.status_code, 400) def test_add_traveler(self): end_point = '/tour/' @@ -151,8 +179,16 @@ def test_add_traveler(self): } data = { 'tour_name': '태근이의 여행', - 'start_date': '2025-03-10', - 'end_date': '2025-03-15', + 'tour_date': '2025-07-07', + 'places': [ + { + "name": "아산 공세리성당", + "mapX": "126.9134070332", + "mapY": "36.8833377411", + "image_url": "http://tong.visitkorea.or.kr/cms/resource/17/3095817_image2_1.jpg", + "road_address": "충청남도 아산시 인주면 공세리성당길 10" + } + ] } response = self.client.post(end_point, headers=headers, data=data, content_type='application/json') @@ -196,251 +232,3 @@ def test_get_area_list(self): end_point = '/tour/get_sido_list/' response = self.client.get(end_point) self.assertEqual(response.status_code, 200) - - - def test_save_course(self): - """ - 해당 테스트는 /tour/course/ 경로 저장 API가 정상적으로 작동하는지 검증합니다. - """ - - # 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.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" - } - ] - } - 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" - } - ] - } - response = self.client.post('/tour/course/', data=wrong_data, headers=headers, content_type='application/json') - self.assertEqual(response.status_code, 404) - - def test_delete_tour_course(self): - """ - 해당 테스트는 내 여행 경로 삭제 API를 검증합니다. - """ - headers = { - 'Authorization': f'Bearer {self.KAKAO_TEST_ACCESS_TOKEN}', - } - - # 여행 생성 - 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) # ✅ 정상적으로 저장되었는지 확인 - - # 삭제 요청 - 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) - - get_response = self.client.get(f'/tour/course/{tour_id}/', headers=headers) - print(get_response.json()) - - def test_retrieve_course(self): - """ - 해당 테스트는 /tour/course// 경로 조회 API가 정상적으로 작동하는지 검증합니다. - """ - - # 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) - - # 3️⃣ 저장한 경로 조회 요청 - retrieve_uri = f'/tour/course/{tour_id}/' - response = self.client.get(retrieve_uri, headers=headers) - self.assertEqual(response.status_code, 200) - - 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): - """ - 해당 테스트는 여행 경로들을 리스트로 가져오는지 테스트합니다. - """ - - # 여행 생성 - 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) - self.assertEqual(response.status_code, 200) - - 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..bd0e1b1 100644 --- a/tour/urls.py +++ b/tour/urls.py @@ -1,15 +1,22 @@ 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 +) urlpatterns = [ - path('', TravelViewSet.as_view({ + path('', NewTourAddView.as_view({ 'get': 'list', 'post': 'create' }), name='travel-list-create'), - path('/', TravelViewSet.as_view({ + path('/', NewTourAddView.as_view({ 'get': 'retrieve', - 'put': 'partial_update', + 'patch': 'partial_update', # 메소드를 일부 업데이트인 patch로 변경 'delete': 'destroy' }), name='travel-detail'), @@ -28,15 +35,13 @@ path('get_sido_list/', Sido_list.as_view({ 'get': 'retrieve' })), - - 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('snapshot/', TourSnapshotsView.as_view({ + 'get': 'list', + 'post': 'create', + })), + path('snapshot//', TourSnapshotsView.as_view({ + 'get': 'retrieve', + 'delete': 'destroy', + })), ] diff --git a/tour/views.py b/tour/views.py index dbe48b8..a1a6e07 100644 --- a/tour/views.py +++ b/tour/views.py @@ -1,47 +1,25 @@ from django.core.exceptions import ValidationError from rest_framework import viewsets, status +from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly from usr.models import User -from .serializers import TravelSerializer, PlaceSerializer, TravelDaysAndPlacesSerializer +from .serializers import TravelSerializer, PlaceSerializer, TravelDaysAndPlacesSerializer, PlaceImageSerializer, \ + TravelListSerializer, TourSnapshotsSerializer 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 +from .models import Travel, Place, PlaceImages, Event, UserTourImages 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) +logger = logging.getLogger(APP_LOGGER) class NearEventView(viewsets.ModelViewSet): @@ -146,242 +124,211 @@ def retrieve(self, request): sido_list = tour.get_sigungu_code_list() return Response(sido_list, status=status.HTTP_200_OK) +class NewTourAddView(viewsets.ModelViewSet): + """ + 해당 뷰는 새로운 여행을 추가하는 뷰를 담당합니다. + 구현 API: + 여행 등록 + 사용자 여행 리스트 조회 + 해당 여행 상세 조회 + 여행 정보 수정(장소 정보 포함) + 여행 삭제 + """ + permission_classes = [IsAuthenticated] + queryset = Travel.objects.all() # 여행 모델에 대한 정보만 가지고 옵니다. + serializer_class = TravelSerializer + place_service = PlaceService(KAKAO_REST_API_KEY) # 주소 저장 서비스 -class CourseView(viewsets.ViewSet): - - def __validate_parameters_in_post(self, tour_id, date, places, user_sub) -> tuple[int, str]: + def save_tdp_place_image(self, tour_id, places_list): """ - 해당 함수는 post 요청이 들어왔을 때 정상적으로 파라미터가 왔는지 검사히기 위한 로직입니다. - 1. places가 리스트 형식인지 확인 - 2. 필수 파라미터가 존재하는지 확인 - 3. 파라미터 중, date 형식이 맞는지 확인 - 4. 실제로 여행 id가 존재하는지 확인 + 해당 함수는 장소들 리스트를 부여받으면 장소, 사진, tdp를 저장해주는 함수입니다. """ - 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의 형식이 올바르지 않습니다." + for each in places_list: + """ + { + name + mapX + mapY + image_url + road_address + address + } + """ + # 파라미터 검증 + if each.get('name', None) is None or each.get('mapX', None) is None or each.get('mapY', None) is None: + raise NoRequiredParameterException(error_message='장소의 필수 파라미터 누락') + + plc_cp_dic = each.copy() + img_url = plc_cp_dic.pop('image_url', None) # 사진 정보는 따로 저장 + road_address_kakao, address_kakao = self.place_service.get_parcel_and_road_address(float(each['mapX']), float(each['mapY'])) + if plc_cp_dic.get('road_address', None) is None: + plc_cp_dic['road_address'] = road_address_kakao + plc_cp_dic['address'] = address_kakao + place_serializer = PlaceSerializer(data=plc_cp_dic) + place_serializer.is_valid(raise_exception=True) + place_serializer.save() + logger.debug(f"장소 저장") + + # 사진 저장 + plc_id = place_serializer.data.get('id') + place = Place.objects.get(id=int(plc_id)) + if img_url is not None and img_url != "": + image_serializer = PlaceImageSerializer(data={ + 'place': int(plc_id), + 'image_url': img_url + }) + image_serializer.is_valid(raise_exception=True) + image_serializer.save() + logger.debug(f"사진 저장") - # 실제로 Travel이 존재하는지 확인합니다. - travel = None - 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 - ) - - # 날짜별 장소 연결 저장 - tdp, _ = TravelDaysAndPlaces.objects.get_or_create( - travel=travel, - place=place, - date=date - ) - - # 이미지가 있을 경우 별도 저장 - if image_url: - PlaceImages.objects.get_or_create( - place=place, - image_url=image_url + tdp_serializer = TravelDaysAndPlacesSerializer(data={ + 'place': int(plc_id), + 'travel': int(tour_id) + }) + tdp_serializer.is_valid(raise_exception=True) + tdp_serializer.save() + logger.debug(f"tdp 저장") + return TravelSerializer(Travel.objects.get(id=tour_id)) + + def get_queryset(self): + logger.debug("queryset 반환 메소드 실행") + return self.queryset.filter(user__sub=self.request.user.sub) + + def retrieve(self, request, *args, **kwargs): + """ + 여행 상세정보 조회 API + """ + tour_id = int(kwargs.get('pk')) + travel_object = Travel.objects.get(id=tour_id) + deserializer = TravelSerializer(travel_object) + return Response(deserializer.data, status=status.HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + tour_id = int(kwargs.get('pk')) + if request.data.get('places', None) is None: return super().partial_update(request, *args, **kwargs) + logger.debug('places_update') + data = request.data.copy() + places_info = data.pop('places') + serializer = TravelSerializer(Travel.objects.get(id=tour_id)) + if len(data) != 0: # 장소 제외 정보가 존재한다면 + # 여행 시리얼라이저 이용해서 저장 + serializer = TravelSerializer(Travel.objects.get(id=tour_id), # 원래 object + data=data, # 요청 data + partial=True) # 일부 업데이트 + serializer.is_valid(raise_exception=True) + serializer.save() + + # self.save_tdp_place_image(tour_id, places_info) + logger.debug('partial 장소 정보 수정 시작') + for each in places_info: + info_data = each.copy() + place_id_str = info_data.get('id', None) + image_url = info_data.pop('image_url', None) + if place_id_str is None: raise NoRequiredParameterException(error_message='각 장소 정보에 장소 id는 필수입니다.') + + place = Place.objects.get(id=int(place_id_str)) # 기존 장소 객체 불러오기 + + mapX = info_data.get('mapX', None) + mapY = info_data.get('mapY', None) + logger.debug('좌표: ' + str(mapX) + ' ' + str(mapY)) + if info_data.get('mapX', None) is not None or info_data.get('mapY', None) is not None: # 좌표 변경 시 + logger.debug('좌표 변경에 따른 주소 변경 시작') + if mapX is None: mapX = place.mapX + if mapY is None: mapY = place.mapY + + road_addr, addr = self.place_service.get_parcel_and_road_address(float(mapX), float(mapY)) + if info_data.get('road_address') is None: info_data['road_address'] = road_addr + info_data['address'] = addr + + + place_serializer = PlaceSerializer(place, data=info_data, partial=True) + place_serializer.is_valid(raise_exception=True) + place_serializer.save() + + if image_url is not None: + logger.debug('partial 사진저장 시작') + place_image_serializer = PlaceImageSerializer( + PlaceImages.objects.get(place_id=int(place_id_str)), + data={"image_url": image_url}, partial=True ) + place_image_serializer.is_valid(raise_exception=True) + place_image_serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) - 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 create(self, request, *args, **kwargs): + """ + 여행 상세등록 API + """ + logger.debug("/tour/ create 메소드 실행") + user_sub = request.user.sub + # 파라미터 유효성 검사 - places만 + if request.data.get('places') is None: + raise NoRequiredParameterException("No Object", "places 정보가 없습니다.") + + # 여행 생성 + cp_dic = request.data.copy() + cp_dic.pop('places') # 장소 정보만 삭제 + serializer = TravelSerializer(data=cp_dic) + serializer.is_valid(raise_exception=True) + travel = serializer.save() + travel.user.add(User.objects.get(sub=user_sub)) # 다대 다 관계시 유저 추가 + logger.debug("여행 생성") + # 장소 생성 + tour_id = serializer.data.get('id') + ser = self.save_tdp_place_image(tour_id, request.data.get('places')) - def retrieve(self, request, pk=None): # 여행 경로 가져오기 API - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 - tour_id = pk + return Response(ser.data, status=status.HTTP_201_CREATED) - # 여행 존재 여부 및 권한 확인 - 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, - }) + def list(self, request, *args, **kwargs): + self.serializer_class = TravelListSerializer + return super().list(request, *args, **kwargs) - # 응답 형태: [{ "date": "YYYY-MM-DD", "places": [...] }, ...] - response_data = [ - { - "date": date, - "places": places - } for date, places in result.items() - ] +class TourSnapshotsView(viewsets.ModelViewSet): + serializer_class = TourSnapshotsSerializer + queryset = UserTourImages.objects.all() + permission_classes = [IsAuthenticated] # 로그인 사용자만 허용 - return Response(response_data, status=status.HTTP_200_OK) + def get_queryset(self): + return self.queryset.filter(user__sub=self.request.user.sub) - 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: + 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() - 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' - ) + data['tour'] = int(data['tour'][0]) + logger.debug('사진 저장 시작') + logger.debug('request: ' + str(data)) - 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) + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + serializer.save() - def list(self, request, *args, **kwargs): # 여행 경로 리스트 조회 API - user_sub = request.user.sub # 액세스 토큰에서 sub 값 가져오기 + return Response(serializer.data, status=status.HTTP_200_OK) - # 사용자가 해당하는 여행 경로들을 모두 조회 + def destroy(self, request, *args, **kwargs): + snapshot_id = kwargs.get('pk') + # 사진 S3에서도 삭제 try: - travels = Travel.objects.filter(user__sub=user_sub) # 해당 user의 여행 경로들 - except Travel.DoesNotExist: - raise NoObjectException( - 'No Travel object exists.', - f'sub: {user_sub}의 여행이 존재하지 않습니다.' - ) - - # 여행 경로들에 대한 결과 리스트 생성 - 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) + snapshot_object = UserTourImages.objects.get(id=int(snapshot_id)) + if snapshot_object.user != request.user: + raise PermissionDenied(detail='본인의 사진만 저장할 수 있습니다.') + # 사진 삭제 + snapshot_object.image.delete() + except UserTourImages.DoesNotExist: + raise NoObjectException(error_message='해당 id에 해당하는 사진이 없습니다.') + return super().destroy(request, *args, **kwargs)