diff --git a/app.py b/app.py index f3bf1ec..898f24d 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,14 @@ -from datetime import datetime +from datetime import date, datetime, timedelta +from decimal import Decimal +from collections import Counter, defaultdict +import itertools +import operator -from flask import Flask +from flask import Flask, request from flask_restplus import Api, Resource from sqlalchemy import and_, func -from models import Bookings, HotelRooms, Hotels +from models import BlockedRooms, Bookings, HotelRooms, Hotels from utils import get_session app = Flask(__name__) @@ -33,7 +37,7 @@ def get(self, hotelroom_id, start_date, end_date): hotelroom = session.query(HotelRooms).get(hotelroom_id) # get number of bookings and cancellations - num_of_bookings = session.query(func.count(Bookings)).filter( + num_of_bookings = session.query(func.count(Bookings.id)).filter( and_( Bookings.hotelroom_id == hotelroom_id, Bookings.reserved_night_date.between( @@ -41,8 +45,8 @@ def get(self, hotelroom_id, start_date, end_date): ), Bookings.row_type == 'booking' ) - ).all() - num_of_cancellations = session.query(func.Count(Bookings)).filter( + ).scalar() + num_of_cancellations = session.query(func.Count(Bookings.id)).filter( and_( Bookings.hotelroom_id == hotelroom_id, Bookings.reserved_night_date.between( @@ -50,10 +54,22 @@ def get(self, hotelroom_id, start_date, end_date): ), Bookings.row_type == 'cancellations' ) - ).all() + ).scalar() + num_of_blocked_rooms = session.query(func.Sum(BlockedRooms.rooms)).filter( + and_( + BlockedRooms.hotelroom_id == hotelroom_id, + BlockedRooms.reserved_night_date.between( + start_date, end_date + ), + ) + ).scalar() + + num_of_bookings = num_of_bookings or 0 + num_of_cancellations = num_of_cancellations or 0 + num_of_blocked_rooms = num_of_blocked_rooms or 0 # calculate numerator and denominator for occupancy - net_bookings = num_of_bookings - num_of_cancellations + net_bookings = num_of_blocked_rooms + num_of_bookings - num_of_cancellations total_available_rooms = hotelroom.capacity * ((end_date - start_date).days + 1) # check to make sure total_available_rooms is not 0 (division by zero error) @@ -86,14 +102,74 @@ def get(self, hotelroom_id, reserved_night_date): # get a database session session = get_session() - occupancy = [] - revenue_booking_curve = [] + # get the hotelroom object to calculate capacity + hotelroom = session.query(HotelRooms).get(hotelroom_id) + + days = request.args.get("days", 90) + today = date.today() + start_date = today - timedelta(days=days-1) + + # bookings for the given room made prior the curve start date + prior_occupancy, prior_revenue = session.query( + func.count(Bookings.id), + func.sum(Bookings.price), + ).filter( + and_( + Bookings.hotelroom_id == hotelroom_id, + Bookings.reserved_night_date == reserved_night_date, + Bookings.booking_datetime < start_date, + ) + ).one() + + # bookings for the given room made within the curve date range + bookings = session.query( + Bookings.booking_datetime, + Bookings.price, + ).filter( + and_( + Bookings.hotelroom_id == hotelroom_id, + Bookings.reserved_night_date == reserved_night_date, + Bookings.booking_datetime >= start_date + ) + ).all() - # write code here for Question 2 + # get occupancy and revenue per day out of existing bookings + occupancy_per_day = Counter( + [booking.booking_datetime.date() for booking in bookings] + ) + revenue_per_day = defaultdict(Decimal) + for booking in bookings: + revenue_per_day[booking.booking_datetime.date()] += booking.price + + # occupancy and revenue per each day of the range + # (including days with no bookings) + occupancy_per_day = [ + occupancy_per_day.get(today - timedelta(days=day), 0) + for day in reversed(range(days)) + ] + revenue_per_day = [ + revenue_per_day.get(today - timedelta(days=day), 0) + for day in reversed(range(days)) + ] + + # account for prior occupancy and revenue + occupancy_per_day[0] += prior_occupancy + revenue_per_day[0] += prior_revenue + + # accumulate occupancy and calculate percentage + occupancy_percentage = [ + str(round(occupancy * 100 / hotelroom.capacity, 2)) + for occupancy + in itertools.accumulate(occupancy_per_day, func=operator.add) + ] + # accumulate revenue + revenue_booking_curve = list( + itertools.accumulate(revenue_per_day, func=operator.add) + ) return { 'booking_curve': { - "occupancy": occupancy, + "occupancy": occupancy_percentage, "revenue": revenue_booking_curve } } diff --git a/models.py b/models.py index 17eb837..7c68929 100644 --- a/models.py +++ b/models.py @@ -40,3 +40,15 @@ class Bookings(Base): row_type = Column(Text, nullable=False) price = Column(Numeric(10, 2), nullable=False) + + +class BlockedRooms(Base): + __tablename__ = 'blockedrooms' + id = Column(Integer, primary_key=True) + + hotelroom_id = Column(Integer, ForeignKey('hotelrooms.id'), nullable=False, index=True) + hotelroom = relationship("HotelRooms", primaryjoin=hotelroom_id == HotelRooms.id) + + reserved_night_date = Column(Date, nullable=False) + + rooms = Column(Integer, nullable=False) diff --git a/test.py b/test.py new file mode 100644 index 0000000..9eff1c8 --- /dev/null +++ b/test.py @@ -0,0 +1,230 @@ +from datetime import date +from decimal import Decimal +import itertools +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +import app, models + + +@pytest.fixture +def db_connection(): + + engine = create_engine( + # TODO make this configurable (for both the fixture and the app) + "postgresql://prix:prix@localhost:5432/interview" + ) + + models.Base.metadata.create_all(bind=engine) + + yield engine.connect() + + # TODO properly close the scoped session usedd by the app + from utils import _SESSION + if _SESSION: + _SESSION.close() + + models.Base.metadata.reflect(bind=engine) + models.Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def db_session(db_connection): + Session = sessionmaker(bind=db_connection) + session = Session() + yield session + session.close() + + +@pytest.fixture +def hotel(db_session): + hotel = models.Hotels(id=1, name="Intercontinental") + db_session.add(hotel) + db_session.commit() + return hotel + + +@pytest.fixture +def hotelroom(db_session, hotel): + hotelroom = models.HotelRooms( + id=1, + hotel_id=hotel.id, + name="Queen Suite", + capacity=10, + ) + db_session.add(hotelroom) + db_session.commit() + return hotelroom + + +@pytest.fixture +def make_booking(db_session, hotelroom): + def make(**overrides): + booking_data = dict( + id=1, + hotelroom_id=hotelroom.id, + reserved_night_date=date(2018, 12, 26), + booking_datetime=date(2018, 12, 26), + row_type="booking", + price=Decimal("100.00"), + ) + booking_data.update(overrides) + return models.Bookings(**booking_data) + return make + + +@pytest.fixture +def bookings(db_session, make_booking): + bookings = [make_booking(id=i) for i in range(1, 7)] + db_session.add_all(bookings) + db_session.commit() + return bookings + + +def test_occupancy(bookings, hotelroom): + + start_date = "2018-12-26" + end_date = "2018-12-26" + + response = app.OccupancyEndpoint().get( + hotelroom.id, start_date, end_date + ) + + assert response["occupancy"] == "60.0" + + +def test_occupancy_with_blocked_rooms( + db_session, bookings, hotelroom, make_booking +): + + start_date = "2018-12-26" + end_date = "2018-12-26" + + blocked_rooms = models.BlockedRooms( + id=1, + hotelroom_id=hotelroom.id, + reserved_night_date=date(2018, 12, 26), + rooms=4 + ) + db_session.add(blocked_rooms) + db_session.commit() + + response = app.OccupancyEndpoint().get( + hotelroom.id, start_date, end_date + ) + + assert response["occupancy"] == "100.0" + + +def test_occupancy_with_date_range(): + pass + + +@pytest.fixture +def request(): + with patch("app.request") as request: + yield request + + +def test_booking_curve_occupancy( + db_session, request, hotelroom, make_booking +): + # TODO add also bookings for different room - should be filtered out + + # for testing shorten the 90 days default + request.args = {"days": 5} + + reserved_night_date = date(2018, 12, 26) + + ids = itertools.count() + bookings = [] + + def make_bookings(n, **overrides): + bookings = [ + make_booking( + id=next(ids), + reserved_night_date=reserved_night_date, + **overrides + ) for i in range(n) + ] + db_session.add_all(bookings) + db_session.commit() + + # bookings prior the curve date range + make_bookings(2, booking_datetime=date(2018, 12, 21)) + + # bookings within the curve date range + make_bookings(4, booking_datetime=date(2018, 12, 23)) + make_bookings(1, booking_datetime=date(2018, 12, 24)) + make_bookings(3, booking_datetime=date(2018, 12, 26)) + + response = app.BookingCurveEndpoint().get( + hotelroom.id, reserved_night_date + ) + + expected_occupancy_curve = ["20.0", "60.0", "70.0", "70.0", "100.0"] + + assert response["booking_curve"]["occupancy"] == expected_occupancy_curve + + +def test_booking_curve_revenue( + db_session, request, hotelroom, make_booking +): + # TODO add also bookings for different room - should be filtered out + + # for testing shorten the 90 days default + request.args = {"days": 5} + + reserved_night_date = date(2018, 12, 26) + + ids = itertools.count() + bookings = [] + + def make_bookings(prices, **overrides): + bookings = [ + make_booking( + id=next(ids), + reserved_night_date=reserved_night_date, + price=price, + **overrides + ) for price in prices + ] + db_session.add_all(bookings) + db_session.commit() + + # bookings prior the curve date range + make_bookings( + ("100.0", "200.0"), + booking_datetime=date(2018, 12, 21) + ) + + # bookings within the curve date range + make_bookings( + ("100.0", "100.0", "100.0", "100.0"), + booking_datetime=date(2018, 12, 23) + ) + make_bookings( + ("100.0",), + booking_datetime=date(2018, 12, 24) + ) + make_bookings( + ("100.0", "100.0"), + booking_datetime=date(2018, 12, 26) + ) + + response = app.BookingCurveEndpoint().get( + hotelroom.id, reserved_night_date + ) + + expected_revenue_curve = [ + Decimal("300.00"), + Decimal("700.00"), + Decimal("800.00"), + Decimal("800.00"), + Decimal("1000.00") + ] + + assert response["booking_curve"]["revenue"] == expected_revenue_curve