본문 바로가기

인턴 프로젝트

미니 요기요 프로젝트(4) - 날씨별 잘 팔린 메뉴

날씨별 잘 팔린 메뉴

동네의 날씨에 따라 잘 팔린 메뉴를 보여준다.

  • 코드

사용자 주소에서 oo동을 가지고 온다.
grid 테이블에서 해당 동에 맞는 x, y좌표를 가지고 날씨 API 호출을 한다.
응답 값에서 날씨 정보를 받아온다.

날씨와 관계없이 모든 날씨(4개)에 잘 팔린 메뉴를 찾는다.

주문 내역에서 사용자 동, 동의 현재 날씨와 일치하는 레코드들을 가져온다.
날씨와 관계없이 잘 팔린 메뉴는 배제한다.
메뉴명으로 그룹화하고 수량을 센다.
이번, 지난, 지지난 달에 팔린 수량에 계산해서 가중치를 구한다.
가중치 기준으로 정렬한다.

class CategoryNum(enum.IntEnum):
    WEATHER_ID = 14


class WeatherNum(enum.IntEnum):
    SUNNY = 1
    PARTLY_CLOUDY = 2
    CLOUDY = 3
    BLUR = 4


class Month(enum.IntEnum):
    one_month = 1
    two_month = 2
    three_month = 3


class Weight(enum.auto):
    one_month_ago_weight = 0.8
    two_month_ago_weight = 0.6


class MenuListAPIView(View):
    def get(self, request, *args, **kwargs):
        category_id = kwargs['category_id']
        weather_list = [WeatherNum.SUNNY, WeatherNum.PARTLY_CLOUDY,
                        WeatherNum.CLOUDY, WeatherNum.BLUR, ]
        count_duplicated_weather_order = {}
        menu_quantity_to_exclude_or_not = {}
        excluded_menu_count = {}
        excluded_menu_quantity = {}
        this_month = datetime.now().month

        if category_id != CategoryNum.WEATHER_ID:
            return get_restaurants(**kwargs)

        elif category_id == CategoryNum.WEATHER_ID:
            category_id = kwargs['category_id']
            if request.user.is_anonymous:
                return JsonResponse(
                    {
                        "message": "로그인되어 있지 않습니다.",
                    },
                    status=HTTPStatus.UNAUTHORIZED,
                )

            if not request.user.address:
                return JsonResponse(
                    {
                        "message": "사용자 주소가 존재하지 않습니다.",
                    },
                    status=HTTPStatus.BAD_REQUEST,
                )

            user_dong = get_dong(request.user.address)

            if user_dong is None:
                return JsonResponse(
                    {'message': 'mypage에서 주소의 동을 입력하세요.'},
                    status=HTTPStatus.BAD_REQUEST
                )

            x_y_grid = get_x_y_grid(user_dong)
            try:
                x = x_y_grid.x
                y = x_y_grid.y
            except:
                return x_y_grid

            user_dong_weather = get_user_dong_weather(x, y)

            if type(user_dong_weather) != int:
                return user_dong_weather

            for weather in weather_list:
                menu_list = (Cart.objects
                             .prefetch_related('order')
                             .filter(order__address__contains=user_dong, order__weather=weather.value, order__created_time__month__gt=this_month - Month.three_month)
                             .exclude(cartitem__menu__isnull=True)
                             .values('cartitem__menu__name')
                             .annotate(menu_quantity=Sum('cartitem__quantity'), menu=F('cartitem__menu__name'))
                             .order_by('-menu_quantity')[:5]
                             .values('menu', quantity=F('menu_quantity'),
                                     price=F('cartitem__menu__price'), img=F('cartitem__menu__img'),
                                     id=F('cartitem__menu__id'), detail=F('cartitem__menu__detail'),
                                     restaurant=F(
                                         'cartitem__menu__restaurant__id'),
                                     )
                             )
                for menu in menu_list:
                    if count_duplicated_weather_order.get(menu['menu']):
                        count_duplicated_weather_order[menu['menu']] += 1
                        menu_quantity_to_exclude_or_not[menu['menu']] += menu['quantity']
                    else:
                        count_duplicated_weather_order[menu['menu']] = 1
                        menu_quantity_to_exclude_or_not[menu['menu']] = menu['quantity']

            try:
                for menu_name, menu_cnt in count_duplicated_weather_order.items():
                    if menu_cnt >= 4:
                        excluded_menu_count[menu_name] = count_duplicated_weather_order[menu_name]
                        excluded_menu_quantity[menu_name] = menu_quantity_to_exclude_or_not[menu_name]
            except KeyError:
                return HttpResponse(
                    status=HTTPStatus.BAD_REQUEST,
                    content={
                        'message': '사전 key(메뉴명) 에러',
                    }
                )
            menu_list = (Cart.objects
                         .prefetch_related('order')
                         .filter(order__address__contains=user_dong, order__weather=user_dong_weather, order__created_time__month__gt=this_month - Month.three_month)
                         .values('cartitem__menu__name')
                         .annotate(menu=F('cartitem__menu__name'), quantity=Sum('cartitem__quantity'))
                         .exclude(menu__in=excluded_menu_count)
                         .order_by('-quantity')
                         .values('menu', 'quantity',
                                 price=F('cartitem__menu__price'), img=F('cartitem__menu__img'),
                                 id=F('cartitem__menu__id'), detail=F('cartitem__menu__detail'),
                                 restaurant=F(
                                     'cartitem__menu__restaurant__id'),
                                 )
                         )[:10]
            if not menu_list:
                return JsonResponse(
                    {'message': '날씨에 따라 잘 팔린 메뉴가 없습니다.'},
                    status=HTTPStatus.BAD_REQUEST
                )
            weighted_quantity = {}
            for menu in menu_list:
                for month in range(this_month, this_month - Month.three_month, -1):
                    queryset = (Order.objects
                                .filter(cart__cartitem__menu__name=menu['menu'], created_time__month=month,
                                        weather=user_dong_weather)
                                .exclude(cart__isnull=True)
                                .select_related('cart', 'cart__cartitem__menu')
                                .prefetch_related('cart__cartitem')
                                .annotate(name=F('cart__cartitem__menu__name'),
                                          quantity=F('cart__cartitem__quantity'))
                                .values('name', 'quantity', 'created_time__month')
                                )
                    for q in queryset:
                        if month == this_month:
                            if q['name'] not in weighted_quantity:
                                weighted_quantity[q['name']] = q['quantity']
                            else:
                                weighted_quantity[q['name']] += q['quantity']
                        elif month == this_month - Month.one_month:
                            if q['name'] not in weighted_quantity:
                                weighted_quantity[q['name']] = round(
                                    q['quantity'] * Weight.one_month_ago_weight, 2)
                            else:
                                weighted_quantity[q['name']] += round(
                                    q['quantity'] * Weight.one_month_ago_weight, 2)
                        elif month == this_month - Month.two_month:
                            if q['name'] not in weighted_quantity:
                                weighted_quantity[q['name']] = round(
                                    q['quantity'] * Weight.two_month_ago_weight, 2)
                            else:
                                weighted_quantity[q['name']] += round(
                                    q['quantity'] * Weight.two_month_ago_weight, 2)
                if menu['menu'] in weighted_quantity.keys():
                    menu['weighted_quantity'] = weighted_quantity[menu['menu']]
            menu_list = sorted(list(menu_list), key=operator.itemgetter('weighted_quantity'), reverse=True)

            menu_list = list(menu_list, )
            data = {
                "menu_list": menu_list,
                "category_id": category_id,
                "sky_info": user_dong_weather,
                "exclude_menu": excluded_menu_count,
                "exclude_menu_quantity": excluded_menu_quantity,
                "message": "날씨별 메뉴 Top5 성공"
            }
            return JsonResponse(
                data,
            )
  • 테스트 코드
