Merge branch 'simulation-data' into develop
This commit is contained in:
commit
830f44e3cc
36 changed files with 2575 additions and 287 deletions
|
@ -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
|
|
@ -384,7 +384,7 @@ def test_suite(session):
|
||||||
|
|
||||||
|
|
||||||
@nox.session(name='fix-branch-references', python=PYTHON, venv_backend='none')
|
@nox.session(name='fix-branch-references', python=PYTHON, venv_backend='none')
|
||||||
def fix_branch_references(session): # noqa:WPS210,WPS231
|
def fix_branch_references(session): # noqa:WPS210
|
||||||
"""Replace branch references with the current branch.
|
"""Replace branch references with the current branch.
|
||||||
|
|
||||||
Intended to be run as a pre-commit hook.
|
Intended to be run as a pre-commit hook.
|
||||||
|
@ -511,7 +511,7 @@ def init_project(session):
|
||||||
|
|
||||||
|
|
||||||
@nox.session(name='clean-pwd', python=PYTHON, venv_backend='none')
|
@nox.session(name='clean-pwd', python=PYTHON, venv_backend='none')
|
||||||
def clean_pwd(session): # noqa:WPS231
|
def clean_pwd(session):
|
||||||
"""Remove (almost) all glob patterns listed in .gitignore.
|
"""Remove (almost) all glob patterns listed in .gitignore.
|
||||||
|
|
||||||
The difference compared to `git clean -X` is that this task
|
The difference compared to `git clean -X` is that this task
|
||||||
|
|
26
setup.cfg
26
setup.cfg
|
@ -93,8 +93,15 @@ extend-ignore =
|
||||||
# until after being processed by Sphinx Napoleon.
|
# until after being processed by Sphinx Napoleon.
|
||||||
# Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17
|
# Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17
|
||||||
RST201,RST203,RST210,RST213,RST301,
|
RST201,RST203,RST210,RST213,RST301,
|
||||||
|
# The "too deep nesting" violation fires off "too early" and cannot be configured.
|
||||||
|
WPS220,
|
||||||
# String constant over-use is checked visually by the programmer.
|
# String constant over-use is checked visually by the programmer.
|
||||||
WPS226,
|
WPS226,
|
||||||
|
# Function complexity is checked by `mccabe` (=C901),
|
||||||
|
# which yields the same result in most cases.
|
||||||
|
WPS231,
|
||||||
|
# Modules as a whole are assumed to be not "too complex".
|
||||||
|
WPS232,
|
||||||
# Allow underscores in numbers.
|
# Allow underscores in numbers.
|
||||||
WPS303,
|
WPS303,
|
||||||
# f-strings are ok.
|
# f-strings are ok.
|
||||||
|
@ -104,6 +111,8 @@ extend-ignore =
|
||||||
# Let's be modern: The Walrus is ok.
|
# Let's be modern: The Walrus is ok.
|
||||||
WPS332,
|
WPS332,
|
||||||
# Let's not worry about the number of noqa's.
|
# Let's not worry about the number of noqa's.
|
||||||
|
# Multi-line loops are ok.
|
||||||
|
WPS352,
|
||||||
WPS402,
|
WPS402,
|
||||||
# Putting logic into __init__.py files may be justified.
|
# Putting logic into __init__.py files may be justified.
|
||||||
WPS412,
|
WPS412,
|
||||||
|
@ -141,24 +150,9 @@ per-file-ignores =
|
||||||
src/urban_meal_delivery/configuration.py:
|
src/urban_meal_delivery/configuration.py:
|
||||||
# Allow upper case class variables within classes.
|
# Allow upper case class variables within classes.
|
||||||
WPS115,
|
WPS115,
|
||||||
src/urban_meal_delivery/console/forecasts.py:
|
|
||||||
# The module is not too complex.
|
|
||||||
WPS232,
|
|
||||||
src/urban_meal_delivery/db/addresses_addresses.py:
|
src/urban_meal_delivery/db/addresses_addresses.py:
|
||||||
# The module does not have too many imports.
|
# The module does not have too many imports.
|
||||||
WPS201,
|
WPS201,
|
||||||
src/urban_meal_delivery/db/customers.py:
|
|
||||||
# The module is not too complex.
|
|
||||||
WPS232,
|
|
||||||
src/urban_meal_delivery/db/restaurants.py:
|
|
||||||
# The module is not too complex.
|
|
||||||
WPS232,
|
|
||||||
src/urban_meal_delivery/forecasts/methods/decomposition.py:
|
|
||||||
# The module is not too complex.
|
|
||||||
WPS232,
|
|
||||||
src/urban_meal_delivery/forecasts/methods/extrapolate_season.py:
|
|
||||||
# The module is not too complex.
|
|
||||||
WPS232,
|
|
||||||
src/urban_meal_delivery/forecasts/models/tactical/horizontal.py:
|
src/urban_meal_delivery/forecasts/models/tactical/horizontal.py:
|
||||||
# The many noqa's are ok.
|
# The many noqa's are ok.
|
||||||
WPS403,
|
WPS403,
|
||||||
|
@ -178,6 +172,8 @@ per-file-ignores =
|
||||||
S311,
|
S311,
|
||||||
# Shadowing outer scopes occurs naturally with mocks.
|
# Shadowing outer scopes occurs naturally with mocks.
|
||||||
WPS442,
|
WPS442,
|
||||||
|
# `utils` should be a valid module name.
|
||||||
|
WPS100,
|
||||||
# Test names may be longer than 40 characters.
|
# Test names may be longer than 40 characters.
|
||||||
WPS118,
|
WPS118,
|
||||||
# Modules may have many test cases.
|
# Modules may have many test cases.
|
||||||
|
|
|
@ -24,7 +24,7 @@ from urban_meal_delivery.forecasts import timify
|
||||||
@click.argument('time_step', default=60, type=int)
|
@click.argument('time_step', default=60, type=int)
|
||||||
@click.argument('train_horizon', default=8, type=int)
|
@click.argument('train_horizon', default=8, type=int)
|
||||||
@decorators.db_revision('8bfb928a31f8')
|
@decorators.db_revision('8bfb928a31f8')
|
||||||
def tactical_heuristic( # noqa:C901,WPS213,WPS216,WPS231
|
def tactical_heuristic( # noqa:C901,WPS213,WPS216
|
||||||
city: str, side_length: int, time_step: int, train_horizon: int,
|
city: str, side_length: int, time_step: int, train_horizon: int,
|
||||||
) -> None: # pragma: no cover
|
) -> None: # pragma: no cover
|
||||||
"""Predict demand for all pixels and days in a city.
|
"""Predict demand for all pixels and days in a city.
|
||||||
|
|
|
@ -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.meta import Base
|
||||||
from urban_meal_delivery.db.orders import Order
|
from urban_meal_delivery.db.orders import Order
|
||||||
from urban_meal_delivery.db.pixels import Pixel
|
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
|
from urban_meal_delivery.db.restaurants import Restaurant
|
||||||
|
|
|
@ -39,6 +39,7 @@ class City(meta.Base):
|
||||||
# Relationships
|
# Relationships
|
||||||
addresses = orm.relationship('Address', back_populates='city')
|
addresses = orm.relationship('Address', back_populates='city')
|
||||||
grids = orm.relationship('Grid', 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.
|
# We do not implement a `.__init__()` method and use SQLAlchemy's default.
|
||||||
# The uninitialized attribute `._map` is computed on the fly. note:d334120ei
|
# The uninitialized attribute `._map` is computed on the fly. note:d334120ei
|
||||||
|
@ -117,7 +118,7 @@ class City(meta.Base):
|
||||||
|
|
||||||
return self._map
|
return self._map
|
||||||
|
|
||||||
def draw_restaurants( # noqa:WPS231
|
def draw_restaurants(
|
||||||
self, order_counts: bool = False, # pragma: no cover
|
self, order_counts: bool = False, # pragma: no cover
|
||||||
) -> folium.Map:
|
) -> folium.Map:
|
||||||
"""Draw all restaurants on the`.map`.
|
"""Draw all restaurants on the`.map`.
|
||||||
|
@ -169,15 +170,15 @@ class City(meta.Base):
|
||||||
)
|
)
|
||||||
# ... and adjust the size of the red dot on the `.map`.
|
# ... and adjust the size of the red dot on the `.map`.
|
||||||
if n_orders >= 1000:
|
if n_orders >= 1000:
|
||||||
radius = 20 # noqa:WPS220
|
radius = 20
|
||||||
elif n_orders >= 500:
|
elif n_orders >= 500:
|
||||||
radius = 15 # noqa:WPS220
|
radius = 15
|
||||||
elif n_orders >= 100:
|
elif n_orders >= 100:
|
||||||
radius = 10 # noqa:WPS220
|
radius = 10
|
||||||
elif n_orders >= 10:
|
elif n_orders >= 10:
|
||||||
radius = 5 # noqa:WPS220
|
radius = 5
|
||||||
else:
|
else:
|
||||||
radius = 1 # noqa:WPS220
|
radius = 1
|
||||||
|
|
||||||
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,7 @@ class Customer(meta.Base):
|
||||||
"""Shortcut to the `...city.map` object."""
|
"""Shortcut to the `...city.map` object."""
|
||||||
return self.orders[0].pickup_address.city.map # noqa:WPS219
|
return self.orders[0].pickup_address.city.map # noqa:WPS219
|
||||||
|
|
||||||
def draw( # noqa:C901,WPS210,WPS231
|
def draw( # noqa:C901,WPS210
|
||||||
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
) -> folium.Map:
|
) -> folium.Map:
|
||||||
"""Draw all the customer's delivery addresses on the `...city.map`.
|
"""Draw all the customer's delivery addresses on the `...city.map`.
|
||||||
|
@ -90,15 +90,15 @@ class Customer(meta.Base):
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
if n_orders >= 25:
|
if n_orders >= 25:
|
||||||
radius = 20 # noqa:WPS220
|
radius = 20
|
||||||
elif n_orders >= 10:
|
elif n_orders >= 10:
|
||||||
radius = 15 # noqa:WPS220
|
radius = 15
|
||||||
elif n_orders >= 5:
|
elif n_orders >= 5:
|
||||||
radius = 10 # noqa:WPS220
|
radius = 10
|
||||||
elif n_orders > 1:
|
elif n_orders > 1:
|
||||||
radius = 5 # noqa:WPS220
|
radius = 5
|
||||||
else:
|
else:
|
||||||
radius = 1 # noqa:WPS220
|
radius = 1
|
||||||
|
|
||||||
address.draw(
|
address.draw(
|
||||||
radius=radius,
|
radius=radius,
|
||||||
|
@ -156,15 +156,15 @@ class Customer(meta.Base):
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
if n_orders >= 25:
|
if n_orders >= 25:
|
||||||
radius = 20 # noqa:WPS220
|
radius = 20
|
||||||
elif n_orders >= 10:
|
elif n_orders >= 10:
|
||||||
radius = 15 # noqa:WPS220
|
radius = 15
|
||||||
elif n_orders >= 5:
|
elif n_orders >= 5:
|
||||||
radius = 10 # noqa:WPS220
|
radius = 10
|
||||||
elif n_orders > 1:
|
elif n_orders > 1:
|
||||||
radius = 5 # noqa:WPS220
|
radius = 5
|
||||||
else:
|
else:
|
||||||
radius = 1 # noqa:WPS220
|
radius = 1
|
||||||
|
|
||||||
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,8 @@ class Order(meta.Base): # noqa:WPS214
|
||||||
onupdate='RESTRICT',
|
onupdate='RESTRICT',
|
||||||
ondelete='RESTRICT',
|
ondelete='RESTRICT',
|
||||||
),
|
),
|
||||||
|
# Needed by a `ForeignKeyConstraint` in `ReplayedOrder`.
|
||||||
|
sa.UniqueConstraint('id', 'ad_hoc'),
|
||||||
sa.CheckConstraint(
|
sa.CheckConstraint(
|
||||||
"""
|
"""
|
||||||
(ad_hoc IS TRUE AND scheduled_delivery_at IS NULL)
|
(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',
|
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(
|
sa.CheckConstraint(
|
||||||
"""
|
"""
|
||||||
NOT (
|
NOT (
|
||||||
|
@ -256,6 +272,7 @@ class Order(meta.Base): # noqa:WPS214
|
||||||
'placed_at < courier_notified_at',
|
'placed_at < courier_notified_at',
|
||||||
'placed_at < courier_accepted_at',
|
'placed_at < courier_accepted_at',
|
||||||
'placed_at < reached_pickup_at',
|
'placed_at < reached_pickup_at',
|
||||||
|
'placed_at < pickup_at',
|
||||||
'placed_at < left_pickup_at',
|
'placed_at < left_pickup_at',
|
||||||
'placed_at < reached_delivery_at',
|
'placed_at < reached_delivery_at',
|
||||||
'placed_at < delivery_at',
|
'placed_at < delivery_at',
|
||||||
|
@ -268,7 +285,6 @@ class Order(meta.Base): # noqa:WPS214
|
||||||
'cancelled_at > pickup_at',
|
'cancelled_at > pickup_at',
|
||||||
'cancelled_at > left_pickup_at',
|
'cancelled_at > left_pickup_at',
|
||||||
'cancelled_at > reached_delivery_at',
|
'cancelled_at > reached_delivery_at',
|
||||||
'cancelled_at > delivery_at',
|
|
||||||
'restaurant_notified_at < restaurant_confirmed_at',
|
'restaurant_notified_at < restaurant_confirmed_at',
|
||||||
'restaurant_notified_at < pickup_at',
|
'restaurant_notified_at < pickup_at',
|
||||||
'restaurant_confirmed_at < pickup_at',
|
'restaurant_confirmed_at < pickup_at',
|
||||||
|
@ -323,6 +339,7 @@ class Order(meta.Base): # noqa:WPS214
|
||||||
back_populates='orders_delivered',
|
back_populates='orders_delivered',
|
||||||
foreign_keys='[Order.delivery_address_id]',
|
foreign_keys='[Order.delivery_address_id]',
|
||||||
)
|
)
|
||||||
|
replays = orm.relationship('ReplayedOrder', back_populates='actual')
|
||||||
|
|
||||||
# Convenience properties
|
# Convenience properties
|
||||||
|
|
||||||
|
|
|
@ -136,7 +136,7 @@ class Pixel(meta.Base):
|
||||||
"""Shortcut to the `.city.map` object."""
|
"""Shortcut to the `.city.map` object."""
|
||||||
return self.grid.city.map
|
return self.grid.city.map
|
||||||
|
|
||||||
def draw( # noqa:C901,WPS210,WPS231
|
def draw( # noqa:C901,WPS210
|
||||||
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
) -> folium.Map:
|
) -> folium.Map:
|
||||||
"""Draw the pixel on the `.grid.city.map`.
|
"""Draw the pixel on the `.grid.city.map`.
|
||||||
|
@ -226,15 +226,15 @@ class Pixel(meta.Base):
|
||||||
)
|
)
|
||||||
# ... and adjust the size of the red dot on the `.map`.
|
# ... and adjust the size of the red dot on the `.map`.
|
||||||
if n_orders >= 1000:
|
if n_orders >= 1000:
|
||||||
radius = 20 # noqa:WPS220
|
radius = 20
|
||||||
elif n_orders >= 500:
|
elif n_orders >= 500:
|
||||||
radius = 15 # noqa:WPS220
|
radius = 15
|
||||||
elif n_orders >= 100:
|
elif n_orders >= 100:
|
||||||
radius = 10 # noqa:WPS220
|
radius = 10
|
||||||
elif n_orders >= 10:
|
elif n_orders >= 10:
|
||||||
radius = 5 # noqa:WPS220
|
radius = 5
|
||||||
else:
|
else:
|
||||||
radius = 1 # noqa:WPS220
|
radius = 1
|
||||||
|
|
||||||
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
|
4
src/urban_meal_delivery/db/replay/__init__.py
Normal file
4
src/urban_meal_delivery/db/replay/__init__.py
Normal 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
|
364
src/urban_meal_delivery/db/replay/orders.py
Normal file
364
src/urban_meal_delivery/db/replay/orders.py
Normal 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
|
46
src/urban_meal_delivery/db/replay/simulations.py
Normal file
46
src/urban_meal_delivery/db/replay/simulations.py
Normal 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,
|
||||||
|
)
|
|
@ -69,7 +69,7 @@ class Restaurant(meta.Base):
|
||||||
"""Shortcut to the `.address.city.map` object."""
|
"""Shortcut to the `.address.city.map` object."""
|
||||||
return self.address.city.map
|
return self.address.city.map
|
||||||
|
|
||||||
def draw( # noqa:WPS231
|
def draw(
|
||||||
self, customers: bool = True, order_counts: bool = False, # pragma: no cover
|
self, customers: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
) -> folium.Map:
|
) -> folium.Map:
|
||||||
"""Draw the restaurant on the `.address.city.map`.
|
"""Draw the restaurant on the `.address.city.map`.
|
||||||
|
@ -116,15 +116,15 @@ class Restaurant(meta.Base):
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
if n_orders >= 25:
|
if n_orders >= 25:
|
||||||
radius = 20 # noqa:WPS220
|
radius = 20
|
||||||
elif n_orders >= 10:
|
elif n_orders >= 10:
|
||||||
radius = 15 # noqa:WPS220
|
radius = 15
|
||||||
elif n_orders >= 5:
|
elif n_orders >= 5:
|
||||||
radius = 10 # noqa:WPS220
|
radius = 10
|
||||||
elif n_orders > 1:
|
elif n_orders > 1:
|
||||||
radius = 5 # noqa:WPS220
|
radius = 5
|
||||||
else:
|
else:
|
||||||
radius = 1 # noqa:WPS220
|
radius = 1
|
||||||
|
|
||||||
address.draw(
|
address.draw(
|
||||||
radius=radius,
|
radius=radius,
|
||||||
|
|
|
@ -11,7 +11,7 @@ from rpy2 import robjects
|
||||||
from rpy2.robjects import pandas2ri
|
from rpy2.robjects import pandas2ri
|
||||||
|
|
||||||
|
|
||||||
def stl( # noqa:C901,WPS210,WPS211,WPS231
|
def stl( # noqa:C901,WPS210,WPS211
|
||||||
time_series: pd.Series,
|
time_series: pd.Series,
|
||||||
*,
|
*,
|
||||||
frequency: int,
|
frequency: int,
|
||||||
|
|
|
@ -7,6 +7,8 @@ from urban_meal_delivery import config
|
||||||
|
|
||||||
# The day on which most test cases take place.
|
# The day on which most test cases take place.
|
||||||
YEAR, MONTH, DAY = 2016, 7, 1
|
YEAR, MONTH, DAY = 2016, 7, 1
|
||||||
|
# Same day as a `tuple`.
|
||||||
|
DATE = (YEAR, MONTH, DAY)
|
||||||
|
|
||||||
# The hour when most test cases take place.
|
# The hour when most test cases take place.
|
||||||
NOON = 12
|
NOON = 12
|
||||||
|
|
|
@ -106,6 +106,7 @@ make_address = fake_data.make_address
|
||||||
make_courier = fake_data.make_courier
|
make_courier = fake_data.make_courier
|
||||||
make_customer = fake_data.make_customer
|
make_customer = fake_data.make_customer
|
||||||
make_order = fake_data.make_order
|
make_order = fake_data.make_order
|
||||||
|
make_replay_order = fake_data.make_replay_order
|
||||||
make_restaurant = fake_data.make_restaurant
|
make_restaurant = fake_data.make_restaurant
|
||||||
|
|
||||||
address = fake_data.address
|
address = fake_data.address
|
||||||
|
@ -114,6 +115,10 @@ city_data = fake_data.city_data
|
||||||
courier = fake_data.courier
|
courier = fake_data.courier
|
||||||
customer = fake_data.customer
|
customer = fake_data.customer
|
||||||
order = fake_data.order
|
order = fake_data.order
|
||||||
|
pre_order = fake_data.pre_order
|
||||||
|
replayed_order = fake_data.replayed_order
|
||||||
restaurant = fake_data.restaurant
|
restaurant = fake_data.restaurant
|
||||||
grid = fake_data.grid
|
grid = fake_data.grid
|
||||||
pixel = fake_data.pixel
|
pixel = fake_data.pixel
|
||||||
|
simulation = fake_data.simulation
|
||||||
|
simulation_data = fake_data.simulation_data
|
||||||
|
|
|
@ -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_courier
|
||||||
from tests.db.fake_data.fixture_makers import make_customer
|
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_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.fixture_makers import make_restaurant
|
||||||
from tests.db.fake_data.static_fixtures import address
|
from tests.db.fake_data.static_fixtures import address
|
||||||
from tests.db.fake_data.static_fixtures import city
|
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 grid
|
||||||
from tests.db.fake_data.static_fixtures import order
|
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 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 restaurant
|
||||||
|
from tests.db.fake_data.static_fixtures import simulation
|
||||||
|
from tests.db.fake_data.static_fixtures import simulation_data
|
||||||
|
|
9
tests/db/fake_data/factories/__init__.py
Normal file
9
tests/db/fake_data/factories/__init__.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
"""Factories to create instances for the SQLAlchemy models."""
|
||||||
|
|
||||||
|
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
|
40
tests/db/fake_data/factories/addresses.py
Normal file
40
tests/db/fake_data/factories/addresses.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
"""Factory to create `Address` instances."""
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
import factory
|
||||||
|
from factory import alchemy
|
||||||
|
|
||||||
|
from tests.db.fake_data.factories import utils
|
||||||
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
|
class AddressFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
|
"""Create instances of the `db.Address` model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = db.Address
|
||||||
|
sqlalchemy_get_or_create = ('id',)
|
||||||
|
|
||||||
|
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
||||||
|
created_at = factory.LazyFunction(utils.early_in_the_morning)
|
||||||
|
|
||||||
|
# When testing, all addresses are considered primary ones.
|
||||||
|
# As non-primary addresses have no different behavior and
|
||||||
|
# the property is only kept from the original dataset for
|
||||||
|
# completeness sake, that is ok to do.
|
||||||
|
primary_id = factory.LazyAttribute(lambda obj: obj.id)
|
||||||
|
|
||||||
|
# Mimic a Google Maps Place ID with just random characters.
|
||||||
|
place_id = factory.LazyFunction(
|
||||||
|
lambda: ''.join(random.choice(string.ascii_lowercase) for _ in range(20)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Place the addresses somewhere in downtown Paris.
|
||||||
|
latitude = factory.Faker('coordinate', center=48.855, radius=0.01)
|
||||||
|
longitude = factory.Faker('coordinate', center=2.34, radius=0.03)
|
||||||
|
# city -> set by the `make_address` fixture as there is only one `city`
|
||||||
|
city_name = 'Paris'
|
||||||
|
zip_code = factory.LazyFunction(lambda: random.randint(75001, 75020))
|
||||||
|
street = factory.Faker('street_address', locale='fr_FR')
|
24
tests/db/fake_data/factories/couriers.py
Normal file
24
tests/db/fake_data/factories/couriers.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
"""Factory to create `Courier` instances."""
|
||||||
|
|
||||||
|
|
||||||
|
import factory
|
||||||
|
from factory import alchemy
|
||||||
|
|
||||||
|
from tests.db.fake_data.factories import utils
|
||||||
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
|
class CourierFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
|
"""Create instances of the `db.Courier` model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = db.Courier
|
||||||
|
sqlalchemy_get_or_create = ('id',)
|
||||||
|
|
||||||
|
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
||||||
|
created_at = factory.LazyFunction(utils.early_in_the_morning)
|
||||||
|
vehicle = 'bicycle'
|
||||||
|
historic_speed = 7.89
|
||||||
|
capacity = 100
|
||||||
|
pay_per_hour = 750
|
||||||
|
pay_per_order = 200
|
16
tests/db/fake_data/factories/customers.py
Normal file
16
tests/db/fake_data/factories/customers.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
"""Factory to create `Customers` instances."""
|
||||||
|
|
||||||
|
import factory
|
||||||
|
from factory import alchemy
|
||||||
|
|
||||||
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
|
"""Create instances of the `db.Customer` model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = db.Customer
|
||||||
|
sqlalchemy_get_or_create = ('id',)
|
||||||
|
|
||||||
|
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
5
tests/db/fake_data/factories/orders/__init__.py
Normal file
5
tests/db/fake_data/factories/orders/__init__.py
Normal file
|
@ -0,0 +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
|
|
@ -1,114 +1,17 @@
|
||||||
"""Factories to create instances for the SQLAlchemy models."""
|
"""Factory to create ad-hoc `Order` instances."""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import random
|
import random
|
||||||
import string
|
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
import faker
|
|
||||||
from factory import alchemy
|
from factory import alchemy
|
||||||
from geopy import distance
|
from geopy import distance
|
||||||
|
|
||||||
from tests import config as test_config
|
from tests import config as test_config
|
||||||
|
from tests.db.fake_data.factories import utils
|
||||||
from urban_meal_delivery import db
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
def _random_timespan( # noqa:WPS211
|
|
||||||
*,
|
|
||||||
min_hours=0,
|
|
||||||
min_minutes=0,
|
|
||||||
min_seconds=0,
|
|
||||||
max_hours=0,
|
|
||||||
max_minutes=0,
|
|
||||||
max_seconds=0,
|
|
||||||
):
|
|
||||||
"""A randomized `timedelta` object between the specified arguments."""
|
|
||||||
total_min_seconds = min_hours * 3600 + min_minutes * 60 + min_seconds
|
|
||||||
total_max_seconds = max_hours * 3600 + max_minutes * 60 + max_seconds
|
|
||||||
return dt.timedelta(seconds=random.randint(total_min_seconds, total_max_seconds))
|
|
||||||
|
|
||||||
|
|
||||||
def _early_in_the_morning():
|
|
||||||
"""A randomized `datetime` object early in the morning."""
|
|
||||||
early = dt.datetime(test_config.YEAR, test_config.MONTH, test_config.DAY, 3, 0)
|
|
||||||
return early + _random_timespan(max_hours=2)
|
|
||||||
|
|
||||||
|
|
||||||
class AddressFactory(alchemy.SQLAlchemyModelFactory):
|
|
||||||
"""Create instances of the `db.Address` model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = db.Address
|
|
||||||
sqlalchemy_get_or_create = ('id',)
|
|
||||||
|
|
||||||
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
|
||||||
created_at = factory.LazyFunction(_early_in_the_morning)
|
|
||||||
|
|
||||||
# When testing, all addresses are considered primary ones.
|
|
||||||
# As non-primary addresses have no different behavior and
|
|
||||||
# the property is only kept from the original dataset for
|
|
||||||
# completeness sake, that is ok to do.
|
|
||||||
primary_id = factory.LazyAttribute(lambda obj: obj.id)
|
|
||||||
|
|
||||||
# Mimic a Google Maps Place ID with just random characters.
|
|
||||||
place_id = factory.LazyFunction(
|
|
||||||
lambda: ''.join(random.choice(string.ascii_lowercase) for _ in range(20)),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Place the addresses somewhere in downtown Paris.
|
|
||||||
latitude = factory.Faker('coordinate', center=48.855, radius=0.01)
|
|
||||||
longitude = factory.Faker('coordinate', center=2.34, radius=0.03)
|
|
||||||
# city -> set by the `make_address` fixture as there is only one `city`
|
|
||||||
city_name = 'Paris'
|
|
||||||
zip_code = factory.LazyFunction(lambda: random.randint(75001, 75020))
|
|
||||||
street = factory.Faker('street_address', locale='fr_FR')
|
|
||||||
|
|
||||||
|
|
||||||
class CourierFactory(alchemy.SQLAlchemyModelFactory):
|
|
||||||
"""Create instances of the `db.Courier` model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = db.Courier
|
|
||||||
sqlalchemy_get_or_create = ('id',)
|
|
||||||
|
|
||||||
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
|
||||||
created_at = factory.LazyFunction(_early_in_the_morning)
|
|
||||||
vehicle = 'bicycle'
|
|
||||||
historic_speed = 7.89
|
|
||||||
capacity = 100
|
|
||||||
pay_per_hour = 750
|
|
||||||
pay_per_order = 200
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerFactory(alchemy.SQLAlchemyModelFactory):
|
|
||||||
"""Create instances of the `db.Customer` model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = db.Customer
|
|
||||||
sqlalchemy_get_or_create = ('id',)
|
|
||||||
|
|
||||||
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
|
||||||
|
|
||||||
|
|
||||||
_restaurant_names = faker.Faker()
|
|
||||||
|
|
||||||
|
|
||||||
class RestaurantFactory(alchemy.SQLAlchemyModelFactory):
|
|
||||||
"""Create instances of the `db.Restaurant` model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = db.Restaurant
|
|
||||||
sqlalchemy_get_or_create = ('id',)
|
|
||||||
|
|
||||||
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
|
||||||
created_at = factory.LazyFunction(_early_in_the_morning)
|
|
||||||
name = factory.LazyFunction(
|
|
||||||
lambda: f"{_restaurant_names.first_name()}'s Restaurant",
|
|
||||||
)
|
|
||||||
# address -> set by the `make_restaurant` fixture as there is only one `city`
|
|
||||||
estimated_prep_duration = 1000
|
|
||||||
|
|
||||||
|
|
||||||
class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
"""Create instances of the `db.Order` model.
|
"""Create instances of the `db.Order` model.
|
||||||
|
|
||||||
|
@ -145,7 +48,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
cancel_=True,
|
cancel_=True,
|
||||||
cancelled_at=factory.LazyAttribute(
|
cancelled_at=factory.LazyAttribute(
|
||||||
lambda obj: obj.dispatch_at
|
lambda obj: obj.dispatch_at
|
||||||
+ _random_timespan(
|
+ utils.random_timespan(
|
||||||
max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(),
|
max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -154,7 +57,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
cancel_=True,
|
cancel_=True,
|
||||||
cancelled_at=factory.LazyAttribute(
|
cancelled_at=factory.LazyAttribute(
|
||||||
lambda obj: obj.pickup_at
|
lambda obj: obj.pickup_at
|
||||||
+ _random_timespan(
|
+ utils.random_timespan(
|
||||||
max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(),
|
max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -167,10 +70,8 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
|
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
|
||||||
# Ad-hoc `Order`s are placed between 11.45 and 14.15.
|
# Ad-hoc `Order`s are placed between 11.45 and 14.15.
|
||||||
placed_at = factory.LazyFunction(
|
placed_at = factory.LazyFunction(
|
||||||
lambda: dt.datetime(
|
lambda: dt.datetime(*test_config.DATE, 11, 45)
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 45,
|
+ utils.random_timespan(max_hours=2, max_minutes=30),
|
||||||
)
|
|
||||||
+ _random_timespan(max_hours=2, max_minutes=30),
|
|
||||||
)
|
)
|
||||||
ad_hoc = True
|
ad_hoc = True
|
||||||
scheduled_delivery_at = None
|
scheduled_delivery_at = None
|
||||||
|
@ -194,14 +95,19 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
# Restaurant-related attributes
|
# Restaurant-related attributes
|
||||||
# restaurant -> set by the `make_order` fixture for better control
|
# restaurant -> set by the `make_order` fixture for better control
|
||||||
restaurant_notified_at = factory.LazyAttribute(
|
restaurant_notified_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.placed_at + _random_timespan(min_seconds=30, max_seconds=90),
|
lambda obj: obj.placed_at
|
||||||
|
+ utils.random_timespan(min_seconds=30, max_seconds=90),
|
||||||
|
)
|
||||||
|
restaurant_notified_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.restaurant_notified_at else None,
|
||||||
)
|
)
|
||||||
restaurant_notified_at_corrected = False
|
|
||||||
restaurant_confirmed_at = factory.LazyAttribute(
|
restaurant_confirmed_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.restaurant_notified_at
|
lambda obj: obj.restaurant_notified_at
|
||||||
+ _random_timespan(min_seconds=30, max_seconds=150),
|
+ utils.random_timespan(min_seconds=30, max_seconds=150),
|
||||||
|
)
|
||||||
|
restaurant_confirmed_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.restaurant_confirmed_at else None,
|
||||||
)
|
)
|
||||||
restaurant_confirmed_at_corrected = False
|
|
||||||
# Use the database defaults of the historic data.
|
# Use the database defaults of the historic data.
|
||||||
estimated_prep_duration = 900
|
estimated_prep_duration = 900
|
||||||
estimated_prep_duration_corrected = False
|
estimated_prep_duration_corrected = False
|
||||||
|
@ -210,19 +116,26 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
# Dispatch-related columns
|
# Dispatch-related columns
|
||||||
# courier -> set by the `make_order` fixture for better control
|
# courier -> set by the `make_order` fixture for better control
|
||||||
dispatch_at = factory.LazyAttribute(
|
dispatch_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.placed_at + _random_timespan(min_seconds=600, max_seconds=1080),
|
lambda obj: obj.placed_at
|
||||||
|
+ utils.random_timespan(min_seconds=600, max_seconds=1080),
|
||||||
|
)
|
||||||
|
dispatch_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.dispatch_at else None,
|
||||||
)
|
)
|
||||||
dispatch_at_corrected = False
|
|
||||||
courier_notified_at = factory.LazyAttribute(
|
courier_notified_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.dispatch_at
|
lambda obj: obj.dispatch_at
|
||||||
+ _random_timespan(min_seconds=100, max_seconds=140),
|
+ utils.random_timespan(min_seconds=100, max_seconds=140),
|
||||||
|
)
|
||||||
|
courier_notified_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.courier_notified_at else None,
|
||||||
)
|
)
|
||||||
courier_notified_at_corrected = False
|
|
||||||
courier_accepted_at = factory.LazyAttribute(
|
courier_accepted_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.courier_notified_at
|
lambda obj: obj.courier_notified_at
|
||||||
+ _random_timespan(min_seconds=15, max_seconds=45),
|
+ utils.random_timespan(min_seconds=15, max_seconds=45),
|
||||||
|
)
|
||||||
|
courier_accepted_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.courier_accepted_at else None,
|
||||||
)
|
)
|
||||||
courier_accepted_at_corrected = False
|
|
||||||
# Sample a realistic utilization.
|
# Sample a realistic utilization.
|
||||||
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
|
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
|
||||||
|
|
||||||
|
@ -230,30 +143,37 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
# pickup_address -> aligned with `restaurant.address` by the `make_order` fixture
|
# pickup_address -> aligned with `restaurant.address` by the `make_order` fixture
|
||||||
reached_pickup_at = factory.LazyAttribute(
|
reached_pickup_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.courier_accepted_at
|
lambda obj: obj.courier_accepted_at
|
||||||
+ _random_timespan(min_seconds=300, max_seconds=600),
|
+ utils.random_timespan(min_seconds=300, max_seconds=600),
|
||||||
)
|
)
|
||||||
pickup_at = factory.LazyAttribute(
|
pickup_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.reached_pickup_at
|
lambda obj: obj.reached_pickup_at
|
||||||
+ _random_timespan(min_seconds=120, max_seconds=600),
|
+ utils.random_timespan(min_seconds=120, max_seconds=600),
|
||||||
|
)
|
||||||
|
pickup_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.pickup_at else None,
|
||||||
)
|
)
|
||||||
pickup_at_corrected = False
|
|
||||||
pickup_not_confirmed = False
|
pickup_not_confirmed = False
|
||||||
left_pickup_at = factory.LazyAttribute(
|
left_pickup_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.pickup_at + _random_timespan(min_seconds=60, max_seconds=180),
|
lambda obj: obj.pickup_at
|
||||||
|
+ utils.random_timespan(min_seconds=60, max_seconds=180),
|
||||||
|
)
|
||||||
|
left_pickup_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.left_pickup_at else None,
|
||||||
)
|
)
|
||||||
left_pickup_at_corrected = False
|
|
||||||
|
|
||||||
# Delivery-related attributes
|
# Delivery-related attributes
|
||||||
# delivery_address -> set by the `make_order` fixture as there is only one `city`
|
# delivery_address -> set by the `make_order` fixture as there is only one `city`
|
||||||
reached_delivery_at = factory.LazyAttribute(
|
reached_delivery_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.left_pickup_at
|
lambda obj: obj.left_pickup_at
|
||||||
+ _random_timespan(min_seconds=240, max_seconds=480),
|
+ utils.random_timespan(min_seconds=240, max_seconds=480),
|
||||||
)
|
)
|
||||||
delivery_at = factory.LazyAttribute(
|
delivery_at = factory.LazyAttribute(
|
||||||
lambda obj: obj.reached_delivery_at
|
lambda obj: obj.reached_delivery_at
|
||||||
+ _random_timespan(min_seconds=240, max_seconds=660),
|
+ utils.random_timespan(min_seconds=240, max_seconds=660),
|
||||||
|
)
|
||||||
|
delivery_at_corrected = factory.LazyAttribute(
|
||||||
|
lambda obj: False if obj.delivery_at else None,
|
||||||
)
|
)
|
||||||
delivery_at_corrected = False
|
|
||||||
delivery_not_confirmed = False
|
delivery_not_confirmed = False
|
||||||
_courier_waited_at_delivery = factory.LazyAttribute(
|
_courier_waited_at_delivery = factory.LazyAttribute(
|
||||||
lambda obj: False if obj.delivery_at else None,
|
lambda obj: False if obj.delivery_at else None,
|
||||||
|
@ -280,9 +200,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
)
|
)
|
||||||
|
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def post( # noqa:C901,WPS231
|
def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
|
||||||
obj, create, extracted, **kwargs, # noqa:B902,N805
|
|
||||||
):
|
|
||||||
"""Discard timestamps that occur after cancellation."""
|
"""Discard timestamps that occur after cancellation."""
|
||||||
if obj.cancelled:
|
if obj.cancelled:
|
||||||
if obj.cancelled_at <= obj.restaurant_notified_at:
|
if obj.cancelled_at <= obj.restaurant_notified_at:
|
||||||
|
@ -316,63 +234,3 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
obj.delivery_at_corrected = None
|
obj.delivery_at_corrected = None
|
||||||
obj.delivery_not_confirmed = None
|
obj.delivery_not_confirmed = None
|
||||||
obj._courier_waited_at_delivery = None
|
obj._courier_waited_at_delivery = None
|
||||||
|
|
||||||
|
|
||||||
class ScheduledOrderFactory(AdHocOrderFactory):
|
|
||||||
"""Create instances of the `db.Order` model.
|
|
||||||
|
|
||||||
This class takes care of the various timestamps for pre-orders.
|
|
||||||
|
|
||||||
Pre-orders are placed long before the test day's lunch time starts.
|
|
||||||
All timestamps are relative to either `.dispatch_at` or `.restaurant_notified_at`
|
|
||||||
and calculated backwards from `.scheduled_delivery_at`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
|
|
||||||
placed_at = factory.LazyFunction(_early_in_the_morning)
|
|
||||||
ad_hoc = False
|
|
||||||
# Discrete `datetime` objects in the "core" lunch time are enough.
|
|
||||||
scheduled_delivery_at = factory.LazyFunction(
|
|
||||||
lambda: random.choice(
|
|
||||||
[
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 0,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 15,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 30,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 45,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 0,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 15,
|
|
||||||
),
|
|
||||||
dt.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 30,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
scheduled_delivery_at_corrected = False
|
|
||||||
# Assume the `Order` is on time.
|
|
||||||
first_estimated_delivery_at = factory.LazyAttribute(
|
|
||||||
lambda obj: obj.scheduled_delivery_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Restaurant-related attributes
|
|
||||||
restaurant_notified_at = factory.LazyAttribute(
|
|
||||||
lambda obj: obj.scheduled_delivery_at
|
|
||||||
- _random_timespan(min_minutes=45, max_minutes=50),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Dispatch-related attributes
|
|
||||||
dispatch_at = factory.LazyAttribute(
|
|
||||||
lambda obj: obj.scheduled_delivery_at
|
|
||||||
- _random_timespan(min_minutes=40, max_minutes=45),
|
|
||||||
)
|
|
120
tests/db/fake_data/factories/orders/replayed.py
Normal file
120
tests/db/fake_data/factories/orders/replayed.py
Normal 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
|
56
tests/db/fake_data/factories/orders/scheduled.py
Normal file
56
tests/db/fake_data/factories/orders/scheduled.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
"""Factory to create scheduled `Order` instances."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import random
|
||||||
|
|
||||||
|
import factory
|
||||||
|
|
||||||
|
from tests import config as test_config
|
||||||
|
from tests.db.fake_data.factories import utils
|
||||||
|
from tests.db.fake_data.factories.orders import ad_hoc
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledOrderFactory(ad_hoc.AdHocOrderFactory):
|
||||||
|
"""Create instances of the `db.Order` model.
|
||||||
|
|
||||||
|
This class takes care of the various timestamps for pre-orders.
|
||||||
|
|
||||||
|
Pre-orders are placed long before the test day's lunch time starts.
|
||||||
|
All timestamps are relative to either `.dispatch_at` or `.restaurant_notified_at`
|
||||||
|
and calculated backwards from `.scheduled_delivery_at`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
|
||||||
|
placed_at = factory.LazyFunction(utils.early_in_the_morning)
|
||||||
|
ad_hoc = False
|
||||||
|
# Discrete `datetime` objects in the "core" lunch time are enough.
|
||||||
|
scheduled_delivery_at = factory.LazyFunction(
|
||||||
|
lambda: random.choice(
|
||||||
|
[
|
||||||
|
dt.datetime(*test_config.DATE, 12, 0),
|
||||||
|
dt.datetime(*test_config.DATE, 12, 15),
|
||||||
|
dt.datetime(*test_config.DATE, 12, 30),
|
||||||
|
dt.datetime(*test_config.DATE, 12, 45),
|
||||||
|
dt.datetime(*test_config.DATE, 13, 0),
|
||||||
|
dt.datetime(*test_config.DATE, 13, 15),
|
||||||
|
dt.datetime(*test_config.DATE, 13, 30),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scheduled_delivery_at_corrected = False
|
||||||
|
# Assume the `Order` is on time.
|
||||||
|
first_estimated_delivery_at = factory.LazyAttribute(
|
||||||
|
lambda obj: obj.scheduled_delivery_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restaurant-related attributes
|
||||||
|
restaurant_notified_at = factory.LazyAttribute(
|
||||||
|
lambda obj: obj.scheduled_delivery_at
|
||||||
|
- utils.random_timespan(min_minutes=45, max_minutes=50),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dispatch-related attributes
|
||||||
|
dispatch_at = factory.LazyAttribute(
|
||||||
|
lambda obj: obj.scheduled_delivery_at
|
||||||
|
- utils.random_timespan(min_minutes=40, max_minutes=45),
|
||||||
|
)
|
27
tests/db/fake_data/factories/restaurants.py
Normal file
27
tests/db/fake_data/factories/restaurants.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""Factory to create `Restaurant` instances."""
|
||||||
|
|
||||||
|
import factory
|
||||||
|
import faker
|
||||||
|
from factory import alchemy
|
||||||
|
|
||||||
|
from tests.db.fake_data.factories import utils
|
||||||
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
|
_restaurant_names = faker.Faker()
|
||||||
|
|
||||||
|
|
||||||
|
class RestaurantFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
|
"""Create instances of the `db.Restaurant` model."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = db.Restaurant
|
||||||
|
sqlalchemy_get_or_create = ('id',)
|
||||||
|
|
||||||
|
id = factory.Sequence(lambda num: num) # noqa:WPS125
|
||||||
|
created_at = factory.LazyFunction(utils.early_in_the_morning)
|
||||||
|
name = factory.LazyFunction(
|
||||||
|
lambda: f"{_restaurant_names.first_name()}'s Restaurant",
|
||||||
|
)
|
||||||
|
# address -> set by the `make_restaurant` fixture as there is only one `city`
|
||||||
|
estimated_prep_duration = 1000
|
27
tests/db/fake_data/factories/utils.py
Normal file
27
tests/db/fake_data/factories/utils.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
"""Utilities used in all `*Factory` classes."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import random
|
||||||
|
|
||||||
|
from tests import config as test_config
|
||||||
|
|
||||||
|
|
||||||
|
def random_timespan( # noqa:WPS211
|
||||||
|
*,
|
||||||
|
min_hours=0,
|
||||||
|
min_minutes=0,
|
||||||
|
min_seconds=0,
|
||||||
|
max_hours=0,
|
||||||
|
max_minutes=0,
|
||||||
|
max_seconds=0,
|
||||||
|
):
|
||||||
|
"""A randomized `timedelta` object between the specified arguments."""
|
||||||
|
total_min_seconds = min_hours * 3600 + min_minutes * 60 + min_seconds
|
||||||
|
total_max_seconds = max_hours * 3600 + max_minutes * 60 + max_seconds
|
||||||
|
return dt.timedelta(seconds=random.randint(total_min_seconds, total_max_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def early_in_the_morning():
|
||||||
|
"""A randomized `datetime` object early in the morning."""
|
||||||
|
early = dt.datetime(*test_config.DATE, 3, 0)
|
||||||
|
return early + random_timespan(max_hours=2)
|
|
@ -103,3 +103,41 @@ def make_order(make_address, make_courier, make_customer, make_restaurant):
|
||||||
)
|
)
|
||||||
|
|
||||||
return func
|
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
|
||||||
|
|
|
@ -54,10 +54,22 @@ def restaurant(address, make_restaurant):
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def order(make_order, restaurant):
|
def order(make_order, restaurant):
|
||||||
"""An `Order` object for the `restaurant`."""
|
"""An ad-hoc `Order` object for the `restaurant`."""
|
||||||
return make_order(restaurant=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
|
@pytest.fixture
|
||||||
def grid(city):
|
def grid(city):
|
||||||
"""A `Grid` with a pixel area of 1 square kilometer."""
|
"""A `Grid` with a pixel area of 1 square kilometer."""
|
||||||
|
@ -68,3 +80,20 @@ def grid(city):
|
||||||
def pixel(grid):
|
def pixel(grid):
|
||||||
"""The `Pixel` in the lower-left corner of the `grid`."""
|
"""The `Pixel` in the lower-left corner of the `grid`."""
|
||||||
return db.Pixel(id=1, grid=grid, n_x=0, n_y=0)
|
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)
|
||||||
|
|
1
tests/db/replay/__init__.py
Normal file
1
tests/db/replay/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Test the replay simulation models in the ORM layer."""
|
585
tests/db/replay/test_orders.py
Normal file
585
tests/db/replay/test_orders.py
Normal 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
|
17
tests/db/replay/test_simulations.py
Normal file
17
tests/db/replay/test_simulations.py
Normal 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
|
|
@ -377,7 +377,10 @@ class TestSyncWithGoogleMaps:
|
||||||
'copyrights': 'Map data ©2021',
|
'copyrights': 'Map data ©2021',
|
||||||
'legs': [
|
'legs': [
|
||||||
{
|
{
|
||||||
'distance': {'text': '3.0 km', 'value': 2999},
|
# We choose an artificially high distance of `9999`
|
||||||
|
# so that the synced bicycle distance is longer than
|
||||||
|
# the randomized air distance (i.e., fake data) for sure.
|
||||||
|
'distance': {'text': '3.0 km', 'value': 9999},
|
||||||
'duration': {'text': '10 mins', 'value': 596},
|
'duration': {'text': '10 mins', 'value': 596},
|
||||||
'end_address': '13 Place Paul et Jean Paul Avisseau, ...',
|
'end_address': '13 Place Paul et Jean Paul Avisseau, ...',
|
||||||
'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105},
|
'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105},
|
||||||
|
@ -633,7 +636,7 @@ class TestSyncWithGoogleMaps:
|
||||||
|
|
||||||
path.sync_with_google_maps()
|
path.sync_with_google_maps()
|
||||||
|
|
||||||
assert path.bicycle_distance == 2_999
|
assert path.bicycle_distance == 9_999
|
||||||
assert path.bicycle_duration == 596
|
assert path.bicycle_duration == 596
|
||||||
assert path._directions is not None
|
assert path._directions is not None
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
"""Test the ORM's `Order` model."""
|
"""Test the ORM's `Order` model."""
|
||||||
|
|
||||||
import datetime
|
import datetime as dt
|
||||||
import random
|
import random
|
||||||
|
|
||||||
import pytest
|
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
|
from urban_meal_delivery import db
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,8 +30,10 @@ class TestSpecialMethods:
|
||||||
class TestConstraints:
|
class TestConstraints:
|
||||||
"""Test the database constraints defined in `Order`."""
|
"""Test the database constraints defined in `Order`."""
|
||||||
|
|
||||||
def test_insert_into_database(self, db_session, order):
|
def test_insert_ad_hoc_order_into_into_database(self, db_session, order):
|
||||||
"""Insert an instance into the (empty) database."""
|
"""Insert an instance into the (empty) database."""
|
||||||
|
assert order.ad_hoc is True
|
||||||
|
|
||||||
assert db_session.query(db.Order).count() == 0
|
assert db_session.query(db.Order).count() == 0
|
||||||
|
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
@ -36,9 +41,602 @@ class TestConstraints:
|
||||||
|
|
||||||
assert db_session.query(db.Order).count() == 1
|
assert db_session.query(db.Order).count() == 1
|
||||||
|
|
||||||
# TODO (order-constraints): the various Foreign Key and Check Constraints
|
def test_insert_scheduled_order_into_into_database(self, db_session, pre_order):
|
||||||
# should be tested eventually. This is not of highest importance as
|
"""Insert an instance into the (empty) database."""
|
||||||
# we have a lot of confidence from the data cleaning notebook.
|
assert pre_order.ad_hoc is False
|
||||||
|
|
||||||
|
assert db_session.query(db.Order).count() == 0
|
||||||
|
|
||||||
|
db_session.add(pre_order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert db_session.query(db.Order).count() == 1
|
||||||
|
|
||||||
|
def test_delete_a_referenced_customer(self, db_session, order):
|
||||||
|
"""Remove a record that is referenced with a FK."""
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||||
|
stmt = sqla.delete(db.Customer).where(db.Customer.id == order.customer.id)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_customers'):
|
||||||
|
db_session.execute(stmt)
|
||||||
|
|
||||||
|
def test_delete_a_referenced_courier(self, db_session, order):
|
||||||
|
"""Remove a record that is referenced with a FK."""
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||||
|
stmt = sqla.delete(db.Courier).where(db.Courier.id == order.courier.id)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_couriers'):
|
||||||
|
db_session.execute(stmt)
|
||||||
|
|
||||||
|
def test_delete_a_referenced_restaurant(self, db_session, order):
|
||||||
|
"""Remove a record that is referenced with a FK."""
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||||
|
stmt = sqla.delete(db.Restaurant).where(db.Restaurant.id == order.restaurant.id)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_restaurants'):
|
||||||
|
db_session.execute(stmt)
|
||||||
|
|
||||||
|
def test_change_a_referenced_pickup_address(self, db_session, order, make_address):
|
||||||
|
"""Remove a record that is referenced with a FK.
|
||||||
|
|
||||||
|
Each `Restaurant` may only have one `Address` in the dataset.
|
||||||
|
"""
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Give the `restaurant` another `address`.
|
||||||
|
order.restaurant.address = make_address()
|
||||||
|
db_session.add(order.restaurant.address)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_restaurants'):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Here should be a test to check deletion of a referenced pickup address, so
|
||||||
|
# `test_delete_a_referenced_pickup_address(self, db_session, order)`.
|
||||||
|
# The corresponding "fk_orders_to_addresses_on_pickup_address_id" constraint
|
||||||
|
# is very hard to test in isolation as the above "fk_orders_to_restaurants_..."
|
||||||
|
# constraint ensures its integrity already.
|
||||||
|
|
||||||
|
def test_delete_a_referenced_delivery_address(self, db_session, order):
|
||||||
|
"""Remove a record that is referenced with a FK."""
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||||
|
stmt = sqla.delete(db.Address).where(db.Address.id == order.delivery_address.id)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_addresses'):
|
||||||
|
db_session.execute(stmt)
|
||||||
|
|
||||||
|
def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
assert order.ad_hoc is True
|
||||||
|
|
||||||
|
order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0)
|
||||||
|
order.scheduled_delivery_at_corrected = False
|
||||||
|
|
||||||
|
db_session.add(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, pre_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
assert pre_order.ad_hoc is False
|
||||||
|
|
||||||
|
pre_order.scheduled_delivery_at = None
|
||||||
|
pre_order.scheduled_delivery_at_corrected = None
|
||||||
|
|
||||||
|
db_session.add(pre_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_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order = make_order(placed_at=dt.datetime(*test_config.DATE, 10, 0))
|
||||||
|
|
||||||
|
db_session.add(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_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order = make_order(placed_at=dt.datetime(*test_config.DATE, 23, 0))
|
||||||
|
|
||||||
|
db_session.add(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_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True, scheduled_delivery_at=dt.datetime(*test_config.DATE, 10, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(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_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True,
|
||||||
|
scheduled_delivery_at=dt.datetime(*test_config.DATE, 11, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(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_order):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
11.45 is the only time outside noon to 11 pm when a scheduled order is allowed.
|
||||||
|
"""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True,
|
||||||
|
scheduled_delivery_at=dt.datetime(*test_config.DATE, 11, 45),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert db_session.query(db.Order).count() == 0
|
||||||
|
|
||||||
|
db_session.add(pre_order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert db_session.query(db.Order).count() == 1
|
||||||
|
|
||||||
|
def test_scheduled_order_too_late(self, db_session, make_order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True, scheduled_delivery_at=dt.datetime(*test_config.DATE, 23, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(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_order, minute,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True,
|
||||||
|
scheduled_delivery_at=dt.datetime( # `minute` is not 0, 15, 30, or 45
|
||||||
|
*test_config.DATE, test_config.NOON, minute,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(pre_order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError,
|
||||||
|
match='scheduled_orders_must_be_at_quarters_of_an_hour',
|
||||||
|
):
|
||||||
|
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_order, second,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
pre_order = make_order(
|
||||||
|
scheduled=True,
|
||||||
|
scheduled_delivery_at=dt.datetime(
|
||||||
|
*test_config.DATE, test_config.NOON, 0, second,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(pre_order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError,
|
||||||
|
match='scheduled_orders_must_be_at_quarters_of_an_hour',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_scheduled_order_too_soon(self, db_session, make_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.
|
||||||
|
pre_order = make_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).
|
||||||
|
pre_order.ad_hoc = False
|
||||||
|
minutes_to_next_quarter = 15 - (pre_order.placed_at.minute % 15)
|
||||||
|
pre_order.scheduled_delivery_at = (
|
||||||
|
# `.placed_at` may have non-0 seconds.
|
||||||
|
pre_order.placed_at.replace(second=0)
|
||||||
|
+ dt.timedelta(minutes=(minutes_to_next_quarter + 15))
|
||||||
|
)
|
||||||
|
pre_order.scheduled_delivery_at_corrected = False
|
||||||
|
|
||||||
|
db_session.add(pre_order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_uncancelled_order_has_cancelled_at(self, db_session, order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.cancelled_at = order.delivery_at
|
||||||
|
order.cancelled_at_corrected = False
|
||||||
|
order.delivery_at = None
|
||||||
|
order.delivery_at_corrected = None
|
||||||
|
order.delivery_not_confirmed = None
|
||||||
|
order._courier_waited_at_delivery = None
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='only_cancelled_orders_may_have_cancelled_at',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_cancelled_order_is_delivered(self, db_session, order):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.cancelled = True
|
||||||
|
order.cancelled_at = order.delivery_at + dt.timedelta(seconds=1)
|
||||||
|
order.cancelled_at_corrected = False
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='cancelled_orders_must_not_be_delivered',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration', [-1, 2701])
|
||||||
|
def test_estimated_prep_duration_out_of_range(self, db_session, order, duration):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.estimated_prep_duration = duration
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='between_0_and_2700',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration', [1, 59, 119, 2699])
|
||||||
|
def test_estimated_prep_duration_not_whole_minute(
|
||||||
|
self, db_session, order, duration,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.estimated_prep_duration = duration
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='must_be_whole_minutes',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration', [-1, 901])
|
||||||
|
def test_estimated_prep_buffer_out_of_range(self, db_session, order, duration):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.estimated_prep_buffer = duration
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='between_0_and_900',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('duration', [1, 59, 119, 899])
|
||||||
|
def test_estimated_prep_buffer_not_whole_minute(self, db_session, order, duration):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.estimated_prep_buffer = duration
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='must_be_whole_minutes',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('utilization', [-1, 101])
|
||||||
|
def test_utilization_out_of_range(self, db_session, order, utilization):
|
||||||
|
"""Insert an instance with invalid data."""
|
||||||
|
order.utilization = utilization
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='between_0_and_100',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'column',
|
||||||
|
[
|
||||||
|
'scheduled_delivery_at',
|
||||||
|
'cancelled_at',
|
||||||
|
'restaurant_notified_at',
|
||||||
|
'restaurant_confirmed_at',
|
||||||
|
'estimated_prep_duration',
|
||||||
|
'dispatch_at',
|
||||||
|
'courier_notified_at',
|
||||||
|
'courier_accepted_at',
|
||||||
|
'left_pickup_at',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unset_timestamp_column_not_marked_as_uncorrected( # noqa:WPS213
|
||||||
|
self, db_session, order, column,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
There are two special cases for this test case below,
|
||||||
|
where other attributes on `order` must be unset.
|
||||||
|
"""
|
||||||
|
# Set the actual timestamp to NULL.
|
||||||
|
setattr(order, column, None)
|
||||||
|
|
||||||
|
# Setting both the timestamp and its correction column to NULL is allowed.
|
||||||
|
setattr(order, f'{column}_corrected', None)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Also, an unset timestamp column may always be marked as corrected.
|
||||||
|
setattr(order, f'{column}_corrected', True)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# Without a timestamp set, a column may not be marked as uncorrected.
|
||||||
|
setattr(order, f'{column}_corrected', False)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='corrections_only_for_set_value',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_unset_timestamp_column_not_marked_as_uncorrected_special_case1(
|
||||||
|
self, db_session, order,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
order.pickup_not_confirmed = None
|
||||||
|
self.test_unset_timestamp_column_not_marked_as_uncorrected(
|
||||||
|
db_session, order, 'pickup_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unset_timestamp_column_not_marked_as_uncorrected_special_case2(
|
||||||
|
self, db_session, order,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
order.delivery_not_confirmed = None
|
||||||
|
order._courier_waited_at_delivery = None
|
||||||
|
self.test_unset_timestamp_column_not_marked_as_uncorrected(
|
||||||
|
db_session, order, 'delivery_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'column',
|
||||||
|
[
|
||||||
|
'restaurant_notified_at',
|
||||||
|
'restaurant_confirmed_at',
|
||||||
|
'estimated_prep_duration',
|
||||||
|
'dispatch_at',
|
||||||
|
'courier_notified_at',
|
||||||
|
'courier_accepted_at',
|
||||||
|
'pickup_at',
|
||||||
|
'left_pickup_at',
|
||||||
|
'delivery_at',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_set_timestamp_column_is_not_unmarked( # noqa:WPS213
|
||||||
|
self, db_session, order, column,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
There are two special cases for this test case below,
|
||||||
|
where other attributes on `order` must be unset.
|
||||||
|
"""
|
||||||
|
# Ensure the timestamp is set.
|
||||||
|
assert getattr(order, column) is not None
|
||||||
|
|
||||||
|
# A set timestamp may be marked as either corrected or uncorrected.
|
||||||
|
|
||||||
|
setattr(order, f'{column}_corrected', True)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
setattr(order, f'{column}_corrected', False)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
# A set timestamp may not be left unmarked.
|
||||||
|
setattr(order, f'{column}_corrected', None)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
sa_exc.IntegrityError, match='corrections_only_for_set_value',
|
||||||
|
):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_set_timestamp_column_is_not_unmarked_special_case1(
|
||||||
|
self, db_session, pre_order,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
self.test_set_timestamp_column_is_not_unmarked(
|
||||||
|
db_session, pre_order, 'scheduled_delivery_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_timestamp_column_is_not_unmarked_special_case2(
|
||||||
|
self, db_session, order,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
order.cancelled = True
|
||||||
|
order.cancelled_at = order.delivery_at
|
||||||
|
|
||||||
|
order.delivery_at = None
|
||||||
|
order.delivery_at_corrected = None
|
||||||
|
order.delivery_not_confirmed = None
|
||||||
|
order._courier_waited_at_delivery = None
|
||||||
|
|
||||||
|
self.test_set_timestamp_column_is_not_unmarked(
|
||||||
|
db_session, order, 'cancelled_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'comparison',
|
||||||
|
[
|
||||||
|
'placed_at < first_estimated_delivery_at',
|
||||||
|
'placed_at < restaurant_notified_at',
|
||||||
|
'placed_at < restaurant_confirmed_at',
|
||||||
|
'placed_at < dispatch_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 < pickup_at',
|
||||||
|
'restaurant_confirmed_at < pickup_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, order, comparison,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
There are two special cases for this test case below,
|
||||||
|
where other attributes on `order` must be unset.
|
||||||
|
"""
|
||||||
|
smaller, bigger = comparison.split(' < ')
|
||||||
|
|
||||||
|
assert smaller is not None
|
||||||
|
|
||||||
|
violating_timestamp = getattr(order, smaller) - dt.timedelta(seconds=1)
|
||||||
|
setattr(order, bigger, violating_timestamp)
|
||||||
|
setattr(order, f'{bigger}_corrected', False)
|
||||||
|
|
||||||
|
db_session.add(order)
|
||||||
|
|
||||||
|
with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'):
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
def test_timestamps_unordered_scheduled(self, db_session, pre_order):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
# As we subtract 1 second in the generic case,
|
||||||
|
# choose one second after a quarter of an hour.
|
||||||
|
pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1)
|
||||||
|
self.test_timestamps_unordered(
|
||||||
|
db_session, 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',
|
||||||
|
'dispatch_at < cancelled_at',
|
||||||
|
'courier_notified_at < cancelled_at',
|
||||||
|
'courier_accepted_at < cancelled_at',
|
||||||
|
'reached_pickup_at < cancelled_at',
|
||||||
|
'pickup_at < cancelled_at',
|
||||||
|
'left_pickup_at < cancelled_at',
|
||||||
|
'reached_delivery_at < cancelled_at',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_timestamps_unordered_cancelled(
|
||||||
|
self, db_session, order, comparison,
|
||||||
|
):
|
||||||
|
"""Insert an instance with invalid data.
|
||||||
|
|
||||||
|
This is one of two special cases. See the generic case above.
|
||||||
|
"""
|
||||||
|
order.cancelled = True
|
||||||
|
|
||||||
|
order.delivery_at = None
|
||||||
|
order.delivery_at_corrected = None
|
||||||
|
order.delivery_not_confirmed = None
|
||||||
|
order._courier_waited_at_delivery = None
|
||||||
|
|
||||||
|
self.test_timestamps_unordered(db_session, order, comparison)
|
||||||
|
|
||||||
|
|
||||||
class TestProperties:
|
class TestProperties:
|
||||||
|
@ -137,7 +735,7 @@ class TestProperties:
|
||||||
"""Test `Order.time_to_accept` property."""
|
"""Test `Order.time_to_accept` property."""
|
||||||
result = order.time_to_accept
|
result = order.time_to_accept
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_time_to_react_no_courier_notified(self, order):
|
def test_time_to_react_no_courier_notified(self, order):
|
||||||
"""Test `Order.time_to_react` property."""
|
"""Test `Order.time_to_react` property."""
|
||||||
|
@ -157,7 +755,7 @@ class TestProperties:
|
||||||
"""Test `Order.time_to_react` property."""
|
"""Test `Order.time_to_react` property."""
|
||||||
result = order.time_to_react
|
result = order.time_to_react
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_time_to_pickup_no_reached_pickup_at(self, order):
|
def test_time_to_pickup_no_reached_pickup_at(self, order):
|
||||||
"""Test `Order.time_to_pickup` property."""
|
"""Test `Order.time_to_pickup` property."""
|
||||||
|
@ -177,7 +775,7 @@ class TestProperties:
|
||||||
"""Test `Order.time_to_pickup` property."""
|
"""Test `Order.time_to_pickup` property."""
|
||||||
result = order.time_to_pickup
|
result = order.time_to_pickup
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_time_at_pickup_no_reached_pickup_at(self, order):
|
def test_time_at_pickup_no_reached_pickup_at(self, order):
|
||||||
"""Test `Order.time_at_pickup` property."""
|
"""Test `Order.time_at_pickup` property."""
|
||||||
|
@ -197,7 +795,7 @@ class TestProperties:
|
||||||
"""Test `Order.time_at_pickup` property."""
|
"""Test `Order.time_at_pickup` property."""
|
||||||
result = order.time_at_pickup
|
result = order.time_at_pickup
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118
|
def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118
|
||||||
"""Test `Order.scheduled_pickup_at` property."""
|
"""Test `Order.scheduled_pickup_at` property."""
|
||||||
|
@ -309,7 +907,7 @@ class TestProperties:
|
||||||
"""Test `Order.time_to_delivery` property."""
|
"""Test `Order.time_to_delivery` property."""
|
||||||
result = order.time_to_delivery
|
result = order.time_to_delivery
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118
|
def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118
|
||||||
"""Test `Order.time_at_delivery` property."""
|
"""Test `Order.time_at_delivery` property."""
|
||||||
|
@ -329,9 +927,9 @@ class TestProperties:
|
||||||
"""Test `Order.time_at_delivery` property."""
|
"""Test `Order.time_at_delivery` property."""
|
||||||
result = order.time_at_delivery
|
result = order.time_at_delivery
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
def test_courier_waited_at_delviery(self, order):
|
def test_courier_waited_at_delivery(self, order):
|
||||||
"""Test `Order.courier_waited_at_delivery` property."""
|
"""Test `Order.courier_waited_at_delivery` property."""
|
||||||
order._courier_waited_at_delivery = True
|
order._courier_waited_at_delivery = True
|
||||||
|
|
||||||
|
@ -357,7 +955,7 @@ class TestProperties:
|
||||||
"""Test `Order.delivery_early` property."""
|
"""Test `Order.delivery_early` property."""
|
||||||
order = make_order(scheduled=True)
|
order = make_order(scheduled=True)
|
||||||
# Schedule the order to a lot later.
|
# Schedule the order to a lot later.
|
||||||
order.scheduled_delivery_at += datetime.timedelta(hours=2)
|
order.scheduled_delivery_at += dt.timedelta(hours=2)
|
||||||
|
|
||||||
result = order.delivery_early
|
result = order.delivery_early
|
||||||
|
|
||||||
|
@ -367,7 +965,7 @@ class TestProperties:
|
||||||
"""Test `Order.delivery_early` property."""
|
"""Test `Order.delivery_early` property."""
|
||||||
order = make_order(scheduled=True)
|
order = make_order(scheduled=True)
|
||||||
# Schedule the order to a lot earlier.
|
# Schedule the order to a lot earlier.
|
||||||
order.scheduled_delivery_at -= datetime.timedelta(hours=2)
|
order.scheduled_delivery_at -= dt.timedelta(hours=2)
|
||||||
|
|
||||||
result = order.delivery_early
|
result = order.delivery_early
|
||||||
|
|
||||||
|
@ -383,7 +981,7 @@ class TestProperties:
|
||||||
"""Test `Order.delivery_early` property."""
|
"""Test `Order.delivery_early` property."""
|
||||||
order = make_order(scheduled=True)
|
order = make_order(scheduled=True)
|
||||||
# Schedule the order to a lot earlier.
|
# Schedule the order to a lot earlier.
|
||||||
order.scheduled_delivery_at -= datetime.timedelta(hours=2)
|
order.scheduled_delivery_at -= dt.timedelta(hours=2)
|
||||||
|
|
||||||
result = order.delivery_late
|
result = order.delivery_late
|
||||||
|
|
||||||
|
@ -393,7 +991,7 @@ class TestProperties:
|
||||||
"""Test `Order.delivery_early` property."""
|
"""Test `Order.delivery_early` property."""
|
||||||
order = make_order(scheduled=True)
|
order = make_order(scheduled=True)
|
||||||
# Schedule the order to a lot later.
|
# Schedule the order to a lot later.
|
||||||
order.scheduled_delivery_at += datetime.timedelta(hours=2)
|
order.scheduled_delivery_at += dt.timedelta(hours=2)
|
||||||
|
|
||||||
result = order.delivery_late
|
result = order.delivery_late
|
||||||
|
|
||||||
|
@ -417,17 +1015,17 @@ class TestProperties:
|
||||||
"""Test `Order.total_time` property."""
|
"""Test `Order.total_time` property."""
|
||||||
result = order.total_time
|
result = order.total_time
|
||||||
|
|
||||||
assert result > datetime.timedelta(0)
|
assert result > dt.timedelta(0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.db
|
@pytest.mark.db
|
||||||
@pytest.mark.no_cover
|
@pytest.mark.no_cover
|
||||||
def test_make_random_orders( # noqa:C901,WPS211,WPS213,WPS231
|
def test_make_random_orders( # noqa:C901,WPS211,WPS213
|
||||||
db_session, make_address, make_courier, make_restaurant, make_order,
|
db_session, make_address, make_courier, make_restaurant, make_order,
|
||||||
):
|
):
|
||||||
"""Sanity check the all the `make_*` fixtures.
|
"""Sanity check the all the `make_*` fixtures.
|
||||||
|
|
||||||
Ensure that all generated `Address`, `Courier`, `Customer`, `Restauarant`,
|
Ensure that all generated `Address`, `Courier`, `Customer`, `Restaurant`,
|
||||||
and `Order` objects adhere to the database constraints.
|
and `Order` objects adhere to the database constraints.
|
||||||
""" # noqa:D202
|
""" # noqa:D202
|
||||||
# Generate a large number of `Order`s to obtain a large variance of data.
|
# Generate a large number of `Order`s to obtain a large variance of data.
|
||||||
|
|
|
@ -17,9 +17,7 @@ def horizontal_datetime_index():
|
||||||
The times resemble a horizontal time series with a `frequency` of `7`.
|
The times resemble a horizontal time series with a `frequency` of `7`.
|
||||||
All observations take place at `NOON`.
|
All observations take place at `NOON`.
|
||||||
"""
|
"""
|
||||||
first_start_at = dt.datetime(
|
first_start_at = dt.datetime(*test_config.DATE, test_config.NOON, 0)
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, test_config.NOON, 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
gen = (
|
gen = (
|
||||||
start_at
|
start_at
|
||||||
|
|
|
@ -73,9 +73,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
@ -104,9 +102,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
@ -137,9 +133,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
@ -169,21 +163,15 @@ class TestAggregateOrders:
|
||||||
ad_hoc_order = make_order(
|
ad_hoc_order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, 11, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(ad_hoc_order)
|
db_session.add(ad_hoc_order)
|
||||||
|
|
||||||
pre_order = make_order(
|
pre_order = make_order(
|
||||||
scheduled=True,
|
scheduled=True,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, 9, 0),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 9, 0,
|
scheduled_delivery_at=datetime.datetime(*test_config.DATE, 12, 0),
|
||||||
),
|
|
||||||
scheduled_delivery_at=datetime.datetime(
|
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 0,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(pre_order)
|
db_session.add(pre_order)
|
||||||
|
|
||||||
|
@ -215,9 +203,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
@ -252,9 +238,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant,
|
restaurant=restaurant,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
@ -333,9 +317,7 @@ class TestAggregateOrders:
|
||||||
order = make_order(
|
order = make_order(
|
||||||
scheduled=False,
|
scheduled=False,
|
||||||
restaurant=restaurant1,
|
restaurant=restaurant1,
|
||||||
placed_at=datetime.datetime(
|
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
|
||||||
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
db_session.add(order)
|
db_session.add(order)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue