[Bob Morgan] 검색 페이지 API 구현

이태권 (Taekwon Lee)·2022년 7월 11일
0

[Project] Bob Morgan

목록 보기
4/6
post-thumbnail

[밥 먹언] 검색 페이지 API 구현

❓ 검색 페이지 API 특징

Frontend 측 검색 페이지 초안

Backend에서 구현해야 할 검색 API 특징

  1. 맛집의 메뉴 가격에 대한 평균값, 최솟값, 최댓값을 구해야 한다.
  2. 같은 종류의 query parameter에 대하여 중복 선택이 되어야 한다.
    (예시: 지역이 제주시이거나 서귀포시인 맛집)

❗️ 검색 페이지 API 구현

  1. annotate를 활용하여 평균값, 최솟값, 최댓값을 구했다.
  2. Q 객체의 활용에서 &=이 아닌 |=를 써 보았다. and가 아닌 or 조건이기 때문이다.
  3. 추가로 for 문을 돌려 중

🎉 API (성공)

Postman

입력한 예시:
localhost:8000/places/search?category=카페&region=제주시&region=서귀포&limit=3&sort=min-price-ascending

  1. 카테고리가 카페인 맛집
  2. 지역이 제주시이거나 서귀포인 맛집
  3. 개수는 3개만
  4. 메뉴의 최소 가격 오름차순 정렬

🎉 Unit Test 결과 (성공)

python manage.py test places



🖥 Code

places/urls.py

from django.urls import path

from places.views import PlaceSearchView

urlpatterns = [
    path('/search', PlaceSearchView.as_view())
]

places/models.py

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'


places/views.py

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)


places/test.py

  • 유닛 테스트를 진행할 때는 생성해야 할 mocked data가 많아 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)

📝 부족한 점

1. 식당의 image_url (places/views.py)

'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를 가져 왔다. 그런데 만약에 아예 존재하지 않는다면?
    • 그래서 메뉴에 이미지가 아예 없을 수도 있으니 삼항 연산자를 썼다.
  1. 여기서 더 나은 방법으로 구현할 수는 없을까?
  2. 모델링에서 menu에 대해서도 image_url를 넣을 걸 그랬나...

2. Unit Test에서 fail을 넣지 않음 (places/tests.py)

  • 성공적으로 구현이 되었을 때만 코드를 구현하여, 예외 처리에 대한 테스트가 부족하다.
  • 사실 test_fail을 구현해 본 적이 없어 무지의 공포가 있는 것 같다.
  • 팀원에 백엔드가 필자 혼자이기에, 시간 관계 상 여러 API를 혼자 구현해야 하는데 어떡하면 좋을까...

3. aggregate의 사용? (places/views.py)

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]
  • 이번 API는 annotate에 집중하여 API를 구현했는데 aggregate를 활용하여 구할 수는 없을까?
  • 내가 알기로는 aggregate는 필드 전체에 대한 값을 구하는 것인데, 어떻게 활용하면 좋을까?

🔖 참고 자료

profile
(Backend Dev.) One step at a time

0개의 댓글