- 저번 Watcha Classic 프로젝트에서 API 구현한 것을 바탕으로 이번 프로젝트에서는 검색 페이지의 API를 구현하였다.
- 이번에 API의 특징은 크게 2가지가 있다.
query parameter
에 대하여 중복 선택이 되어야 한다.제주시
이거나 서귀포시
인 맛집)
annotate
를 활용하여 평균값, 최솟값, 최댓값을 구했다.Q 객체
의 활용에서&=
이 아닌|=
를 써 보았다.and
가 아닌or
조건이기 때문이다.- 추가로
for 문
을 돌려 중
입력한 예시:
localhost:8000/places/search?category=카페®ion=제주시®ion=서귀포&limit=3&sort=min-price-ascending
from django.urls import path
from places.views import PlaceSearchView
urlpatterns = [
path('/search', PlaceSearchView.as_view())
]
from django.db import models
class Place(models.Model):
name = models.CharField(max_length=100)
address = models.CharField(max_length=200)
phone_number = models.CharField(max_length=50, default='')
opening_hours = models.CharField(max_length=200, default='')
description = models.CharField(max_length=1000, default='')
maximum_number_of_subscriber = models.IntegerField()
latitude = models.DecimalField(max_digits=11, decimal_places=8)
longitude = models.DecimalField(max_digits=11, decimal_places=8)
able_to_reserve = models.BooleanField()
closed_temporarily = models.BooleanField()
category = models.ForeignKey('Category', on_delete=models.CASCADE)
region = models.ForeignKey('Region', on_delete=models.CASCADE)
menus = models.ManyToManyField('Menu', through='PlaceMenu', related_name='places')
class Meta:
db_table = 'places'
class PlaceMenu(models.Model):
menu = models.ForeignKey('Menu', on_delete=models.CASCADE)
place = models.ForeignKey('Place', on_delete=models.CASCADE)
price = models.ForeignKey('Price', on_delete=models.CASCADE)
is_signature = models.BooleanField()
class Meta:
db_table = 'places_menus'
class Menu(models.Model):
name = models.CharField(max_length=50)
class Meta:
db_table = 'menus'
class Price(models.Model):
price = models.DecimalField(max_digits=7, decimal_places=0)
class Meta:
db_table = 'prices'
class Category(models.Model):
name = models.CharField(max_length=50)
class Meta:
db_table = 'categories'
class Image(models.Model):
image_url = models.CharField(max_length=1000)
place = models.ForeignKey('Place', on_delete=models.CASCADE)
class Meta:
db_table = 'images'
class Region(models.Model):
name = models.CharField(max_length=50)
class Meta:
db_table = 'regions'
from django.http import JsonResponse
from django.views import View
from django.db.models import Q, Avg, Min, Max
from places.models import Place
class PlaceSearchView(View):
def get(self, request):
try:
regions = request.GET.getlist('region')
categories = request.GET.getlist('category')
sort = request.GET.get('sort')
offset = int(request.GET.get('offset', 0))
limit = int(request.GET.get('limit', 20))
q = Q()
if regions:
for region in regions:
q |= Q(region__name = region)
if categories:
for category in categories:
q |= Q(category__name = category)
sort_set = {
'avg-price-ascending' : 'avg_price',
'avg-price-descending' : '-avg_price',
'min-price-ascending' : 'min_price',
'max-price-descending' : '-max_price',
'random' : '?',
}
order_key = sort_set.get(sort, 'id')
places = Place.objects.annotate(
min_price = Min('placemenu__price__price'),
max_price = Max('placemenu__price__price'),
avg_price = Avg('placemenu__price__price')
).filter(q).order_by(order_key)[offset:offset+limit]
results = [{
'place_id' : place.id,
'place_name' : place.name,
'place_opening_hours' : place.opening_hours,
'place_maximum_number_of_subscriber' : place.maximum_number_of_subscriber,
'place_able_to_reserve' : place.able_to_reserve,
'place_closed_temporarily' : place.closed_temporarily,
'place_category' : place.category.name,
'place_region' : place.region.name,
'place_image' : place.image_set.all().first().image_url if place.image_set.all() else None,
'menu_name_list' : [menu.name for menu in place.menus.all()],
'menu_price_list' : [menu.price.price for menu in place.placemenu_set.all()],
'menu_is_signature' : [menu.is_signature for menu in place.placemenu_set.all()],
'menu_avg_price' : place.avg_price,
'menu_min_price' : place.min_price,
'menu_max_price' : place.max_price,
} for place in places]
return JsonResponse({'results': results}, status=200)
except KeyError:
return JsonResponse({'message': 'KEY_ERROR'}, status=400)
create
가 아닌 bulk_create
메서드를 이용해 보았다.from django.test import TestCase, Client
from places.models import Place, PlaceMenu, Menu, Price, Category, Image, Region
class PlaceSearchViewTest(TestCase):
def setUp(self):
Menu.objects.bulk_create([
Menu(id = 1, name = '까눌레'),
Menu(id = 2, name = '아메리카노'),
Menu(id = 3, name = '핸드드립'),
Menu(id = 4, name = '흑돼지고기국수'),
Menu(id = 5, name = '돔베고기'),
Menu(id = 6, name = '흑돼지비빔국수'),
])
Price.objects.bulk_create([
Price(id = 6, price = '3000'),
Price(id = 10, price = '5000'),
Price(id = 12, price = '6000'),
Price(id = 16, price = '8000'),
Price(id = 56, price = '28000'),
])
Category.objects.bulk_create([
Category(id = 1, name = '한식'),
Category(id = 5, name = '카페'),
])
Region.objects.bulk_create([
Region(id = 2, name = '애월 · 한림'),
Region(id = 4, name = '성산 · 표선'),
])
Place.objects.bulk_create([
Place(
id = 1,
name = '토투가커피',
address = '제주특별자치도 제주시 한림읍 귀덕9길 19',
phone_number = '010-6886-6121',
opening_hours = '10:00 - 18:00, 연중무휴',
description = '맞아, 까눌레는 이래야지! 라는 말이 절로 나오는 집!',
maximum_number_of_subscriber = '0',
latitude = '33.44283842',
longitude = '126.289942',
able_to_reserve = False,
closed_temporarily = False,
category_id = '5',
region_id = '2',
),
Place(
id = 2,
name = '꽃가람',
address = '제주특별자치도 서귀포시 성산읍 고성동서로 73',
phone_number = '064-783-3939',
opening_hours = '09:10 - 20:00, 매 주 목요일 휴무',
description = '담백하고 깔끔한 고기국수를 맛볼 수 있는 곳!',
maximum_number_of_subscriber = '6',
latitude = '33.45089738',
longitude = '126.9149578',
able_to_reserve = True,
closed_temporarily = False,
category_id = '1',
region_id = '4',
)
])
PlaceMenu.objects.bulk_create([
PlaceMenu(menu_id = 1, place_id = 1, price_id = 6, is_signature = True),
PlaceMenu(menu_id = 2, place_id = 1, price_id = 10, is_signature = True),
PlaceMenu(menu_id = 3, place_id = 1, price_id = 12, is_signature = False),
PlaceMenu(menu_id = 4, place_id = 2, price_id = 16, is_signature = True),
PlaceMenu(menu_id = 5, place_id = 2, price_id = 56, is_signature = True),
PlaceMenu(menu_id = 6, place_id = 2, price_id = 16, is_signature = False),
])
Image.objects.bulk_create([
Image(image_url = 'https://mustgo.carmore.kr/data/file/matzip/977797506_dMG6qOYk_bbb37afa85c4c5bf67abe3e40aea39f356c852bf.jpeg', place_id = 1),
Image(image_url = 'https://mustgo.carmore.kr/data/file/matzip/977797506_TEPcAJMy_8808aea72589ba335d85f349ce8729bbb4144445.jpeg', place_id = 2),
])
def tearDown(self):
Menu.objects.all().delete()
Price.objects.all().delete()
Category.objects.all().delete()
Region.objects.all().delete()
Place.objects.all().delete()
PlaceMenu.objects.all().delete()
Image.objects.all().delete()
def test_success_place_search_get(self):
client = Client()
response = client.get('/places/search')
self.assertEqual(response.json(),{
"results" : [
{
"place_id": 1,
"place_name": "토투가커피",
"place_opening_hours": "10:00 - 18:00, 연중무휴",
"place_maximum_number_of_subscriber": 0,
"place_able_to_reserve": False,
"place_closed_temporarily": False,
"place_category": "카페",
"place_region": "애월 · 한림",
"place_image": "https://mustgo.carmore.kr/data/file/matzip/977797506_dMG6qOYk_bbb37afa85c4c5bf67abe3e40aea39f356c852bf.jpeg",
"menu_name_list": [
"까눌레",
"아메리카노",
"핸드드립"
],
"menu_price_list": [
"3000",
"5000",
"6000"
],
"menu_is_signature": [
True,
True,
False
],
"menu_avg_price": "4666.6667",
"menu_min_price": "3000",
"menu_max_price": "6000"
},
{
"place_id": 2,
"place_name": "꽃가람",
"place_opening_hours": "09:10 - 20:00, 매 주 목요일 휴무",
"place_maximum_number_of_subscriber": 6,
"place_able_to_reserve": True,
"place_closed_temporarily": False,
"place_category": "한식",
"place_region": "성산 · 표선",
"place_image": "https://mustgo.carmore.kr/data/file/matzip/977797506_TEPcAJMy_8808aea72589ba335d85f349ce8729bbb4144445.jpeg",
"menu_name_list": [
"흑돼지고기국수",
"돔베고기",
"흑돼지비빔국수"
],
"menu_price_list": [
"8000",
"28000",
"8000"
],
"menu_is_signature": [
True,
True,
False
],
"menu_avg_price": "14666.6667",
"menu_min_price": "8000",
"menu_max_price": "28000"
}
]
})
self.assertEqual(response.status_code, 200)
def test_success_place_search_get_filtered_by_categories(self):
client = Client()
response = client.get('/places/search?category=카페')
self.assertEqual(response.json(),{
"results" : [
{
"place_id": 1,
"place_name": "토투가커피",
"place_opening_hours": "10:00 - 18:00, 연중무휴",
"place_maximum_number_of_subscriber": 0,
"place_able_to_reserve": False,
"place_closed_temporarily": False,
"place_category": "카페",
"place_region": "애월 · 한림",
"place_image": "https://mustgo.carmore.kr/data/file/matzip/977797506_dMG6qOYk_bbb37afa85c4c5bf67abe3e40aea39f356c852bf.jpeg",
"menu_name_list": [
"까눌레",
"아메리카노",
"핸드드립"
],
"menu_price_list": [
"3000",
"5000",
"6000"
],
"menu_is_signature": [
True,
True,
False
],
"menu_avg_price": "4666.6667",
"menu_min_price": "3000",
"menu_max_price": "6000"
}
]
})
self.assertEqual(response.status_code, 200)
def test_success_place_search_get_filtered_by_regions(self):
client = Client()
response = client.get('/places/search?region=성산 · 표선')
self.assertEqual(response.json(),{
"results" : [
{
"place_id": 2,
"place_name": "꽃가람",
"place_opening_hours": "09:10 - 20:00, 매 주 목요일 휴무",
"place_maximum_number_of_subscriber": 6,
"place_able_to_reserve": True,
"place_closed_temporarily": False,
"place_category": "한식",
"place_region": "성산 · 표선",
"place_image": "https://mustgo.carmore.kr/data/file/matzip/977797506_TEPcAJMy_8808aea72589ba335d85f349ce8729bbb4144445.jpeg",
"menu_name_list": [
"흑돼지고기국수",
"돔베고기",
"흑돼지비빔국수"
],
"menu_price_list": [
"8000",
"28000",
"8000"
],
"menu_is_signature": [
True,
True,
False
],
"menu_avg_price": "14666.6667",
"menu_min_price": "8000",
"menu_max_price": "28000"
}
]
})
self.assertEqual(response.status_code, 200)
'place_image' : place.image_set.all().first().image_url if place.image_set.all() else None,
MySQL
에 넣은 식당에 대한 이미지는 1개밖에 존재하지 않는다. 하지만 추후에 식당에 여러 image_url
을 넣어야 하기에 get
으로는 안될 것 같다.views.py
에서 하나만 넣기 위해 first()
를 활용하여 첫 번째image_url
를 가져 왔다. 그런데 만약에 아예 존재하지 않는다면?삼항 연산자
를 썼다.menu
에 대해서도 image_url
를 넣을 걸 그랬나...test_fail
을 구현해 본 적이 없어 무지의 공포가 있는 것 같다.Place.objects.annotate(
min_price = Min('placemenu__price__price'),
max_price = Max('placemenu__price__price'),
avg_price = Avg('placemenu__price__price')
).filter(q).order_by(order_key)[offset:offset+limit]
annotate
에 집중하여 API를 구현했는데 aggregate
를 활용하여 구할 수는 없을까?aggregate
는 필드 전체에 대한 값을 구하는 것인데, 어떻게 활용하면 좋을까?