Add ORM models for the simulation data

- add new ORM models `ReplaySimulation` and `ReplayedOrder`
  to store the data generated by the routing simulations
- add migrations script to create the corresponding database tables
  + create "replay_simulations" and "replayed_orders" tables
  + add missing check constraints to "orders" table
  + add unique constraint to "orders" table to enable compound key
  + drop unnecessary check constraints from the "orders" table
- add tests for the new ORM models
  + add `simulation`, `replayed_order`, `make_replay_order()`, and
    `pre_order` fixtures
  + add `ReplayedOrderFactor` faker class
- fix some typos
This commit is contained in:
Alexander Hess 2021-09-16 11:58:55 +02:00
parent 41f75f507d
commit e4e543bd40
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
19 changed files with 1677 additions and 10 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -0,0 +1 @@
"""Test the replay simulation models in the ORM layer."""

View file

@ -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'<ReplayedOrder(#{replayed_order.actual.id})>'
@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

View file

@ -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('<ReplaySimulation(')
assert simulation.city.name in result
assert simulation.strategy in result