from http import HTTPStatus

from django.test import TestCase, RequestFactory
from django.urls import reverse

from django.contrib.auth import get_user_model
from cart.models import Cart, CartItem
from category.models import Category
from order.models import Order
from restaurant.models import Restaurant
from menu.models import Menu
from grid.models import Grid

User = get_user_model()


class MenuTestClass(TestCase):
    def setUp(self):
        self.request_factory = RequestFactory()

        self.user1 = User.objects.create_user(username='mike', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user2 = User.objects.create_user(username='jake', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user3 = User.objects.create_user(username='fake', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user4 = User.objects.create_user(username='lake', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user5 = User.objects.create_user(username='cake', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user6 = User.objects.create_user(username='cake2', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user7 = User.objects.create_user(username='cake3', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user8 = User.objects.create_user(username='cake4', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user9 = User.objects.create_user(username='cake5', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user10 = User.objects.create_user(username='cake6', email='', password='2',
                                              address='서울시 서초구 서초2동 사랑의 교회 1300호')
        self.user_without_addr = User.objects.create_user(username='james', email='', password='1')
        self.user_without_dong = User.objects.create_user(username='ajax', email='', password='1',
                                                          address='서울시 서초구 100호')
        self.user_without_x_y_dong = User.objects.create_user(username='차이니즈', email='', password='1',
                                                              address='서울시 서초구 베이징3동')
        self.user_with_wrong_x_y = User.objects.create_user(username='xy오류', email='', password='1',
                                                            address='서울시 서초구 서초9동')
        self.user_with_wrong_weather = User.objects.create_user(username='abc', email='', password='1',
                                                            address='서울시 서초구 서초2동')
        self.restaurant = Restaurant.objects.create(
            name='굽내치킨', title='굽내치킨-서초점',
            min_order_price=10000, delivery_charge=1000, estimated_delivery_time='20:00',
            operation_start_hour='11:00', operation_end_hour='20:00', )
        self.menu = Menu.objects.create(
            restaurant=self.restaurant,
            name='볼케이노',
            detail='매콤한 맛입니다.',
            price=20000,
            type='양념류',
            img='test.jpg',
        )
        self.menu2 = Menu.objects.create(
            restaurant=self.restaurant,
            name='갈비천왕',
            detail='갈비 맛입니다.',
            price=20000,
            type='양념류',
            img='test.jpg',
        )
        self.menu3 = Menu.objects.create(
            restaurant=self.restaurant,
            name='허니멜로',
            detail='꿀 맛입니다.',
            price=20000,
            type='양념류',
            img='test.jpg',
        )
        self.cart1 = Cart.objects.create(user=self.user1)
        self.cart2 = Cart.objects.create(user=self.user2)
        self.cart3 = Cart.objects.create(user=self.user3)
        self.cart4 = Cart.objects.create(user=self.user4)
        self.cart5 = Cart.objects.create(user=self.user5)
        self.cart6 = Cart.objects.create(user=self.user6)
        self.cart7 = Cart.objects.create(user=self.user7)
        self.cart8 = Cart.objects.create(user=self.user8)
        self.cart9 = Cart.objects.create(user=self.user9)
        self.cart10 = Cart.objects.create(user=self.user10)
        self.cart_without_user_addr = Cart.objects.create(user=self.user_without_addr)
        self.cart_item1 = CartItem.objects.create(menu=self.menu, cart=self.cart1, quantity=1)
        self.cart_item2 = CartItem.objects.create(menu=self.menu, cart=self.cart2, quantity=2)
        self.cart_item3 = CartItem.objects.create(menu=self.menu, cart=self.cart3, quantity=3)
        self.cart_item4 = CartItem.objects.create(menu=self.menu2, cart=self.cart4, quantity=4)
        self.cart_item5 = CartItem.objects.create(menu=self.menu2, cart=self.cart5, quantity=5)
        self.cart_item6 = CartItem.objects.create(menu=self.menu2, cart=self.cart6, quantity=6)
        self.cart_item7 = CartItem.objects.create(menu=self.menu3, cart=self.cart7, quantity=7)
        self.cart_item8 = CartItem.objects.create(menu=self.menu3, cart=self.cart8, quantity=8)
        self.cart_item9 = CartItem.objects.create(menu=self.menu3, cart=self.cart9, quantity=9)
        self.cart_item10 = CartItem.objects.create(menu=self.menu3, cart=self.cart10, quantity=10)


        self.cart_item_without_user_addr = CartItem.objects.create(menu=self.menu, cart=self.cart_without_user_addr, quantity=1)
        self.category = Category.objects.create(name='치킨', img='restaurant/chicken.jpg')
        self.category_weather = Category.objects.create(id=15, name='날씨별 추천')
        self.grid1 = Grid.objects.create(
            name='서초2동',
            x=61,
            y=125,
        )
        self.grid2 = Grid.objects.create(
            name='서초9동',
            x=32767,
            y=32767,
        )

    def test_menu_list_api_for_weather_should_return_datas_on_valid_request(self):
        '''
        사용자 로그인 성공, 주소 존재, 동 정보 존재, 동에 대한 x, y 좌표 존재, 기상청 API 응답 성공
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.client.login(username='mike', password='2')
        self.order_weather_1 = Order.objects.create(
            user=self.user1,
            cart=self.cart1,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=1,
        )
        self.order_weather_2 = Order.objects.create(
            user=self.user2,
            cart=self.cart2,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=2,
        )
        self.order_weather_3 = Order.objects.create(
            user=self.user3,
            cart=self.cart3,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=3,
        )
        self.order_weather_4 = Order.objects.create(
            user=self.user4,
            cart=self.cart4,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=4,
        )
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.OK)

    def test_menu_list_api_for_weather_should_return_UNAUTHORIZED_on_userless_request(self):
        '''
        AnonymousUser인 경우 401 반환
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        # When
        response = self.client.get(reverse("menu_api:menu_list_api", kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.UNAUTHORIZED)

    def test_menu_list_api_for_weather_should_return_BAD_REQUEST_on_addressless_request(self):
        '''
        사용자 계정은 있지만 주소가 없는 경우 400 반환
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.client.login(username='james', password='1')
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)

    def test_menu_list_api_for_weather_should_return_BAD_REQUEST_on_dongless_request(self):
        '''
        사용자 주소의 동이 없는 경우 400 반환
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.client.login(username='ajax', password='1')
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)

    def test_menu_list_api_for_weather_should_return_BAD_REQUEST_on_xygridless_request(self):
        '''
        Grid 테이블에서 사용자의 동에 대한 x, y 좌표가 없는 경우 400 반환
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.client.login(username='차이니즈', password='1')
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)

    def test_menu_list_api_for_weather_should_return_INTERNAL_SERVER_ERROR_when_meteorological_administration_return_err(
            self):
        '''
        기상청 API 응답에 문제가 있는 경우 500 반환, 기상청에 직접적으로 문제가 있는지 판단하기 어려우므로 x,y 좌표를 큰 값으로 줘서 에러 응답을 만듦.
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.order_weather_1 = Order.objects.create(
            user=self.user1,
            cart=self.cart1,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=1,
        )
        self.order_weather_2 = Order.objects.create(
            user=self.user1,
            cart=self.cart2,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=2,
        )
        self.order_weather_3 = Order.objects.create(
            user=self.user1,
            cart=self.cart3,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=3,
        )
        self.order_weather_4 = Order.objects.create(
            user=self.user1,
            cart=self.cart4,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=0,
            weather=4,
        )
        self.client.login(username='xy오류', password='1')
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)

    def test_return_OK_when_no_order_related_with_weather(self):
        '''
        날씨와 관계 있는 잘 팔린 메뉴가 없는 경우 200 반환
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.order1 = Order.objects.create(
            user=self.user2,
            cart=self.cart2,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=1,
        )
        self.order2 = Order.objects.create(
            user=self.user2,
            cart=self.cart2,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=2,
        )
        self.client.login(username='mike', password='2')
        # When
        response = self.client.get(reverse("menu_api:menu_list_api", kwargs=kwargs))
        # Then
        message = response.json()['message']
        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertEqual(message, '날씨에 따라 잘 팔린 메뉴가 없습니다.')

    def test_return_OK_when_request(self):
        '''
        정상 동작 시 OK
        '''
        # Given
        kwargs = {
            'category_id': self.category_weather.id,
        }
        self.client.login(username='mike', password='2')
        self.order1 = Order.objects.create(
            user=self.user1,
            cart=self.cart1,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=1,
        )
        self.order2 = Order.objects.create(
            user=self.user2,
            cart=self.cart2,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=1,
        )
        self.order3 = Order.objects.create(
            user=self.user3,
            cart=self.cart3,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=2,
        )
        self.order4 = Order.objects.create(
            user=self.user4,
            cart=self.cart4,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=2,
        )
        self.order5 = Order.objects.create(
            user=self.user5,
            cart=self.cart5,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=3,
        )
        self.order6 = Order.objects.create(
            user=self.user6,
            cart=self.cart6,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=3,
        )
        self.order7 = Order.objects.create(
            user=self.user7,
            cart=self.cart7,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=3,
        )
        self.order8 = Order.objects.create(
            user=self.user8,
            cart=self.cart8,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=3,
        )
        self.order9 = Order.objects.create(
            user=self.user9,
            cart=self.cart9,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=4,
        )
        self.order10 = Order.objects.create(
            user=self.user10,
            cart=self.cart10,
            restaurant=self.restaurant,
            address='서울시 서초구 서초2동',
            status=0,
            delivery_status=1,
            weather=4,
        )
        # When
        response = self.client.get(reverse('menu_api:menu_list_api', kwargs=kwargs))
        # Then
        self.assertEqual(response.status_code, HTTPStatus.OK)
  • 어려웠던 점
    • ORM
    • 가중치 계산
    • 특정 키의 값을 기준으로 dict 정렬하는 것
  • 해결방법
    • Order 테이블에서 부터 join을 하려고 했으나 안먹혔다.. 그래서 Cart 테이블부터 시작해서 join했다.
    • 돌머리 싸매면서 계산했다.
    • sorted로 dict 정렬을 했다. sorted 함수에 key 속성에다가 operator.itemgetter(KEY)를 넣으면 KEY의 value를 기준으로 정렬한다.
  • 느낀점
    • ORM을 자유자재로 다루는 것이 매우 중요하다. 여러 테이블을 join시키고 집계함수를 사용해서 값을 계산하는 게 쉽진 않다. ORM이 어떻게 동작하는지 제대로된 원리를 알고 싶다. 이런건 어디서 알지