diff --git a/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py b/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py new file mode 100644 index 0000000..81c8b67 --- /dev/null +++ b/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py @@ -0,0 +1,413 @@ +"""Add simulation data. + +Revision: # 81aa304d7a6e at 2021-09-14 13:47:03 +Revises: # b4dd0b8903a5 +""" + +import os + +import sqlalchemy as sa +from alembic import op + +from urban_meal_delivery import configuration + + +revision = '81aa304d7a6e' +down_revision = 'b4dd0b8903a5' +branch_labels = None +depends_on = None + + +config = configuration.make_config('testing' if os.getenv('TESTING') else 'production') + + +def upgrade(): + """Upgrade to revision 81aa304d7a6e.""" + # Drop unnecessary check constraint. + op.execute( # `.delivery_at` is not set for `.cancelled` orders anyways. + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT ck_orders_on_ordered_timestamps_21; + """, + ) # noqa:WPS355 + + # Add forgotten check constraints to the `orders` table. + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at + CHECK (placed_at < pickup_at); + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour + CHECK ( + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + ); + """, + ) # noqa:WPS355 + + # This `UniqueConstraint` is needed by the `replayed_orders` table below. + op.create_unique_constraint( + 'uq_orders_on_id_ad_hoc', + 'orders', + ['id', 'ad_hoc'], + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'replay_simulations', + sa.Column('id', sa.Integer, autoincrement=True), + sa.Column('city_id', sa.SmallInteger, nullable=False), + sa.Column('strategy', sa.Unicode(length=100), nullable=False), + sa.Column('run', sa.SmallInteger, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_replay_simulations')), + sa.ForeignKeyConstraint( + ['city_id'], + [f'{config.CLEAN_SCHEMA}.cities.id'], + name=op.f('fk_replay_simulations_to_cities_via_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.UniqueConstraint( + 'city_id', + 'strategy', + 'run', + name=op.f('uq_replay_simulations_on_city_id_strategy_run'), + ), + sa.CheckConstraint('run >= 0', name=op.f('run_is_a_count')), + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'replayed_orders', + sa.Column('simulation_id', sa.Integer, primary_key=True), + sa.Column('actual_order_id', sa.Integer, primary_key=True), + sa.Column('ad_hoc', sa.Boolean, nullable=False), + sa.Column('placed_at', sa.DateTime, nullable=False), + sa.Column('scheduled_delivery_at', sa.DateTime), + sa.Column('cancelled_at', sa.DateTime), + sa.Column('estimated_prep_duration', sa.SmallInteger), + sa.Column('restaurant_notified_at', sa.DateTime), + sa.Column('restaurant_confirmed_at', sa.DateTime), + sa.Column('restaurant_ready_at', sa.DateTime), + sa.Column('dispatch_at', sa.DateTime), + sa.Column('first_estimated_delivery_at', sa.DateTime), + sa.Column('courier_id', sa.Integer), + sa.Column('courier_notified_at', sa.DateTime), + sa.Column('courier_accepted_at', sa.DateTime), + sa.Column('utilization', sa.SmallInteger), + sa.Column('reached_pickup_at', sa.DateTime), + sa.Column('pickup_at', sa.DateTime), + sa.Column('left_pickup_at', sa.DateTime), + sa.Column('reached_delivery_at', sa.DateTime), + sa.Column('delivery_at', sa.DateTime), + sa.PrimaryKeyConstraint( + 'simulation_id', 'actual_order_id', name=op.f('pk_replayed_orders'), + ), + sa.ForeignKeyConstraint( + ['simulation_id'], + [f'{config.CLEAN_SCHEMA}.replay_simulations.id'], + name=op.f('fk_replayed_orders_to_replay_simulations_via_simulation_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + # Needs the `UniqueConstraint` from above. + ['actual_order_id', 'ad_hoc'], + [ + f'{config.CLEAN_SCHEMA}.orders.id', + f'{config.CLEAN_SCHEMA}.orders.ad_hoc', + ], + name=op.f('fk_replayed_orders_to_orders_via_actual_order_id_ad_hoc'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['courier_id'], + [f'{config.CLEAN_SCHEMA}.couriers.id'], + name=op.f('fk_replayed_orders_to_couriers_via_courier_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.CheckConstraint( + """ + ( + ad_hoc IS TRUE + AND + scheduled_delivery_at IS NULL + ) + OR + ( + ad_hoc IS FALSE + AND + scheduled_delivery_at IS NOT NULL + ) + """, + name=op.f('either_ad_hoc_or_scheduled_order'), + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS TRUE + AND ( + EXTRACT(HOUR FROM placed_at) < 11 + OR + EXTRACT(HOUR FROM placed_at) > 22 + ) + ) + """, + name=op.f('ad_hoc_orders_within_business_hours'), + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS FALSE + AND ( + ( + EXTRACT(HOUR FROM scheduled_delivery_at) <= 11 + AND + NOT ( + EXTRACT(HOUR FROM scheduled_delivery_at) = 11 + AND + EXTRACT(MINUTE FROM scheduled_delivery_at) = 45 + ) + ) + OR + EXTRACT(HOUR FROM scheduled_delivery_at) > 22 + ) + ) + """, + name=op.f('scheduled_orders_within_business_hours'), + ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name=op.f('scheduled_orders_must_be_at_quarters_of_an_hour'), + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800 + ) + """, + name=op.f('scheduled_orders_not_within_30_minutes'), + ), + sa.CheckConstraint( + """ + cancelled_at IS NOT NULL + OR + ( + restaurant_notified_at IS NOT NULL + AND + restaurant_confirmed_at IS NOT NULL + AND + restaurant_ready_at IS NOT NULL + AND + dispatch_at IS NOT NULL + AND + courier_id IS NOT NULL + AND + courier_notified_at IS NOT NULL + AND + courier_accepted_at IS NOT NULL + AND + reached_pickup_at IS NOT NULL + AND + pickup_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name=op.f('orders_must_be_either_cancelled_or_fully_simulated'), + ), + sa.CheckConstraint( + """ + NOT ( -- Only occurred in 528 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + pickup_at IS NOT NULL + ) + AND + NOT ( -- Only occurred in 176 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name=op.f('cancellations_may_only_occur_before_pickup'), + ), + sa.CheckConstraint( + '0 <= estimated_prep_duration AND estimated_prep_duration <= 3600', + name=op.f('estimated_prep_duration_between_0_and_3600'), + ), + sa.CheckConstraint( + 'estimated_prep_duration % 60 = 0', + name=op.f('estimated_prep_duration_must_be_whole_minutes'), + ), + sa.CheckConstraint( + '0 <= utilization AND utilization <= 100', + name=op.f('utilization_between_0_and_100'), + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(HOUR FROM restaurant_notified_at) < 11 + OR + EXTRACT(HOUR FROM dispatch_at) < 11 + ) + """, + name=op.f('orders_dispatched_in_business_hours'), + ), + *( + sa.CheckConstraint( + constraint, name='ordered_timestamps_{index}'.format(index=index), + ) + for index, constraint in enumerate( + ( + 'placed_at < scheduled_delivery_at', + 'placed_at < cancelled_at', + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'cancelled_at > restaurant_notified_at', + 'cancelled_at > restaurant_confirmed_at', + 'cancelled_at > restaurant_ready_at', + 'cancelled_at > dispatch_at', + 'cancelled_at > courier_notified_at', + 'cancelled_at > courier_accepted_at', + 'cancelled_at > reached_pickup_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ), + ) + ), + schema=config.CLEAN_SCHEMA, + ) + + op.create_index( + op.f('ix_replay_simulations_on_city_id'), + 'replay_simulations', + ['city_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_replay_simulations_on_strategy'), + 'replay_simulations', + ['strategy'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_replayed_orders_on_courier_id'), + 'replayed_orders', + ['courier_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + + +def downgrade(): + """Downgrade to revision b4dd0b8903a5.""" + op.drop_table('replayed_orders', schema=config.CLEAN_SCHEMA) + op.drop_table('replay_simulations', schema=config.CLEAN_SCHEMA) + op.drop_constraint( + 'uq_orders_on_id_ad_hoc', 'orders', type_=None, schema=config.CLEAN_SCHEMA, + ) + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour; + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at; + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT ck_orders_on_ordered_timestamps_21 + CHECK (cancelled_at > delivery_at); + """, + ) # noqa:WPS355 diff --git a/setup.cfg b/setup.cfg index df965ca..4d571dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -111,6 +111,8 @@ extend-ignore = # Let's be modern: The Walrus is ok. WPS332, # Let's not worry about the number of noqa's. + # Multi-line loops are ok. + WPS352, WPS402, # Putting logic into __init__.py files may be justified. WPS412, diff --git a/src/urban_meal_delivery/db/__init__.py b/src/urban_meal_delivery/db/__init__.py index 6161657..7f69d6d 100644 --- a/src/urban_meal_delivery/db/__init__.py +++ b/src/urban_meal_delivery/db/__init__.py @@ -14,4 +14,6 @@ from urban_meal_delivery.db.grids import Grid from urban_meal_delivery.db.meta import Base from urban_meal_delivery.db.orders import Order from urban_meal_delivery.db.pixels import Pixel +from urban_meal_delivery.db.replay import ReplayedOrder +from urban_meal_delivery.db.replay import ReplaySimulation from urban_meal_delivery.db.restaurants import Restaurant diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index e646046..dd8b734 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -39,6 +39,7 @@ class City(meta.Base): # Relationships addresses = orm.relationship('Address', back_populates='city') grids = orm.relationship('Grid', back_populates='city') + replays = orm.relationship('ReplaySimulation', back_populates='city') # We do not implement a `.__init__()` method and use SQLAlchemy's default. # The uninitialized attribute `._map` is computed on the fly. note:d334120ei diff --git a/src/urban_meal_delivery/db/orders.py b/src/urban_meal_delivery/db/orders.py index 1b1341b..4c4a2f7 100644 --- a/src/urban_meal_delivery/db/orders.py +++ b/src/urban_meal_delivery/db/orders.py @@ -105,6 +105,8 @@ class Order(meta.Base): # noqa:WPS214 onupdate='RESTRICT', ondelete='RESTRICT', ), + # Needed by a `ForeignKeyConstraint` in `ReplayedOrder`. + sa.UniqueConstraint('id', 'ad_hoc'), sa.CheckConstraint( """ (ad_hoc IS TRUE AND scheduled_delivery_at IS NULL) @@ -147,6 +149,20 @@ class Order(meta.Base): # noqa:WPS214 """, name='scheduled_orders_within_business_hours', ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name='scheduled_orders_must_be_at_quarters_of_an_hour', + ), sa.CheckConstraint( """ NOT ( @@ -256,6 +272,7 @@ class Order(meta.Base): # noqa:WPS214 'placed_at < courier_notified_at', 'placed_at < courier_accepted_at', 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', 'placed_at < left_pickup_at', 'placed_at < reached_delivery_at', 'placed_at < delivery_at', @@ -268,7 +285,6 @@ class Order(meta.Base): # noqa:WPS214 'cancelled_at > pickup_at', 'cancelled_at > left_pickup_at', 'cancelled_at > reached_delivery_at', - 'cancelled_at > delivery_at', 'restaurant_notified_at < restaurant_confirmed_at', 'restaurant_notified_at < pickup_at', 'restaurant_confirmed_at < pickup_at', @@ -323,6 +339,7 @@ class Order(meta.Base): # noqa:WPS214 back_populates='orders_delivered', foreign_keys='[Order.delivery_address_id]', ) + replays = orm.relationship('ReplayedOrder', back_populates='actual') # Convenience properties diff --git a/src/urban_meal_delivery/db/replay/__init__.py b/src/urban_meal_delivery/db/replay/__init__.py new file mode 100644 index 0000000..592d821 --- /dev/null +++ b/src/urban_meal_delivery/db/replay/__init__.py @@ -0,0 +1,4 @@ +"""Provide the ORM models regarding the replay simulations.""" + +from urban_meal_delivery.db.replay.orders import ReplayedOrder +from urban_meal_delivery.db.replay.simulations import ReplaySimulation diff --git a/src/urban_meal_delivery/db/replay/orders.py b/src/urban_meal_delivery/db/replay/orders.py new file mode 100644 index 0000000..ba8b2e2 --- /dev/null +++ b/src/urban_meal_delivery/db/replay/orders.py @@ -0,0 +1,364 @@ +"""Provide the ORM's `ReplayedOrder` model for the replay simulations.""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class ReplayedOrder(meta.Base): + """A simulated order by a `Customer` of the UDP.""" + + __tablename__ = 'replayed_orders' + + # Generic columns + simulation_id = sa.Column(sa.Integer, primary_key=True) + actual_order_id = sa.Column(sa.Integer, primary_key=True) + + # `Order`-type related columns + # `.ad_hoc` is part of a `ForeignKeyConstraint` to ensure the type + # of the `ReplayedOrder` (i.e., ad-hoc or scheduled) is in line + # with the `.actual` order. + ad_hoc = sa.Column(sa.Boolean, nullable=False) + placed_at = sa.Column(sa.DateTime, nullable=False) + # Depending on `.ad_hoc`, `.scheduled_delivery_at` is either filled in or not. + scheduled_delivery_at = sa.Column(sa.DateTime) + # If an order is cancelled in a simulation, + # some of the columns below do not apply any more. + cancelled_at = sa.Column(sa.DateTime) + + # Restaurant-related columns + estimated_prep_duration = sa.Column(sa.SmallInteger) + restaurant_notified_at = sa.Column(sa.DateTime) + restaurant_confirmed_at = sa.Column(sa.DateTime) + restaurant_ready_at = sa.Column(sa.DateTime) + + # Dispatch-related columns + dispatch_at = sa.Column(sa.DateTime) + first_estimated_delivery_at = sa.Column(sa.DateTime) + courier_id = sa.Column(sa.Integer, index=True) + courier_notified_at = sa.Column(sa.DateTime) + courier_accepted_at = sa.Column(sa.DateTime) + utilization = sa.Column(sa.SmallInteger) + + # Pickup-related columns + reached_pickup_at = sa.Column(sa.DateTime) + pickup_at = sa.Column(sa.DateTime) + left_pickup_at = sa.Column(sa.DateTime) + + # Delivery-related columns + reached_delivery_at = sa.Column(sa.DateTime) + delivery_at = sa.Column(sa.DateTime) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['simulation_id'], + ['replay_simulations.id'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['actual_order_id', 'ad_hoc'], + ['orders.id', 'orders.ad_hoc'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['courier_id'], ['couriers.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + # The check constraints model some of the assumptions made in the simulations. + sa.CheckConstraint( + # Align the order's type, ad-hoc or scheduled, with the type of the + # `.actual` order and ensure the corresponding datetime column is set + # or not set. + """ + ( + ad_hoc IS TRUE + AND + scheduled_delivery_at IS NULL + ) + OR + ( + ad_hoc IS FALSE + AND + scheduled_delivery_at IS NOT NULL + ) + """, + name='either_ad_hoc_or_scheduled_order', + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS TRUE + AND ( + EXTRACT(HOUR FROM placed_at) < 11 + OR + EXTRACT(HOUR FROM placed_at) > 22 + ) + ) + """, + name='ad_hoc_orders_within_business_hours', + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS FALSE + AND ( + ( + EXTRACT(HOUR FROM scheduled_delivery_at) <= 11 + AND + NOT ( + EXTRACT(HOUR FROM scheduled_delivery_at) = 11 + AND + EXTRACT(MINUTE FROM scheduled_delivery_at) = 45 + ) + ) + OR + EXTRACT(HOUR FROM scheduled_delivery_at) > 22 + ) + ) + """, + name='scheduled_orders_within_business_hours', + ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name='scheduled_orders_must_be_at_quarters_of_an_hour', + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800 + ) + """, + name='scheduled_orders_not_within_30_minutes', + ), + sa.CheckConstraint( + # A simulation must fill in at least all the timing and dispatch related + # columns, unless an order is cancelled in a run. + # Only `.estimated_prep_duration`, `.first_estimated_delivery_at`, and + # `.utilization` are truly optional. + # This constraint does allow missing columns for cancelled orders + # in any combination of the below columns. So, no precedence constraints + # are enforced (e.g., `.restaurant_notified_at` and `.restaurant_ready_at` + # could be set without `.restaurant_confirmed_at`). That is acceptable + # as cancelled orders are left out in the optimized KPIs. + """ + cancelled_at IS NOT NULL + OR + ( + restaurant_notified_at IS NOT NULL + AND + restaurant_confirmed_at IS NOT NULL + AND + restaurant_ready_at IS NOT NULL + AND + dispatch_at IS NOT NULL + AND + courier_id IS NOT NULL + AND + courier_notified_at IS NOT NULL + AND + courier_accepted_at IS NOT NULL + AND + reached_pickup_at IS NOT NULL + AND + pickup_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name='orders_must_be_either_cancelled_or_fully_simulated', + ), + sa.CheckConstraint( + # 23,487 out of the 660,608 orders were cancelled. In 685 of them, the + # cancellation occurred after the pickup (as modeled by the `.pickup_at` + # and `.left_pickup_at` columns, where the latter may be set with the + # former being unset). As a simplification, we only allow cancellations + # before the pickup in the simulations. Then, the restaurant throws away + # the meal and the courier is free for other orders right away. Orders + # cancelled after pickup in the actual data are assumed to be delivered + # to the customer's door (and then still accepted or thrown away by the + # customer). So, the courier becomes free only after the "fake" delivery. + """ + NOT ( -- Only occurred in 528 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + pickup_at IS NOT NULL + ) + AND + NOT ( -- Only occurred in 176 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name='cancellations_may_only_occur_before_pickup', + ), + sa.CheckConstraint( + # The actual `.estimated_prep_duration` and `.estimated_prep_buffer` + # are modeled together as only one value. Therefore, the individual + # upper limits of 2700 and 900 are added and result in 3600. + '0 <= estimated_prep_duration AND estimated_prep_duration <= 3600', + name='estimated_prep_duration_between_0_and_3600', + ), + sa.CheckConstraint( + # We still round estimates of the preparation time to whole minutes. + # Other estimates are unlikely to change the simulation results in + # a significant way. + 'estimated_prep_duration % 60 = 0', + name='estimated_prep_duration_must_be_whole_minutes', + ), + sa.CheckConstraint( + # If a simulation's `.strategy` models `.utilization`, it must be + # realistic. The value can be deduced from the actual order's + # courier's `.capacity` and the actual order's `.utilization`. + '0 <= utilization AND utilization <= 100', + name='utilization_between_0_and_100', + ), + sa.CheckConstraint( + # The UDP is open from 11 am to 11 pm. So, before 11 am there is no + # activity. After 11 pm, the last orders of a day are all assumed to be + # dispatched before midnight. + """ + NOT ( + EXTRACT(HOUR FROM restaurant_notified_at) < 11 + OR + EXTRACT(HOUR FROM dispatch_at) < 11 + ) + """, + name='orders_dispatched_in_business_hours', + ), + *( + # The timestamps must be in a logically correct order. That is the same as + # in the actual `Order` model with an extra `restaurant_ready_at` column + # and the non-simulated columns removed. + sa.CheckConstraint( + constraint, name='ordered_timestamps_{index}'.format(index=index), + ) + for index, constraint in enumerate( + ( + 'placed_at < scheduled_delivery_at', + 'placed_at < cancelled_at', + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'cancelled_at > restaurant_notified_at', + 'cancelled_at > restaurant_confirmed_at', + 'cancelled_at > restaurant_ready_at', + 'cancelled_at > dispatch_at', + 'cancelled_at > courier_notified_at', + 'cancelled_at > courier_accepted_at', + 'cancelled_at > reached_pickup_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ), + ) + ), + ) + + # Relationships + simulation = orm.relationship('ReplaySimulation', back_populates='orders') + actual = orm.relationship('Order', back_populates='replays') + courier = orm.relationship('Courier') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}(#{order_id})>'.format( + cls=self.__class__.__name__, order_id=self.actual.id, + ) + + # Convenience properties + + @property + def customer(self) -> db.Customer: + """`.customer` of the actual `Order`.""" + return self.actual.customer + + @property + def restaurant(self) -> db.Restaurant: + """`.restaurant` of the actual `Order`.""" + return self.actual.restaurant + + @property + def pickup_address(self) -> db.Address: + """`.pickup_address` of the actual `Order`.""" + return self.actual.pickup_address + + @property + def delivery_address(self) -> db.Address: + """`.delivery_address` of the actual `Order`.""" + return self.actual.delivery_address + + +from urban_meal_delivery import db # noqa:E402 isort:skip diff --git a/src/urban_meal_delivery/db/replay/simulations.py b/src/urban_meal_delivery/db/replay/simulations.py new file mode 100644 index 0000000..8e4802d --- /dev/null +++ b/src/urban_meal_delivery/db/replay/simulations.py @@ -0,0 +1,46 @@ +"""Provide the ORM's `ReplaySimulation` model for the replay simulations.""" + + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class ReplaySimulation(meta.Base): + """A simulation of the UDP's routing given a strategy ... + + ... for the orders as they arrived in reality. + """ + + __tablename__ = 'replay_simulations' + + # Columns + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) # noqa:WPS125 + city_id = sa.Column(sa.SmallInteger, nullable=False, index=True) + strategy = sa.Column(sa.Unicode(length=100), nullable=False, index=True) + # `.run` may be used as random seed. + run = sa.Column(sa.SmallInteger, nullable=False) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + # A `.strategy` can be replayed several times per `.city`. + sa.UniqueConstraint('city_id', 'strategy', 'run'), + sa.CheckConstraint('run >= 0', name='run_is_a_count'), + ) + + # Relationships + city = orm.relationship('City', back_populates='replays') + orders = orm.relationship('ReplayedOrder', back_populates='simulation') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}({strategy} #{run} in {city})>'.format( + cls=self.__class__.__name__, + strategy=self.strategy, + run=self.run, + city=self.city.name, + ) diff --git a/tests/conftest.py b/tests/conftest.py index d4b1b25..673c251 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,6 +106,7 @@ make_address = fake_data.make_address make_courier = fake_data.make_courier make_customer = fake_data.make_customer make_order = fake_data.make_order +make_replay_order = fake_data.make_replay_order make_restaurant = fake_data.make_restaurant address = fake_data.address @@ -114,6 +115,10 @@ city_data = fake_data.city_data courier = fake_data.courier customer = fake_data.customer order = fake_data.order +pre_order = fake_data.pre_order +replayed_order = fake_data.replayed_order restaurant = fake_data.restaurant grid = fake_data.grid pixel = fake_data.pixel +simulation = fake_data.simulation +simulation_data = fake_data.simulation_data diff --git a/tests/db/fake_data/__init__.py b/tests/db/fake_data/__init__.py index 80a7be3..0fc9433 100644 --- a/tests/db/fake_data/__init__.py +++ b/tests/db/fake_data/__init__.py @@ -4,6 +4,7 @@ from tests.db.fake_data.fixture_makers import make_address from tests.db.fake_data.fixture_makers import make_courier from tests.db.fake_data.fixture_makers import make_customer from tests.db.fake_data.fixture_makers import make_order +from tests.db.fake_data.fixture_makers import make_replay_order from tests.db.fake_data.fixture_makers import make_restaurant from tests.db.fake_data.static_fixtures import address from tests.db.fake_data.static_fixtures import city @@ -13,4 +14,8 @@ from tests.db.fake_data.static_fixtures import customer from tests.db.fake_data.static_fixtures import grid from tests.db.fake_data.static_fixtures import order from tests.db.fake_data.static_fixtures import pixel +from tests.db.fake_data.static_fixtures import pre_order +from tests.db.fake_data.static_fixtures import replayed_order from tests.db.fake_data.static_fixtures import restaurant +from tests.db.fake_data.static_fixtures import simulation +from tests.db.fake_data.static_fixtures import simulation_data diff --git a/tests/db/fake_data/factories/__init__.py b/tests/db/fake_data/factories/__init__.py index 215b2ae..3d153ae 100644 --- a/tests/db/fake_data/factories/__init__.py +++ b/tests/db/fake_data/factories/__init__.py @@ -4,5 +4,6 @@ from tests.db.fake_data.factories.addresses import AddressFactory from tests.db.fake_data.factories.couriers import CourierFactory from tests.db.fake_data.factories.customers import CustomerFactory from tests.db.fake_data.factories.orders import AdHocOrderFactory +from tests.db.fake_data.factories.orders import ReplayedOrderFactory from tests.db.fake_data.factories.orders import ScheduledOrderFactory from tests.db.fake_data.factories.restaurants import RestaurantFactory diff --git a/tests/db/fake_data/factories/orders/__init__.py b/tests/db/fake_data/factories/orders/__init__.py index 407bbdb..d854b6c 100644 --- a/tests/db/fake_data/factories/orders/__init__.py +++ b/tests/db/fake_data/factories/orders/__init__.py @@ -1,4 +1,5 @@ """Factory to create `Order` instances.""" from tests.db.fake_data.factories.orders.ad_hoc import AdHocOrderFactory +from tests.db.fake_data.factories.orders.replayed import ReplayedOrderFactory from tests.db.fake_data.factories.orders.scheduled import ScheduledOrderFactory diff --git a/tests/db/fake_data/factories/orders/ad_hoc.py b/tests/db/fake_data/factories/orders/ad_hoc.py index b6090f1..12fc611 100644 --- a/tests/db/fake_data/factories/orders/ad_hoc.py +++ b/tests/db/fake_data/factories/orders/ad_hoc.py @@ -98,12 +98,16 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): lambda obj: obj.placed_at + utils.random_timespan(min_seconds=30, max_seconds=90), ) - restaurant_notified_at_corrected = False + restaurant_notified_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.restaurant_notified_at else None, + ) restaurant_confirmed_at = factory.LazyAttribute( lambda obj: obj.restaurant_notified_at + utils.random_timespan(min_seconds=30, max_seconds=150), ) - restaurant_confirmed_at_corrected = False + restaurant_confirmed_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.restaurant_confirmed_at else None, + ) # Use the database defaults of the historic data. estimated_prep_duration = 900 estimated_prep_duration_corrected = False @@ -115,17 +119,23 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): lambda obj: obj.placed_at + utils.random_timespan(min_seconds=600, max_seconds=1080), ) - dispatch_at_corrected = False + dispatch_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.dispatch_at else None, + ) courier_notified_at = factory.LazyAttribute( lambda obj: obj.dispatch_at + utils.random_timespan(min_seconds=100, max_seconds=140), ) - courier_notified_at_corrected = False + courier_notified_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.courier_notified_at else None, + ) courier_accepted_at = factory.LazyAttribute( lambda obj: obj.courier_notified_at + utils.random_timespan(min_seconds=15, max_seconds=45), ) - courier_accepted_at_corrected = False + courier_accepted_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.courier_accepted_at else None, + ) # Sample a realistic utilization. utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100])) @@ -139,13 +149,17 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): lambda obj: obj.reached_pickup_at + utils.random_timespan(min_seconds=120, max_seconds=600), ) - pickup_at_corrected = False + pickup_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.pickup_at else None, + ) pickup_not_confirmed = False left_pickup_at = factory.LazyAttribute( lambda obj: obj.pickup_at + utils.random_timespan(min_seconds=60, max_seconds=180), ) - left_pickup_at_corrected = False + left_pickup_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.left_pickup_at else None, + ) # Delivery-related attributes # delivery_address -> set by the `make_order` fixture as there is only one `city` @@ -157,7 +171,9 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): lambda obj: obj.reached_delivery_at + utils.random_timespan(min_seconds=240, max_seconds=660), ) - delivery_at_corrected = False + delivery_at_corrected = factory.LazyAttribute( + lambda obj: False if obj.delivery_at else None, + ) delivery_not_confirmed = False _courier_waited_at_delivery = factory.LazyAttribute( lambda obj: False if obj.delivery_at else None, diff --git a/tests/db/fake_data/factories/orders/replayed.py b/tests/db/fake_data/factories/orders/replayed.py new file mode 100644 index 0000000..857bc4c --- /dev/null +++ b/tests/db/fake_data/factories/orders/replayed.py @@ -0,0 +1,120 @@ +"""Factory to create `Order` and `ReplayedOrder` instances.""" + +import datetime as dt + +import factory +from factory import alchemy + +from tests.db.fake_data.factories import utils +from urban_meal_delivery import db + + +class ReplayedOrderFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.ReplayedOrder` model. + + For simplicity, we assume the underlying `.order` to be an ad-hoc `Order`. + """ + + class Meta: + model = db.ReplayedOrder + sqlalchemy_get_or_create = ('simulation_id', 'order_id') + + # Generic columns + # simulation -> set by the `make_replay_order` fixture for better control + # actual (`Order`) -> set by the `make_replay_order` fixture for better control + + # `Order`-type related columns + ad_hoc = factory.LazyAttribute(lambda obj: obj.actual.ad_hoc) + placed_at = factory.LazyAttribute(lambda obj: obj.actual.placed_at) + scheduled_delivery_at = factory.LazyAttribute( + lambda obj: obj.actual.scheduled_delivery_at, + ) + cancelled_at = None + + # Restaurant-related columns + estimated_prep_duration = 1200 + restaurant_notified_at = factory.LazyAttribute( + lambda obj: obj.placed_at + + utils.random_timespan(min_seconds=1, max_seconds=30), + ) + restaurant_confirmed_at = factory.LazyAttribute( + lambda obj: obj.restaurant_notified_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + restaurant_ready_at = factory.LazyAttribute( + lambda obj: obj.restaurant_confirmed_at + + dt.timedelta(seconds=obj.estimated_prep_duration) + + utils.random_timespan(min_seconds=300, max_seconds=300), + ) + + # Dispatch-related columns + dispatch_at = factory.LazyAttribute( + lambda obj: obj.actual.placed_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + first_estimated_delivery_at = factory.LazyAttribute( + lambda obj: obj.restaurant_notified_at + + dt.timedelta(seconds=obj.estimated_prep_duration) + + dt.timedelta(minutes=10), + ) + # courier -> set by the `make_replay_order` fixture for better control + courier_notified_at = factory.LazyAttribute( + lambda obj: obj.dispatch_at + + utils.random_timespan(min_seconds=1, max_seconds=30), + ) + courier_accepted_at = factory.LazyAttribute( + lambda obj: obj.courier_notified_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + utilization = None + + # Pickup-related columns + reached_pickup_at = factory.LazyAttribute( + lambda obj: obj.restaurant_ready_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + pickup_at = factory.LazyAttribute( + lambda obj: obj.reached_pickup_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + left_pickup_at = factory.LazyAttribute( + lambda obj: obj.pickup_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + + # Delivery-related columns + reached_delivery_at = factory.LazyAttribute( + lambda obj: obj.left_pickup_at + + utils.random_timespan(min_minutes=5, max_minutes=10), + ) + delivery_at = factory.LazyAttribute( + lambda obj: obj.reached_delivery_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + + @factory.post_generation + def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805 + """Discard timestamps that occur after cancellation.""" + if obj.cancelled_at: + if obj.cancelled_at <= obj.restaurant_notified_at: + obj.restaurant_notified_at = None + if obj.cancelled_at <= obj.restaurant_confirmed_at: + obj.restaurant_confirmed_at = None + if obj.cancelled_at <= obj.restaurant_ready_at: + obj.restaurant_ready_at = None + if obj.cancelled_at <= obj.dispatch_at: + obj.dispatch_at = None + if obj.cancelled_at <= obj.courier_notified_at: + obj.courier_notified_at = None + if obj.cancelled_at <= obj.courier_accepted_at: + obj.courier_accepted_at = None + if obj.cancelled_at <= obj.reached_pickup_at: + obj.reached_pickup_at = None + if obj.cancelled_at <= obj.pickup_at: + obj.pickup_at = None + if obj.cancelled_at <= obj.left_pickup_at: + obj.left_pickup_at = None + if obj.cancelled_at <= obj.reached_delivery_at: + obj.reached_delivery_at = None + if obj.cancelled_at <= obj.delivery_at: + obj.delivery_at = None diff --git a/tests/db/fake_data/fixture_makers.py b/tests/db/fake_data/fixture_makers.py index 9a5419b..8f6f891 100644 --- a/tests/db/fake_data/fixture_makers.py +++ b/tests/db/fake_data/fixture_makers.py @@ -103,3 +103,41 @@ def make_order(make_address, make_courier, make_customer, make_restaurant): ) return func + + +@pytest.fixture +def make_replay_order(make_order, simulation): + """Replaces `ReplayedOrderFactory.build()`: Create a `ReplayedOrder`.""" + # Reset the identifiers before every test. + factories.ReplayedOrderFactory.reset_sequence(1) + + def func(scheduled=False, order=None, **kwargs): + """Create a new `ReplayedOrder` object. + + Each `ReplayOrder` is made by a new `Customer` with a unique `Address`. + + Args: + scheduled: if an `Order` is a pre-order + order: the `Order` that is replayed + kwargs: keyword arguments forwarded to the `ReplayedOrderFactory` + + Returns: + order + + Raises: + RuntimeError: if `scheduled=True` is passed in + together with an ad-hoc `order` + """ + if scheduled: + if order and order.ad_hoc is False: + raise RuntimeError('`order` must be scheduled') + elif order is None: + order = make_order(scheduled=True) + elif order is None: + order = make_order() + + return factories.ReplayedOrderFactory.build( + simulation=simulation, actual=order, courier=order.courier, **kwargs, + ) + + return func diff --git a/tests/db/fake_data/static_fixtures.py b/tests/db/fake_data/static_fixtures.py index 60d4181..4be94c5 100644 --- a/tests/db/fake_data/static_fixtures.py +++ b/tests/db/fake_data/static_fixtures.py @@ -54,10 +54,22 @@ def restaurant(address, make_restaurant): @pytest.fixture def order(make_order, restaurant): - """An `Order` object for the `restaurant`.""" + """An ad-hoc `Order` object for the `restaurant`.""" return make_order(restaurant=restaurant) +@pytest.fixture +def pre_order(make_order, restaurant): + """A scheduled `Order` object for the `restaurant`.""" + return make_order(restaurant=restaurant, scheduled=True) + + +@pytest.fixture +def replayed_order(make_replay_order, order): + """A `ReplayedOrder` object for the `restaurant`.""" + return make_replay_order(order=order) + + @pytest.fixture def grid(city): """A `Grid` with a pixel area of 1 square kilometer.""" @@ -68,3 +80,20 @@ def grid(city): def pixel(grid): """The `Pixel` in the lower-left corner of the `grid`.""" return db.Pixel(id=1, grid=grid, n_x=0, n_y=0) + + +@pytest.fixture +def simulation_data(city): + """The data for the one and only `ReplaySimulation` object as a `dict`.""" + return { + 'id': 1, + 'city': city, + 'strategy': 'best_possible_routing', + 'run': 0, + } + + +@pytest.fixture +def simulation(simulation_data): + """The one and only `ReplaySimulation` object.""" + return db.ReplaySimulation(**simulation_data) diff --git a/tests/db/replay/__init__.py b/tests/db/replay/__init__.py new file mode 100644 index 0000000..cd9da80 --- /dev/null +++ b/tests/db/replay/__init__.py @@ -0,0 +1 @@ +"""Test the replay simulation models in the ORM layer.""" diff --git a/tests/db/replay/test_orders.py b/tests/db/replay/test_orders.py new file mode 100644 index 0000000..0ad62eb --- /dev/null +++ b/tests/db/replay/test_orders.py @@ -0,0 +1,585 @@ +"""Test the ORM's `ReplayedOrder` model.""" + +import datetime as dt + +import pytest +import sqlalchemy as sqla +from sqlalchemy import exc as sa_exc + +from tests import config as test_config +from urban_meal_delivery import db + + +class TestSpecialMethods: + """Test special methods in `ReplayedOrder`.""" + + def test_create_order(self, replayed_order): + """Test instantiation of a new `ReplayedOrder` object.""" + assert replayed_order is not None + + def test_text_representation(self, replayed_order): + """`ReplayedOrder` has a non-literal text representation.""" + result = repr(replayed_order) + + assert result == f'' + + +@pytest.mark.db +@pytest.mark.no_cover +class TestConstraints: + """Test the database constraints defined in `ReplayedOrder`.""" + + def test_insert_into_into_database(self, db_session, replayed_order): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.ReplayedOrder).count() == 0 + + db_session.add(replayed_order) + db_session.commit() + + assert db_session.query(db.ReplayedOrder).count() == 1 + + def test_delete_a_referenced_simulation(self, db_session, replayed_order): + """Remove a record that is referenced with a FK.""" + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.ReplaySimulation).where( + db.ReplaySimulation.id == replayed_order.simulation.id, + ) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_replayed_orders_to_replay_simulations', + ): + db_session.execute(stmt) + + def test_delete_a_referenced_actual_order(self, db_session, replayed_order): + """Remove a record that is referenced with a FK.""" + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Order).where(db.Order.id == replayed_order.actual.id) + + with pytest.raises(sa_exc.IntegrityError, match='fk_replayed_orders_to_orders'): + db_session.execute(stmt) + + def test_delete_a_referenced_courier( + self, db_session, replayed_order, make_courier, + ): + """Remove a record that is referenced with a FK.""" + # Need a second courier, one that is + # not associated with the `.actual` order. + replayed_order.courier = make_courier() + + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Courier).where(db.Courier.id == replayed_order.courier.id) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_replayed_orders_to_couriers', + ): + db_session.execute(stmt) + + def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, replayed_order): + """Insert an instance with invalid data.""" + assert replayed_order.ad_hoc is True + + replayed_order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order', + ): + db_session.commit() + + def test_scheduled_order_without_scheduled_delivery_at( + self, db_session, make_replay_order, + ): + """Insert an instance with invalid data.""" + replay_order = make_replay_order(scheduled=True) + + assert replay_order.ad_hoc is False + + replay_order.scheduled_delivery_at = None + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order', + ): + db_session.commit() + + def test_ad_hoc_order_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + replay_order = make_replay_order( + placed_at=dt.datetime(*test_config.DATE, 10, 0), + ) + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours', + ): + db_session.commit() + + def test_ad_hoc_order_too_late(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + replay_order = make_replay_order( + placed_at=dt.datetime(*test_config.DATE, 23, 0), + ) + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_way_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 10, 0) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=12), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_a_bit_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 30) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=14), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_not_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + 11.45 is the only time outside noon to 11 pm when a scheduled order is allowed. + """ + scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 45) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=14), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + assert db_session.query(db.Order).count() == 0 + + db_session.add(replay_pre_order) + db_session.commit() + + assert db_session.query(db.Order).count() == 1 + + def test_scheduled_order_too_late(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 23, 0) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + @pytest.mark.parametrize('minute', [min_ for min_ in range(60) if min_ % 15 != 0]) + def test_scheduled_order_at_non_quarter_of_an_hour( + self, db_session, make_replay_order, minute, + ): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime( # `minute` is not 0, 15, 30, or 45 + *test_config.DATE, test_config.NOON, minute, + ) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='scheduled_orders_must_be_at_quart', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('second', list(range(1, 60))) + def test_scheduled_order_at_non_quarter_of_an_hour_by_seconds( + self, db_session, make_replay_order, second, + ): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime( + *test_config.DATE, test_config.NOON, 0, second, + ) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='scheduled_orders_must_be_at_quart', # constraint name too long + ): + db_session.commit() + + def test_scheduled_order_too_soon(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + Scheduled orders must be at least 30 minutes into the future. + """ + # Create an ad-hoc order first and then make that a scheduled order. + # This way, it is less work to keep the timestamps consistent. + replay_pre_order = make_replay_order(scheduled=False) + + # Make the `scheduled_delivery_at` the quarter of an hour + # following the next quarter of an hour (i.e., the timestamp + # is between 15 and 30 minutes into the future). + replay_pre_order.ad_hoc = False + replay_pre_order.actual.ad_hoc = False + minutes_to_next_quarter = 15 - (replay_pre_order.placed_at.minute % 15) + scheduled_delivery_at = ( + # `.placed_at` may have non-0 seconds. + replay_pre_order.placed_at.replace(second=0) + + dt.timedelta(minutes=(minutes_to_next_quarter + 15)) + ) + replay_pre_order.scheduled_delivery_at = scheduled_delivery_at + replay_pre_order.actual.scheduled_delivery_at = scheduled_delivery_at + replay_pre_order.actual.scheduled_delivery_at_corrected = False + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes', + ): + db_session.commit() + + @pytest.mark.parametrize( + 'column', + [ + 'restaurant_notified_at', + 'restaurant_confirmed_at', + 'restaurant_ready_at', + 'dispatch_at', + 'courier', # not `.courier_id` + 'courier_notified_at', + 'courier_accepted_at', + 'reached_pickup_at', + 'pickup_at', + 'left_pickup_at', + 'reached_delivery_at', + 'delivery_at', + ], + ) + def test_not_fully_simulated_order(self, db_session, replayed_order, column): + """Insert an instance with invalid data.""" + assert replayed_order.cancelled_at is None + + setattr(replayed_order, column, None) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='either_cancelled_o', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize( + 'column', + [ + 'restaurant_notified_at', + 'restaurant_confirmed_at', + 'restaurant_ready_at', + 'dispatch_at', + 'courier_id', + 'courier_notified_at', + 'courier_accepted_at', + 'reached_pickup_at', + ], + ) + def test_simulated_cancellation(self, db_session, replayed_order, column): + """Insert an instance with invalid data. + + Cancelled orders may have missing timestamps + """ + replayed_order.cancelled_at = replayed_order.pickup_at + + replayed_order.pickup_at = None + replayed_order.left_pickup_at = None + replayed_order.reached_delivery_at = None + replayed_order.delivery_at = None + + setattr(replayed_order, column, None) + + db_session.add(replayed_order) + db_session.commit() + + @pytest.mark.parametrize( + 'column', ['pickup_at', 'left_pickup_at', 'reached_delivery_at', 'delivery_at'], + ) + def test_no_simulated_cancellation_after_pickup( + self, db_session, replayed_order, column, + ): + """Insert an instance with invalid data.""" + # Setting `.cancelled_at` to the end of a day + # ensures the timestamps are logically ok. + replayed_order.cancelled_at = dt.datetime(*test_config.DATE, 23) + + # Set all timestamps after `.reached_pickup_at` to NULL + # except the one under test. + for unset_column in ( + 'pickup_at', + 'left_pickup_at', + 'reached_delivery_at', + 'delivery_at', + ): + if unset_column != column: + setattr(replayed_order, unset_column, None) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='cancellations_may_only_occur_befo', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('duration', [-1, 3601]) + def test_estimated_prep_duration_out_of_range( + self, db_session, replayed_order, duration, + ): + """Insert an instance with invalid data.""" + replayed_order.estimated_prep_duration = duration + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='between_0', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('duration', [1, 59, 119, 3599]) + def test_estimated_prep_duration_not_whole_minute( + self, db_session, replayed_order, duration, + ): + """Insert an instance with invalid data.""" + replayed_order.estimated_prep_duration = duration + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='must_be_w', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('utilization', [-1, 101]) + def test_utilization_out_of_range(self, db_session, replayed_order, utilization): + """Insert an instance with invalid data.""" + replayed_order.utilization = utilization + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='between_0_and_100', + ): + db_session.commit() + + @pytest.mark.parametrize('column', ['restaurant_notified_at', 'dispatch_at']) + @pytest.mark.parametrize('hour', [0, 10]) + def test_order_dispatched_in_non_business_hour( + self, db_session, replayed_order, column, hour, + ): + """Insert an instance with invalid data.""" + orig_timestamp = getattr(replayed_order, column) + new_timestamp = orig_timestamp.replace(hour=hour) + + replayed_order.placed_at = new_timestamp - dt.timedelta(minutes=1) + setattr(replayed_order, column, new_timestamp) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='business_hours', + ): + db_session.commit() + + @pytest.mark.parametrize( + 'comparison', + [ + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ], + ) + def test_timestamps_unordered( + self, db_session, replayed_order, comparison, + ): + """Insert an instance with invalid data. + + There are two special cases for this test case below, + where other attributes on `replayed_order` must be unset. + """ + smaller, bigger = comparison.split(' < ') + + assert smaller is not None + + violating_timestamp = getattr(replayed_order, smaller) - dt.timedelta(seconds=1) + setattr(replayed_order, bigger, violating_timestamp) + + db_session.add(replayed_order) + + with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'): + db_session.commit() + + def test_timestamps_unordered_scheduled(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + This is one of two special cases. See the generic case above. + """ + replayed_pre_order = make_replay_order(scheduled=True) + # As we subtract 1 second in the generic case, + # choose one second after a quarter of an hour. + replayed_pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1) + self.test_timestamps_unordered( + db_session, replayed_pre_order, 'placed_at < scheduled_delivery_at', + ) + + @pytest.mark.parametrize( + 'comparison', + [ + 'placed_at < cancelled_at', + 'restaurant_notified_at < cancelled_at', + 'restaurant_confirmed_at < cancelled_at', + 'restaurant_ready_at < cancelled_at', + 'dispatch_at < cancelled_at', + 'courier_notified_at < cancelled_at', + 'courier_accepted_at < cancelled_at', + 'reached_pickup_at < cancelled_at', + ], + ) + def test_timestamps_unordered_cancelled( + self, db_session, replayed_order, comparison, + ): + """Insert an instance with invalid data. + + This is one of two special cases. See the generic case above. + """ + replayed_order.pickup_at = None + replayed_order.left_pickup_at = None + replayed_order.reached_delivery_at = None + replayed_order.delivery_at = None + + self.test_timestamps_unordered(db_session, replayed_order, comparison) + + +class TestProperties: + """Test properties in `ReplayedOrder`. + + The `replayed_order` fixture uses the defaults specified in + `factories.ReplayedOrderFactory` and provided by the `make_replay_order` fixture. + """ + + def test_has_customer(self, replayed_order): + """Test `ReplayedOrder.customer` property.""" + result = replayed_order.customer + + assert result is replayed_order.actual.customer + + def test_has_restaurant(self, replayed_order): + """Test `ReplayedOrder.restaurant` property.""" + result = replayed_order.restaurant + + assert result is replayed_order.actual.restaurant + + def test_has_pickup_address(self, replayed_order): + """Test `ReplayedOrder.pickup_address` property.""" + result = replayed_order.pickup_address + + assert result is replayed_order.actual.pickup_address + + def test_has_delivery_address(self, replayed_order): + """Test `ReplayedOrder.delivery_address` property.""" + result = replayed_order.delivery_address + + assert result is replayed_order.actual.delivery_address diff --git a/tests/db/replay/test_simulations.py b/tests/db/replay/test_simulations.py new file mode 100644 index 0000000..5d2ce08 --- /dev/null +++ b/tests/db/replay/test_simulations.py @@ -0,0 +1,17 @@ +"""Test the ORM's `ReplaySimulation` model.""" + + +class TestSpecialMethods: + """Test special methods in `ReplaySimulation`.""" + + def test_create_simulation(self, simulation): + """Test instantiation of a new `ReplaySimulation` object.""" + assert simulation is not None + + def test_text_representation(self, simulation): + """`ReplaySimulation` has a non-literal text representation.""" + result = repr(simulation) + + assert result.startswith('