diff --git a/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py b/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py new file mode 100644 index 0000000..81c8b67 --- /dev/null +++ b/migrations/versions/rev_20210914_13_81aa304d7a6e_add_simulation_data.py @@ -0,0 +1,413 @@ +"""Add simulation data. + +Revision: # 81aa304d7a6e at 2021-09-14 13:47:03 +Revises: # b4dd0b8903a5 +""" + +import os + +import sqlalchemy as sa +from alembic import op + +from urban_meal_delivery import configuration + + +revision = '81aa304d7a6e' +down_revision = 'b4dd0b8903a5' +branch_labels = None +depends_on = None + + +config = configuration.make_config('testing' if os.getenv('TESTING') else 'production') + + +def upgrade(): + """Upgrade to revision 81aa304d7a6e.""" + # Drop unnecessary check constraint. + op.execute( # `.delivery_at` is not set for `.cancelled` orders anyways. + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT ck_orders_on_ordered_timestamps_21; + """, + ) # noqa:WPS355 + + # Add forgotten check constraints to the `orders` table. + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at + CHECK (placed_at < pickup_at); + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour + CHECK ( + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + ); + """, + ) # noqa:WPS355 + + # This `UniqueConstraint` is needed by the `replayed_orders` table below. + op.create_unique_constraint( + 'uq_orders_on_id_ad_hoc', + 'orders', + ['id', 'ad_hoc'], + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'replay_simulations', + sa.Column('id', sa.Integer, autoincrement=True), + sa.Column('city_id', sa.SmallInteger, nullable=False), + sa.Column('strategy', sa.Unicode(length=100), nullable=False), + sa.Column('run', sa.SmallInteger, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_replay_simulations')), + sa.ForeignKeyConstraint( + ['city_id'], + [f'{config.CLEAN_SCHEMA}.cities.id'], + name=op.f('fk_replay_simulations_to_cities_via_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.UniqueConstraint( + 'city_id', + 'strategy', + 'run', + name=op.f('uq_replay_simulations_on_city_id_strategy_run'), + ), + sa.CheckConstraint('run >= 0', name=op.f('run_is_a_count')), + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'replayed_orders', + sa.Column('simulation_id', sa.Integer, primary_key=True), + sa.Column('actual_order_id', sa.Integer, primary_key=True), + sa.Column('ad_hoc', sa.Boolean, nullable=False), + sa.Column('placed_at', sa.DateTime, nullable=False), + sa.Column('scheduled_delivery_at', sa.DateTime), + sa.Column('cancelled_at', sa.DateTime), + sa.Column('estimated_prep_duration', sa.SmallInteger), + sa.Column('restaurant_notified_at', sa.DateTime), + sa.Column('restaurant_confirmed_at', sa.DateTime), + sa.Column('restaurant_ready_at', sa.DateTime), + sa.Column('dispatch_at', sa.DateTime), + sa.Column('first_estimated_delivery_at', sa.DateTime), + sa.Column('courier_id', sa.Integer), + sa.Column('courier_notified_at', sa.DateTime), + sa.Column('courier_accepted_at', sa.DateTime), + sa.Column('utilization', sa.SmallInteger), + sa.Column('reached_pickup_at', sa.DateTime), + sa.Column('pickup_at', sa.DateTime), + sa.Column('left_pickup_at', sa.DateTime), + sa.Column('reached_delivery_at', sa.DateTime), + sa.Column('delivery_at', sa.DateTime), + sa.PrimaryKeyConstraint( + 'simulation_id', 'actual_order_id', name=op.f('pk_replayed_orders'), + ), + sa.ForeignKeyConstraint( + ['simulation_id'], + [f'{config.CLEAN_SCHEMA}.replay_simulations.id'], + name=op.f('fk_replayed_orders_to_replay_simulations_via_simulation_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + # Needs the `UniqueConstraint` from above. + ['actual_order_id', 'ad_hoc'], + [ + f'{config.CLEAN_SCHEMA}.orders.id', + f'{config.CLEAN_SCHEMA}.orders.ad_hoc', + ], + name=op.f('fk_replayed_orders_to_orders_via_actual_order_id_ad_hoc'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['courier_id'], + [f'{config.CLEAN_SCHEMA}.couriers.id'], + name=op.f('fk_replayed_orders_to_couriers_via_courier_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.CheckConstraint( + """ + ( + ad_hoc IS TRUE + AND + scheduled_delivery_at IS NULL + ) + OR + ( + ad_hoc IS FALSE + AND + scheduled_delivery_at IS NOT NULL + ) + """, + name=op.f('either_ad_hoc_or_scheduled_order'), + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS TRUE + AND ( + EXTRACT(HOUR FROM placed_at) < 11 + OR + EXTRACT(HOUR FROM placed_at) > 22 + ) + ) + """, + name=op.f('ad_hoc_orders_within_business_hours'), + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS FALSE + AND ( + ( + EXTRACT(HOUR FROM scheduled_delivery_at) <= 11 + AND + NOT ( + EXTRACT(HOUR FROM scheduled_delivery_at) = 11 + AND + EXTRACT(MINUTE FROM scheduled_delivery_at) = 45 + ) + ) + OR + EXTRACT(HOUR FROM scheduled_delivery_at) > 22 + ) + ) + """, + name=op.f('scheduled_orders_within_business_hours'), + ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name=op.f('scheduled_orders_must_be_at_quarters_of_an_hour'), + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800 + ) + """, + name=op.f('scheduled_orders_not_within_30_minutes'), + ), + sa.CheckConstraint( + """ + cancelled_at IS NOT NULL + OR + ( + restaurant_notified_at IS NOT NULL + AND + restaurant_confirmed_at IS NOT NULL + AND + restaurant_ready_at IS NOT NULL + AND + dispatch_at IS NOT NULL + AND + courier_id IS NOT NULL + AND + courier_notified_at IS NOT NULL + AND + courier_accepted_at IS NOT NULL + AND + reached_pickup_at IS NOT NULL + AND + pickup_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name=op.f('orders_must_be_either_cancelled_or_fully_simulated'), + ), + sa.CheckConstraint( + """ + NOT ( -- Only occurred in 528 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + pickup_at IS NOT NULL + ) + AND + NOT ( -- Only occurred in 176 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name=op.f('cancellations_may_only_occur_before_pickup'), + ), + sa.CheckConstraint( + '0 <= estimated_prep_duration AND estimated_prep_duration <= 3600', + name=op.f('estimated_prep_duration_between_0_and_3600'), + ), + sa.CheckConstraint( + 'estimated_prep_duration % 60 = 0', + name=op.f('estimated_prep_duration_must_be_whole_minutes'), + ), + sa.CheckConstraint( + '0 <= utilization AND utilization <= 100', + name=op.f('utilization_between_0_and_100'), + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(HOUR FROM restaurant_notified_at) < 11 + OR + EXTRACT(HOUR FROM dispatch_at) < 11 + ) + """, + name=op.f('orders_dispatched_in_business_hours'), + ), + *( + sa.CheckConstraint( + constraint, name='ordered_timestamps_{index}'.format(index=index), + ) + for index, constraint in enumerate( + ( + 'placed_at < scheduled_delivery_at', + 'placed_at < cancelled_at', + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'cancelled_at > restaurant_notified_at', + 'cancelled_at > restaurant_confirmed_at', + 'cancelled_at > restaurant_ready_at', + 'cancelled_at > dispatch_at', + 'cancelled_at > courier_notified_at', + 'cancelled_at > courier_accepted_at', + 'cancelled_at > reached_pickup_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ), + ) + ), + schema=config.CLEAN_SCHEMA, + ) + + op.create_index( + op.f('ix_replay_simulations_on_city_id'), + 'replay_simulations', + ['city_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_replay_simulations_on_strategy'), + 'replay_simulations', + ['strategy'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_replayed_orders_on_courier_id'), + 'replayed_orders', + ['courier_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + + +def downgrade(): + """Downgrade to revision b4dd0b8903a5.""" + op.drop_table('replayed_orders', schema=config.CLEAN_SCHEMA) + op.drop_table('replay_simulations', schema=config.CLEAN_SCHEMA) + op.drop_constraint( + 'uq_orders_on_id_ad_hoc', 'orders', type_=None, schema=config.CLEAN_SCHEMA, + ) + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour; + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + DROP CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at; + """, + ) # noqa:WPS355 + op.execute( + f""" + ALTER TABLE {config.CLEAN_SCHEMA}.orders + ADD CONSTRAINT ck_orders_on_ordered_timestamps_21 + CHECK (cancelled_at > delivery_at); + """, + ) # noqa:WPS355 diff --git a/noxfile.py b/noxfile.py index f79025c..05106a6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -384,7 +384,7 @@ def test_suite(session): @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. 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') -def clean_pwd(session): # noqa:WPS231 +def clean_pwd(session): """Remove (almost) all glob patterns listed in .gitignore. The difference compared to `git clean -X` is that this task diff --git a/setup.cfg b/setup.cfg index e91e277..4d571dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -93,8 +93,15 @@ extend-ignore = # until after being processed by Sphinx Napoleon. # Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17 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. 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. WPS303, # f-strings are ok. @@ -104,6 +111,8 @@ extend-ignore = # Let's be modern: The Walrus is ok. WPS332, # Let's not worry about the number of noqa's. + # Multi-line loops are ok. + WPS352, WPS402, # Putting logic into __init__.py files may be justified. WPS412, @@ -141,24 +150,9 @@ per-file-ignores = src/urban_meal_delivery/configuration.py: # Allow upper case class variables within classes. WPS115, - src/urban_meal_delivery/console/forecasts.py: - # The module is not too complex. - WPS232, src/urban_meal_delivery/db/addresses_addresses.py: # The module does not have too many imports. 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: # The many noqa's are ok. WPS403, @@ -178,6 +172,8 @@ per-file-ignores = S311, # Shadowing outer scopes occurs naturally with mocks. WPS442, + # `utils` should be a valid module name. + WPS100, # Test names may be longer than 40 characters. WPS118, # Modules may have many test cases. diff --git a/src/urban_meal_delivery/console/forecasts.py b/src/urban_meal_delivery/console/forecasts.py index 5d2a20d..c91a7fd 100644 --- a/src/urban_meal_delivery/console/forecasts.py +++ b/src/urban_meal_delivery/console/forecasts.py @@ -24,7 +24,7 @@ from urban_meal_delivery.forecasts import timify @click.argument('time_step', default=60, type=int) @click.argument('train_horizon', default=8, type=int) @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, ) -> None: # pragma: no cover """Predict demand for all pixels and days in a city. diff --git a/src/urban_meal_delivery/db/__init__.py b/src/urban_meal_delivery/db/__init__.py index 6161657..7f69d6d 100644 --- a/src/urban_meal_delivery/db/__init__.py +++ b/src/urban_meal_delivery/db/__init__.py @@ -14,4 +14,6 @@ from urban_meal_delivery.db.grids import Grid from urban_meal_delivery.db.meta import Base from urban_meal_delivery.db.orders import Order from urban_meal_delivery.db.pixels import Pixel +from urban_meal_delivery.db.replay import ReplayedOrder +from urban_meal_delivery.db.replay import ReplaySimulation from urban_meal_delivery.db.restaurants import Restaurant diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index 926b3ae..dd8b734 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -39,6 +39,7 @@ class City(meta.Base): # Relationships addresses = orm.relationship('Address', back_populates='city') grids = orm.relationship('Grid', back_populates='city') + replays = orm.relationship('ReplaySimulation', back_populates='city') # We do not implement a `.__init__()` method and use SQLAlchemy's default. # The uninitialized attribute `._map` is computed on the fly. note:d334120ei @@ -117,7 +118,7 @@ class City(meta.Base): return self._map - def draw_restaurants( # noqa:WPS231 + def draw_restaurants( self, order_counts: bool = False, # pragma: no cover ) -> folium.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`. if n_orders >= 1000: - radius = 20 # noqa:WPS220 + radius = 20 elif n_orders >= 500: - radius = 15 # noqa:WPS220 + radius = 15 elif n_orders >= 100: - radius = 10 # noqa:WPS220 + radius = 10 elif n_orders >= 10: - radius = 5 # noqa:WPS220 + radius = 5 else: - radius = 1 # noqa:WPS220 + radius = 1 tooltip += f' | n_orders={n_orders}' # noqa:WPS336 diff --git a/src/urban_meal_delivery/db/customers.py b/src/urban_meal_delivery/db/customers.py index fcbfd58..384c003 100644 --- a/src/urban_meal_delivery/db/customers.py +++ b/src/urban_meal_delivery/db/customers.py @@ -42,7 +42,7 @@ class Customer(meta.Base): """Shortcut to the `...city.map` object.""" 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 ) -> folium.Map: """Draw all the customer's delivery addresses on the `...city.map`. @@ -90,15 +90,15 @@ class Customer(meta.Base): .count() ) if n_orders >= 25: - radius = 20 # noqa:WPS220 + radius = 20 elif n_orders >= 10: - radius = 15 # noqa:WPS220 + radius = 15 elif n_orders >= 5: - radius = 10 # noqa:WPS220 + radius = 10 elif n_orders > 1: - radius = 5 # noqa:WPS220 + radius = 5 else: - radius = 1 # noqa:WPS220 + radius = 1 address.draw( radius=radius, @@ -156,15 +156,15 @@ class Customer(meta.Base): .count() ) if n_orders >= 25: - radius = 20 # noqa:WPS220 + radius = 20 elif n_orders >= 10: - radius = 15 # noqa:WPS220 + radius = 15 elif n_orders >= 5: - radius = 10 # noqa:WPS220 + radius = 10 elif n_orders > 1: - radius = 5 # noqa:WPS220 + radius = 5 else: - radius = 1 # noqa:WPS220 + radius = 1 tooltip += f' | n_orders={n_orders}' # noqa:WPS336 diff --git a/src/urban_meal_delivery/db/orders.py b/src/urban_meal_delivery/db/orders.py index 1b1341b..4c4a2f7 100644 --- a/src/urban_meal_delivery/db/orders.py +++ b/src/urban_meal_delivery/db/orders.py @@ -105,6 +105,8 @@ class Order(meta.Base): # noqa:WPS214 onupdate='RESTRICT', ondelete='RESTRICT', ), + # Needed by a `ForeignKeyConstraint` in `ReplayedOrder`. + sa.UniqueConstraint('id', 'ad_hoc'), sa.CheckConstraint( """ (ad_hoc IS TRUE AND scheduled_delivery_at IS NULL) @@ -147,6 +149,20 @@ class Order(meta.Base): # noqa:WPS214 """, name='scheduled_orders_within_business_hours', ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name='scheduled_orders_must_be_at_quarters_of_an_hour', + ), sa.CheckConstraint( """ NOT ( @@ -256,6 +272,7 @@ class Order(meta.Base): # noqa:WPS214 'placed_at < courier_notified_at', 'placed_at < courier_accepted_at', 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', 'placed_at < left_pickup_at', 'placed_at < reached_delivery_at', 'placed_at < delivery_at', @@ -268,7 +285,6 @@ class Order(meta.Base): # noqa:WPS214 'cancelled_at > pickup_at', 'cancelled_at > left_pickup_at', 'cancelled_at > reached_delivery_at', - 'cancelled_at > delivery_at', 'restaurant_notified_at < restaurant_confirmed_at', 'restaurant_notified_at < pickup_at', 'restaurant_confirmed_at < pickup_at', @@ -323,6 +339,7 @@ class Order(meta.Base): # noqa:WPS214 back_populates='orders_delivered', foreign_keys='[Order.delivery_address_id]', ) + replays = orm.relationship('ReplayedOrder', back_populates='actual') # Convenience properties diff --git a/src/urban_meal_delivery/db/pixels.py b/src/urban_meal_delivery/db/pixels.py index f063634..327e21d 100644 --- a/src/urban_meal_delivery/db/pixels.py +++ b/src/urban_meal_delivery/db/pixels.py @@ -136,7 +136,7 @@ class Pixel(meta.Base): """Shortcut to the `.city.map` object.""" 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 ) -> folium.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`. if n_orders >= 1000: - radius = 20 # noqa:WPS220 + radius = 20 elif n_orders >= 500: - radius = 15 # noqa:WPS220 + radius = 15 elif n_orders >= 100: - radius = 10 # noqa:WPS220 + radius = 10 elif n_orders >= 10: - radius = 5 # noqa:WPS220 + radius = 5 else: - radius = 1 # noqa:WPS220 + radius = 1 tooltip += f' | n_orders={n_orders}' # noqa:WPS336 diff --git a/src/urban_meal_delivery/db/replay/__init__.py b/src/urban_meal_delivery/db/replay/__init__.py new file mode 100644 index 0000000..592d821 --- /dev/null +++ b/src/urban_meal_delivery/db/replay/__init__.py @@ -0,0 +1,4 @@ +"""Provide the ORM models regarding the replay simulations.""" + +from urban_meal_delivery.db.replay.orders import ReplayedOrder +from urban_meal_delivery.db.replay.simulations import ReplaySimulation diff --git a/src/urban_meal_delivery/db/replay/orders.py b/src/urban_meal_delivery/db/replay/orders.py new file mode 100644 index 0000000..ba8b2e2 --- /dev/null +++ b/src/urban_meal_delivery/db/replay/orders.py @@ -0,0 +1,364 @@ +"""Provide the ORM's `ReplayedOrder` model for the replay simulations.""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class ReplayedOrder(meta.Base): + """A simulated order by a `Customer` of the UDP.""" + + __tablename__ = 'replayed_orders' + + # Generic columns + simulation_id = sa.Column(sa.Integer, primary_key=True) + actual_order_id = sa.Column(sa.Integer, primary_key=True) + + # `Order`-type related columns + # `.ad_hoc` is part of a `ForeignKeyConstraint` to ensure the type + # of the `ReplayedOrder` (i.e., ad-hoc or scheduled) is in line + # with the `.actual` order. + ad_hoc = sa.Column(sa.Boolean, nullable=False) + placed_at = sa.Column(sa.DateTime, nullable=False) + # Depending on `.ad_hoc`, `.scheduled_delivery_at` is either filled in or not. + scheduled_delivery_at = sa.Column(sa.DateTime) + # If an order is cancelled in a simulation, + # some of the columns below do not apply any more. + cancelled_at = sa.Column(sa.DateTime) + + # Restaurant-related columns + estimated_prep_duration = sa.Column(sa.SmallInteger) + restaurant_notified_at = sa.Column(sa.DateTime) + restaurant_confirmed_at = sa.Column(sa.DateTime) + restaurant_ready_at = sa.Column(sa.DateTime) + + # Dispatch-related columns + dispatch_at = sa.Column(sa.DateTime) + first_estimated_delivery_at = sa.Column(sa.DateTime) + courier_id = sa.Column(sa.Integer, index=True) + courier_notified_at = sa.Column(sa.DateTime) + courier_accepted_at = sa.Column(sa.DateTime) + utilization = sa.Column(sa.SmallInteger) + + # Pickup-related columns + reached_pickup_at = sa.Column(sa.DateTime) + pickup_at = sa.Column(sa.DateTime) + left_pickup_at = sa.Column(sa.DateTime) + + # Delivery-related columns + reached_delivery_at = sa.Column(sa.DateTime) + delivery_at = sa.Column(sa.DateTime) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['simulation_id'], + ['replay_simulations.id'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['actual_order_id', 'ad_hoc'], + ['orders.id', 'orders.ad_hoc'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['courier_id'], ['couriers.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + # The check constraints model some of the assumptions made in the simulations. + sa.CheckConstraint( + # Align the order's type, ad-hoc or scheduled, with the type of the + # `.actual` order and ensure the corresponding datetime column is set + # or not set. + """ + ( + ad_hoc IS TRUE + AND + scheduled_delivery_at IS NULL + ) + OR + ( + ad_hoc IS FALSE + AND + scheduled_delivery_at IS NOT NULL + ) + """, + name='either_ad_hoc_or_scheduled_order', + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS TRUE + AND ( + EXTRACT(HOUR FROM placed_at) < 11 + OR + EXTRACT(HOUR FROM placed_at) > 22 + ) + ) + """, + name='ad_hoc_orders_within_business_hours', + ), + sa.CheckConstraint( + """ + NOT ( + ad_hoc IS FALSE + AND ( + ( + EXTRACT(HOUR FROM scheduled_delivery_at) <= 11 + AND + NOT ( + EXTRACT(HOUR FROM scheduled_delivery_at) = 11 + AND + EXTRACT(MINUTE FROM scheduled_delivery_at) = 45 + ) + ) + OR + EXTRACT(HOUR FROM scheduled_delivery_at) > 22 + ) + ) + """, + name='scheduled_orders_within_business_hours', + ), + sa.CheckConstraint( + """ + ( + EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER + % 15 = 0 + ) + AND + ( + EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER + = 0 + ) + """, + name='scheduled_orders_must_be_at_quarters_of_an_hour', + ), + sa.CheckConstraint( + """ + NOT ( + EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800 + ) + """, + name='scheduled_orders_not_within_30_minutes', + ), + sa.CheckConstraint( + # A simulation must fill in at least all the timing and dispatch related + # columns, unless an order is cancelled in a run. + # Only `.estimated_prep_duration`, `.first_estimated_delivery_at`, and + # `.utilization` are truly optional. + # This constraint does allow missing columns for cancelled orders + # in any combination of the below columns. So, no precedence constraints + # are enforced (e.g., `.restaurant_notified_at` and `.restaurant_ready_at` + # could be set without `.restaurant_confirmed_at`). That is acceptable + # as cancelled orders are left out in the optimized KPIs. + """ + cancelled_at IS NOT NULL + OR + ( + restaurant_notified_at IS NOT NULL + AND + restaurant_confirmed_at IS NOT NULL + AND + restaurant_ready_at IS NOT NULL + AND + dispatch_at IS NOT NULL + AND + courier_id IS NOT NULL + AND + courier_notified_at IS NOT NULL + AND + courier_accepted_at IS NOT NULL + AND + reached_pickup_at IS NOT NULL + AND + pickup_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name='orders_must_be_either_cancelled_or_fully_simulated', + ), + sa.CheckConstraint( + # 23,487 out of the 660,608 orders were cancelled. In 685 of them, the + # cancellation occurred after the pickup (as modeled by the `.pickup_at` + # and `.left_pickup_at` columns, where the latter may be set with the + # former being unset). As a simplification, we only allow cancellations + # before the pickup in the simulations. Then, the restaurant throws away + # the meal and the courier is free for other orders right away. Orders + # cancelled after pickup in the actual data are assumed to be delivered + # to the customer's door (and then still accepted or thrown away by the + # customer). So, the courier becomes free only after the "fake" delivery. + """ + NOT ( -- Only occurred in 528 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + pickup_at IS NOT NULL + ) + AND + NOT ( -- Only occurred in 176 of 660,608 orders in the actual data. + cancelled_at IS NOT NULL + AND + left_pickup_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + reached_delivery_at IS NOT NULL + ) + AND + NOT ( -- Never occurred in the actual data. + cancelled_at IS NOT NULL + AND + delivery_at IS NOT NULL + ) + """, + name='cancellations_may_only_occur_before_pickup', + ), + sa.CheckConstraint( + # The actual `.estimated_prep_duration` and `.estimated_prep_buffer` + # are modeled together as only one value. Therefore, the individual + # upper limits of 2700 and 900 are added and result in 3600. + '0 <= estimated_prep_duration AND estimated_prep_duration <= 3600', + name='estimated_prep_duration_between_0_and_3600', + ), + sa.CheckConstraint( + # We still round estimates of the preparation time to whole minutes. + # Other estimates are unlikely to change the simulation results in + # a significant way. + 'estimated_prep_duration % 60 = 0', + name='estimated_prep_duration_must_be_whole_minutes', + ), + sa.CheckConstraint( + # If a simulation's `.strategy` models `.utilization`, it must be + # realistic. The value can be deduced from the actual order's + # courier's `.capacity` and the actual order's `.utilization`. + '0 <= utilization AND utilization <= 100', + name='utilization_between_0_and_100', + ), + sa.CheckConstraint( + # The UDP is open from 11 am to 11 pm. So, before 11 am there is no + # activity. After 11 pm, the last orders of a day are all assumed to be + # dispatched before midnight. + """ + NOT ( + EXTRACT(HOUR FROM restaurant_notified_at) < 11 + OR + EXTRACT(HOUR FROM dispatch_at) < 11 + ) + """, + name='orders_dispatched_in_business_hours', + ), + *( + # The timestamps must be in a logically correct order. That is the same as + # in the actual `Order` model with an extra `restaurant_ready_at` column + # and the non-simulated columns removed. + sa.CheckConstraint( + constraint, name='ordered_timestamps_{index}'.format(index=index), + ) + for index, constraint in enumerate( + ( + 'placed_at < scheduled_delivery_at', + 'placed_at < cancelled_at', + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'cancelled_at > restaurant_notified_at', + 'cancelled_at > restaurant_confirmed_at', + 'cancelled_at > restaurant_ready_at', + 'cancelled_at > dispatch_at', + 'cancelled_at > courier_notified_at', + 'cancelled_at > courier_accepted_at', + 'cancelled_at > reached_pickup_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ), + ) + ), + ) + + # Relationships + simulation = orm.relationship('ReplaySimulation', back_populates='orders') + actual = orm.relationship('Order', back_populates='replays') + courier = orm.relationship('Courier') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}(#{order_id})>'.format( + cls=self.__class__.__name__, order_id=self.actual.id, + ) + + # Convenience properties + + @property + def customer(self) -> db.Customer: + """`.customer` of the actual `Order`.""" + return self.actual.customer + + @property + def restaurant(self) -> db.Restaurant: + """`.restaurant` of the actual `Order`.""" + return self.actual.restaurant + + @property + def pickup_address(self) -> db.Address: + """`.pickup_address` of the actual `Order`.""" + return self.actual.pickup_address + + @property + def delivery_address(self) -> db.Address: + """`.delivery_address` of the actual `Order`.""" + return self.actual.delivery_address + + +from urban_meal_delivery import db # noqa:E402 isort:skip diff --git a/src/urban_meal_delivery/db/replay/simulations.py b/src/urban_meal_delivery/db/replay/simulations.py new file mode 100644 index 0000000..8e4802d --- /dev/null +++ b/src/urban_meal_delivery/db/replay/simulations.py @@ -0,0 +1,46 @@ +"""Provide the ORM's `ReplaySimulation` model for the replay simulations.""" + + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class ReplaySimulation(meta.Base): + """A simulation of the UDP's routing given a strategy ... + + ... for the orders as they arrived in reality. + """ + + __tablename__ = 'replay_simulations' + + # Columns + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) # noqa:WPS125 + city_id = sa.Column(sa.SmallInteger, nullable=False, index=True) + strategy = sa.Column(sa.Unicode(length=100), nullable=False, index=True) + # `.run` may be used as random seed. + run = sa.Column(sa.SmallInteger, nullable=False) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + # A `.strategy` can be replayed several times per `.city`. + sa.UniqueConstraint('city_id', 'strategy', 'run'), + sa.CheckConstraint('run >= 0', name='run_is_a_count'), + ) + + # Relationships + city = orm.relationship('City', back_populates='replays') + orders = orm.relationship('ReplayedOrder', back_populates='simulation') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}({strategy} #{run} in {city})>'.format( + cls=self.__class__.__name__, + strategy=self.strategy, + run=self.run, + city=self.city.name, + ) diff --git a/src/urban_meal_delivery/db/restaurants.py b/src/urban_meal_delivery/db/restaurants.py index 31df7f8..fd838ab 100644 --- a/src/urban_meal_delivery/db/restaurants.py +++ b/src/urban_meal_delivery/db/restaurants.py @@ -69,7 +69,7 @@ class Restaurant(meta.Base): """Shortcut to the `.address.city.map` object.""" return self.address.city.map - def draw( # noqa:WPS231 + def draw( self, customers: bool = True, order_counts: bool = False, # pragma: no cover ) -> folium.Map: """Draw the restaurant on the `.address.city.map`. @@ -116,15 +116,15 @@ class Restaurant(meta.Base): .count() ) if n_orders >= 25: - radius = 20 # noqa:WPS220 + radius = 20 elif n_orders >= 10: - radius = 15 # noqa:WPS220 + radius = 15 elif n_orders >= 5: - radius = 10 # noqa:WPS220 + radius = 10 elif n_orders > 1: - radius = 5 # noqa:WPS220 + radius = 5 else: - radius = 1 # noqa:WPS220 + radius = 1 address.draw( radius=radius, diff --git a/src/urban_meal_delivery/forecasts/methods/decomposition.py b/src/urban_meal_delivery/forecasts/methods/decomposition.py index c71f6be..3a96ac9 100644 --- a/src/urban_meal_delivery/forecasts/methods/decomposition.py +++ b/src/urban_meal_delivery/forecasts/methods/decomposition.py @@ -11,7 +11,7 @@ from rpy2 import robjects from rpy2.robjects import pandas2ri -def stl( # noqa:C901,WPS210,WPS211,WPS231 +def stl( # noqa:C901,WPS210,WPS211 time_series: pd.Series, *, frequency: int, diff --git a/tests/config.py b/tests/config.py index 2af0d60..b99c8a9 100644 --- a/tests/config.py +++ b/tests/config.py @@ -7,6 +7,8 @@ from urban_meal_delivery import config # The day on which most test cases take place. YEAR, MONTH, DAY = 2016, 7, 1 +# Same day as a `tuple`. +DATE = (YEAR, MONTH, DAY) # The hour when most test cases take place. NOON = 12 diff --git a/tests/conftest.py b/tests/conftest.py index d4b1b25..673c251 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,6 +106,7 @@ make_address = fake_data.make_address make_courier = fake_data.make_courier make_customer = fake_data.make_customer make_order = fake_data.make_order +make_replay_order = fake_data.make_replay_order make_restaurant = fake_data.make_restaurant address = fake_data.address @@ -114,6 +115,10 @@ city_data = fake_data.city_data courier = fake_data.courier customer = fake_data.customer order = fake_data.order +pre_order = fake_data.pre_order +replayed_order = fake_data.replayed_order restaurant = fake_data.restaurant grid = fake_data.grid pixel = fake_data.pixel +simulation = fake_data.simulation +simulation_data = fake_data.simulation_data diff --git a/tests/db/fake_data/__init__.py b/tests/db/fake_data/__init__.py index 80a7be3..0fc9433 100644 --- a/tests/db/fake_data/__init__.py +++ b/tests/db/fake_data/__init__.py @@ -4,6 +4,7 @@ from tests.db.fake_data.fixture_makers import make_address from tests.db.fake_data.fixture_makers import make_courier from tests.db.fake_data.fixture_makers import make_customer from tests.db.fake_data.fixture_makers import make_order +from tests.db.fake_data.fixture_makers import make_replay_order from tests.db.fake_data.fixture_makers import make_restaurant from tests.db.fake_data.static_fixtures import address from tests.db.fake_data.static_fixtures import city @@ -13,4 +14,8 @@ from tests.db.fake_data.static_fixtures import customer from tests.db.fake_data.static_fixtures import grid from tests.db.fake_data.static_fixtures import order from tests.db.fake_data.static_fixtures import pixel +from tests.db.fake_data.static_fixtures import pre_order +from tests.db.fake_data.static_fixtures import replayed_order from tests.db.fake_data.static_fixtures import restaurant +from tests.db.fake_data.static_fixtures import simulation +from tests.db.fake_data.static_fixtures import simulation_data diff --git a/tests/db/fake_data/factories/__init__.py b/tests/db/fake_data/factories/__init__.py new file mode 100644 index 0000000..3d153ae --- /dev/null +++ b/tests/db/fake_data/factories/__init__.py @@ -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 diff --git a/tests/db/fake_data/factories/addresses.py b/tests/db/fake_data/factories/addresses.py new file mode 100644 index 0000000..39ac266 --- /dev/null +++ b/tests/db/fake_data/factories/addresses.py @@ -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') diff --git a/tests/db/fake_data/factories/couriers.py b/tests/db/fake_data/factories/couriers.py new file mode 100644 index 0000000..1f1e751 --- /dev/null +++ b/tests/db/fake_data/factories/couriers.py @@ -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 diff --git a/tests/db/fake_data/factories/customers.py b/tests/db/fake_data/factories/customers.py new file mode 100644 index 0000000..55954e3 --- /dev/null +++ b/tests/db/fake_data/factories/customers.py @@ -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 diff --git a/tests/db/fake_data/factories/orders/__init__.py b/tests/db/fake_data/factories/orders/__init__.py new file mode 100644 index 0000000..d854b6c --- /dev/null +++ b/tests/db/fake_data/factories/orders/__init__.py @@ -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 diff --git a/tests/db/fake_data/factories.py b/tests/db/fake_data/factories/orders/ad_hoc.py similarity index 51% rename from tests/db/fake_data/factories.py rename to tests/db/fake_data/factories/orders/ad_hoc.py index 46f2ff3..12fc611 100644 --- a/tests/db/fake_data/factories.py +++ b/tests/db/fake_data/factories/orders/ad_hoc.py @@ -1,114 +1,17 @@ -"""Factories to create instances for the SQLAlchemy models.""" +"""Factory to create ad-hoc `Order` instances.""" import datetime as dt import random -import string import factory -import faker from factory import alchemy from geopy import distance from tests import config as test_config +from tests.db.fake_data.factories import utils 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): """Create instances of the `db.Order` model. @@ -145,7 +48,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): cancel_=True, cancelled_at=factory.LazyAttribute( lambda obj: obj.dispatch_at - + _random_timespan( + + utils.random_timespan( max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(), ), ), @@ -154,7 +57,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): cancel_=True, cancelled_at=factory.LazyAttribute( lambda obj: obj.pickup_at - + _random_timespan( + + utils.random_timespan( 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. # Ad-hoc `Order`s are placed between 11.45 and 14.15. placed_at = factory.LazyFunction( - lambda: dt.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 45, - ) - + _random_timespan(max_hours=2, max_minutes=30), + lambda: dt.datetime(*test_config.DATE, 11, 45) + + utils.random_timespan(max_hours=2, max_minutes=30), ) ad_hoc = True scheduled_delivery_at = None @@ -194,14 +95,19 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): # Restaurant-related attributes # restaurant -> set by the `make_order` fixture for better control 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( 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. estimated_prep_duration = 900 estimated_prep_duration_corrected = False @@ -210,19 +116,26 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): # Dispatch-related columns # courier -> set by the `make_order` fixture for better control 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( 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( 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. 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 reached_pickup_at = factory.LazyAttribute( 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( 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 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_address -> set by the `make_order` fixture as there is only one `city` reached_delivery_at = factory.LazyAttribute( 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( 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 _courier_waited_at_delivery = factory.LazyAttribute( lambda obj: False if obj.delivery_at else None, @@ -280,9 +200,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): ) @factory.post_generation - def post( # noqa:C901,WPS231 - obj, create, extracted, **kwargs, # noqa:B902,N805 - ): + def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805 """Discard timestamps that occur after cancellation.""" if obj.cancelled: if obj.cancelled_at <= obj.restaurant_notified_at: @@ -316,63 +234,3 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): obj.delivery_at_corrected = None obj.delivery_not_confirmed = 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), - ) diff --git a/tests/db/fake_data/factories/orders/replayed.py b/tests/db/fake_data/factories/orders/replayed.py new file mode 100644 index 0000000..857bc4c --- /dev/null +++ b/tests/db/fake_data/factories/orders/replayed.py @@ -0,0 +1,120 @@ +"""Factory to create `Order` and `ReplayedOrder` instances.""" + +import datetime as dt + +import factory +from factory import alchemy + +from tests.db.fake_data.factories import utils +from urban_meal_delivery import db + + +class ReplayedOrderFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.ReplayedOrder` model. + + For simplicity, we assume the underlying `.order` to be an ad-hoc `Order`. + """ + + class Meta: + model = db.ReplayedOrder + sqlalchemy_get_or_create = ('simulation_id', 'order_id') + + # Generic columns + # simulation -> set by the `make_replay_order` fixture for better control + # actual (`Order`) -> set by the `make_replay_order` fixture for better control + + # `Order`-type related columns + ad_hoc = factory.LazyAttribute(lambda obj: obj.actual.ad_hoc) + placed_at = factory.LazyAttribute(lambda obj: obj.actual.placed_at) + scheduled_delivery_at = factory.LazyAttribute( + lambda obj: obj.actual.scheduled_delivery_at, + ) + cancelled_at = None + + # Restaurant-related columns + estimated_prep_duration = 1200 + restaurant_notified_at = factory.LazyAttribute( + lambda obj: obj.placed_at + + utils.random_timespan(min_seconds=1, max_seconds=30), + ) + restaurant_confirmed_at = factory.LazyAttribute( + lambda obj: obj.restaurant_notified_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + restaurant_ready_at = factory.LazyAttribute( + lambda obj: obj.restaurant_confirmed_at + + dt.timedelta(seconds=obj.estimated_prep_duration) + + utils.random_timespan(min_seconds=300, max_seconds=300), + ) + + # Dispatch-related columns + dispatch_at = factory.LazyAttribute( + lambda obj: obj.actual.placed_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + first_estimated_delivery_at = factory.LazyAttribute( + lambda obj: obj.restaurant_notified_at + + dt.timedelta(seconds=obj.estimated_prep_duration) + + dt.timedelta(minutes=10), + ) + # courier -> set by the `make_replay_order` fixture for better control + courier_notified_at = factory.LazyAttribute( + lambda obj: obj.dispatch_at + + utils.random_timespan(min_seconds=1, max_seconds=30), + ) + courier_accepted_at = factory.LazyAttribute( + lambda obj: obj.courier_notified_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + utilization = None + + # Pickup-related columns + reached_pickup_at = factory.LazyAttribute( + lambda obj: obj.restaurant_ready_at + + utils.random_timespan(min_seconds=1, max_seconds=60), + ) + pickup_at = factory.LazyAttribute( + lambda obj: obj.reached_pickup_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + left_pickup_at = factory.LazyAttribute( + lambda obj: obj.pickup_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + + # Delivery-related columns + reached_delivery_at = factory.LazyAttribute( + lambda obj: obj.left_pickup_at + + utils.random_timespan(min_minutes=5, max_minutes=10), + ) + delivery_at = factory.LazyAttribute( + lambda obj: obj.reached_delivery_at + + utils.random_timespan(min_seconds=30, max_seconds=60), + ) + + @factory.post_generation + def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805 + """Discard timestamps that occur after cancellation.""" + if obj.cancelled_at: + if obj.cancelled_at <= obj.restaurant_notified_at: + obj.restaurant_notified_at = None + if obj.cancelled_at <= obj.restaurant_confirmed_at: + obj.restaurant_confirmed_at = None + if obj.cancelled_at <= obj.restaurant_ready_at: + obj.restaurant_ready_at = None + if obj.cancelled_at <= obj.dispatch_at: + obj.dispatch_at = None + if obj.cancelled_at <= obj.courier_notified_at: + obj.courier_notified_at = None + if obj.cancelled_at <= obj.courier_accepted_at: + obj.courier_accepted_at = None + if obj.cancelled_at <= obj.reached_pickup_at: + obj.reached_pickup_at = None + if obj.cancelled_at <= obj.pickup_at: + obj.pickup_at = None + if obj.cancelled_at <= obj.left_pickup_at: + obj.left_pickup_at = None + if obj.cancelled_at <= obj.reached_delivery_at: + obj.reached_delivery_at = None + if obj.cancelled_at <= obj.delivery_at: + obj.delivery_at = None diff --git a/tests/db/fake_data/factories/orders/scheduled.py b/tests/db/fake_data/factories/orders/scheduled.py new file mode 100644 index 0000000..27c1798 --- /dev/null +++ b/tests/db/fake_data/factories/orders/scheduled.py @@ -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), + ) diff --git a/tests/db/fake_data/factories/restaurants.py b/tests/db/fake_data/factories/restaurants.py new file mode 100644 index 0000000..add3976 --- /dev/null +++ b/tests/db/fake_data/factories/restaurants.py @@ -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 diff --git a/tests/db/fake_data/factories/utils.py b/tests/db/fake_data/factories/utils.py new file mode 100644 index 0000000..28142a9 --- /dev/null +++ b/tests/db/fake_data/factories/utils.py @@ -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) diff --git a/tests/db/fake_data/fixture_makers.py b/tests/db/fake_data/fixture_makers.py index 9a5419b..8f6f891 100644 --- a/tests/db/fake_data/fixture_makers.py +++ b/tests/db/fake_data/fixture_makers.py @@ -103,3 +103,41 @@ def make_order(make_address, make_courier, make_customer, make_restaurant): ) return func + + +@pytest.fixture +def make_replay_order(make_order, simulation): + """Replaces `ReplayedOrderFactory.build()`: Create a `ReplayedOrder`.""" + # Reset the identifiers before every test. + factories.ReplayedOrderFactory.reset_sequence(1) + + def func(scheduled=False, order=None, **kwargs): + """Create a new `ReplayedOrder` object. + + Each `ReplayOrder` is made by a new `Customer` with a unique `Address`. + + Args: + scheduled: if an `Order` is a pre-order + order: the `Order` that is replayed + kwargs: keyword arguments forwarded to the `ReplayedOrderFactory` + + Returns: + order + + Raises: + RuntimeError: if `scheduled=True` is passed in + together with an ad-hoc `order` + """ + if scheduled: + if order and order.ad_hoc is False: + raise RuntimeError('`order` must be scheduled') + elif order is None: + order = make_order(scheduled=True) + elif order is None: + order = make_order() + + return factories.ReplayedOrderFactory.build( + simulation=simulation, actual=order, courier=order.courier, **kwargs, + ) + + return func diff --git a/tests/db/fake_data/static_fixtures.py b/tests/db/fake_data/static_fixtures.py index 60d4181..4be94c5 100644 --- a/tests/db/fake_data/static_fixtures.py +++ b/tests/db/fake_data/static_fixtures.py @@ -54,10 +54,22 @@ def restaurant(address, make_restaurant): @pytest.fixture def order(make_order, restaurant): - """An `Order` object for the `restaurant`.""" + """An ad-hoc `Order` object for the `restaurant`.""" return make_order(restaurant=restaurant) +@pytest.fixture +def pre_order(make_order, restaurant): + """A scheduled `Order` object for the `restaurant`.""" + return make_order(restaurant=restaurant, scheduled=True) + + +@pytest.fixture +def replayed_order(make_replay_order, order): + """A `ReplayedOrder` object for the `restaurant`.""" + return make_replay_order(order=order) + + @pytest.fixture def grid(city): """A `Grid` with a pixel area of 1 square kilometer.""" @@ -68,3 +80,20 @@ def grid(city): def pixel(grid): """The `Pixel` in the lower-left corner of the `grid`.""" return db.Pixel(id=1, grid=grid, n_x=0, n_y=0) + + +@pytest.fixture +def simulation_data(city): + """The data for the one and only `ReplaySimulation` object as a `dict`.""" + return { + 'id': 1, + 'city': city, + 'strategy': 'best_possible_routing', + 'run': 0, + } + + +@pytest.fixture +def simulation(simulation_data): + """The one and only `ReplaySimulation` object.""" + return db.ReplaySimulation(**simulation_data) diff --git a/tests/db/replay/__init__.py b/tests/db/replay/__init__.py new file mode 100644 index 0000000..cd9da80 --- /dev/null +++ b/tests/db/replay/__init__.py @@ -0,0 +1 @@ +"""Test the replay simulation models in the ORM layer.""" diff --git a/tests/db/replay/test_orders.py b/tests/db/replay/test_orders.py new file mode 100644 index 0000000..0ad62eb --- /dev/null +++ b/tests/db/replay/test_orders.py @@ -0,0 +1,585 @@ +"""Test the ORM's `ReplayedOrder` model.""" + +import datetime as dt + +import pytest +import sqlalchemy as sqla +from sqlalchemy import exc as sa_exc + +from tests import config as test_config +from urban_meal_delivery import db + + +class TestSpecialMethods: + """Test special methods in `ReplayedOrder`.""" + + def test_create_order(self, replayed_order): + """Test instantiation of a new `ReplayedOrder` object.""" + assert replayed_order is not None + + def test_text_representation(self, replayed_order): + """`ReplayedOrder` has a non-literal text representation.""" + result = repr(replayed_order) + + assert result == f'' + + +@pytest.mark.db +@pytest.mark.no_cover +class TestConstraints: + """Test the database constraints defined in `ReplayedOrder`.""" + + def test_insert_into_into_database(self, db_session, replayed_order): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.ReplayedOrder).count() == 0 + + db_session.add(replayed_order) + db_session.commit() + + assert db_session.query(db.ReplayedOrder).count() == 1 + + def test_delete_a_referenced_simulation(self, db_session, replayed_order): + """Remove a record that is referenced with a FK.""" + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.ReplaySimulation).where( + db.ReplaySimulation.id == replayed_order.simulation.id, + ) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_replayed_orders_to_replay_simulations', + ): + db_session.execute(stmt) + + def test_delete_a_referenced_actual_order(self, db_session, replayed_order): + """Remove a record that is referenced with a FK.""" + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Order).where(db.Order.id == replayed_order.actual.id) + + with pytest.raises(sa_exc.IntegrityError, match='fk_replayed_orders_to_orders'): + db_session.execute(stmt) + + def test_delete_a_referenced_courier( + self, db_session, replayed_order, make_courier, + ): + """Remove a record that is referenced with a FK.""" + # Need a second courier, one that is + # not associated with the `.actual` order. + replayed_order.courier = make_courier() + + db_session.add(replayed_order) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Courier).where(db.Courier.id == replayed_order.courier.id) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_replayed_orders_to_couriers', + ): + db_session.execute(stmt) + + def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, replayed_order): + """Insert an instance with invalid data.""" + assert replayed_order.ad_hoc is True + + replayed_order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order', + ): + db_session.commit() + + def test_scheduled_order_without_scheduled_delivery_at( + self, db_session, make_replay_order, + ): + """Insert an instance with invalid data.""" + replay_order = make_replay_order(scheduled=True) + + assert replay_order.ad_hoc is False + + replay_order.scheduled_delivery_at = None + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order', + ): + db_session.commit() + + def test_ad_hoc_order_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + replay_order = make_replay_order( + placed_at=dt.datetime(*test_config.DATE, 10, 0), + ) + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours', + ): + db_session.commit() + + def test_ad_hoc_order_too_late(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + replay_order = make_replay_order( + placed_at=dt.datetime(*test_config.DATE, 23, 0), + ) + + db_session.add(replay_order) + + with pytest.raises( + sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_way_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 10, 0) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=12), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_a_bit_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 30) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=14), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + def test_scheduled_order_not_too_early(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + 11.45 is the only time outside noon to 11 pm when a scheduled order is allowed. + """ + scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 45) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=14), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1), + dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1), + ) + + assert db_session.query(db.Order).count() == 0 + + db_session.add(replay_pre_order) + db_session.commit() + + assert db_session.query(db.Order).count() == 1 + + def test_scheduled_order_too_late(self, db_session, make_replay_order): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime(*test_config.DATE, 23, 0) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_within_business_hours', + ): + db_session.commit() + + @pytest.mark.parametrize('minute', [min_ for min_ in range(60) if min_ % 15 != 0]) + def test_scheduled_order_at_non_quarter_of_an_hour( + self, db_session, make_replay_order, minute, + ): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime( # `minute` is not 0, 15, 30, or 45 + *test_config.DATE, test_config.NOON, minute, + ) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='scheduled_orders_must_be_at_quart', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('second', list(range(1, 60))) + def test_scheduled_order_at_non_quarter_of_an_hour_by_seconds( + self, db_session, make_replay_order, second, + ): + """Insert an instance with invalid data.""" + scheduled_delivery_at = dt.datetime( + *test_config.DATE, test_config.NOON, 0, second, + ) + replay_pre_order = make_replay_order( + scheduled=True, + placed_at=scheduled_delivery_at - dt.timedelta(hours=10), + scheduled_delivery_at=scheduled_delivery_at, + restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30), + dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30), + ) + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='scheduled_orders_must_be_at_quart', # constraint name too long + ): + db_session.commit() + + def test_scheduled_order_too_soon(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + Scheduled orders must be at least 30 minutes into the future. + """ + # Create an ad-hoc order first and then make that a scheduled order. + # This way, it is less work to keep the timestamps consistent. + replay_pre_order = make_replay_order(scheduled=False) + + # Make the `scheduled_delivery_at` the quarter of an hour + # following the next quarter of an hour (i.e., the timestamp + # is between 15 and 30 minutes into the future). + replay_pre_order.ad_hoc = False + replay_pre_order.actual.ad_hoc = False + minutes_to_next_quarter = 15 - (replay_pre_order.placed_at.minute % 15) + scheduled_delivery_at = ( + # `.placed_at` may have non-0 seconds. + replay_pre_order.placed_at.replace(second=0) + + dt.timedelta(minutes=(minutes_to_next_quarter + 15)) + ) + replay_pre_order.scheduled_delivery_at = scheduled_delivery_at + replay_pre_order.actual.scheduled_delivery_at = scheduled_delivery_at + replay_pre_order.actual.scheduled_delivery_at_corrected = False + + db_session.add(replay_pre_order) + + with pytest.raises( + sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes', + ): + db_session.commit() + + @pytest.mark.parametrize( + 'column', + [ + 'restaurant_notified_at', + 'restaurant_confirmed_at', + 'restaurant_ready_at', + 'dispatch_at', + 'courier', # not `.courier_id` + 'courier_notified_at', + 'courier_accepted_at', + 'reached_pickup_at', + 'pickup_at', + 'left_pickup_at', + 'reached_delivery_at', + 'delivery_at', + ], + ) + def test_not_fully_simulated_order(self, db_session, replayed_order, column): + """Insert an instance with invalid data.""" + assert replayed_order.cancelled_at is None + + setattr(replayed_order, column, None) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='either_cancelled_o', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize( + 'column', + [ + 'restaurant_notified_at', + 'restaurant_confirmed_at', + 'restaurant_ready_at', + 'dispatch_at', + 'courier_id', + 'courier_notified_at', + 'courier_accepted_at', + 'reached_pickup_at', + ], + ) + def test_simulated_cancellation(self, db_session, replayed_order, column): + """Insert an instance with invalid data. + + Cancelled orders may have missing timestamps + """ + replayed_order.cancelled_at = replayed_order.pickup_at + + replayed_order.pickup_at = None + replayed_order.left_pickup_at = None + replayed_order.reached_delivery_at = None + replayed_order.delivery_at = None + + setattr(replayed_order, column, None) + + db_session.add(replayed_order) + db_session.commit() + + @pytest.mark.parametrize( + 'column', ['pickup_at', 'left_pickup_at', 'reached_delivery_at', 'delivery_at'], + ) + def test_no_simulated_cancellation_after_pickup( + self, db_session, replayed_order, column, + ): + """Insert an instance with invalid data.""" + # Setting `.cancelled_at` to the end of a day + # ensures the timestamps are logically ok. + replayed_order.cancelled_at = dt.datetime(*test_config.DATE, 23) + + # Set all timestamps after `.reached_pickup_at` to NULL + # except the one under test. + for unset_column in ( + 'pickup_at', + 'left_pickup_at', + 'reached_delivery_at', + 'delivery_at', + ): + if unset_column != column: + setattr(replayed_order, unset_column, None) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, + match='cancellations_may_only_occur_befo', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('duration', [-1, 3601]) + def test_estimated_prep_duration_out_of_range( + self, db_session, replayed_order, duration, + ): + """Insert an instance with invalid data.""" + replayed_order.estimated_prep_duration = duration + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='between_0', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('duration', [1, 59, 119, 3599]) + def test_estimated_prep_duration_not_whole_minute( + self, db_session, replayed_order, duration, + ): + """Insert an instance with invalid data.""" + replayed_order.estimated_prep_duration = duration + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='must_be_w', # constraint name too long + ): + db_session.commit() + + @pytest.mark.parametrize('utilization', [-1, 101]) + def test_utilization_out_of_range(self, db_session, replayed_order, utilization): + """Insert an instance with invalid data.""" + replayed_order.utilization = utilization + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='between_0_and_100', + ): + db_session.commit() + + @pytest.mark.parametrize('column', ['restaurant_notified_at', 'dispatch_at']) + @pytest.mark.parametrize('hour', [0, 10]) + def test_order_dispatched_in_non_business_hour( + self, db_session, replayed_order, column, hour, + ): + """Insert an instance with invalid data.""" + orig_timestamp = getattr(replayed_order, column) + new_timestamp = orig_timestamp.replace(hour=hour) + + replayed_order.placed_at = new_timestamp - dt.timedelta(minutes=1) + setattr(replayed_order, column, new_timestamp) + + db_session.add(replayed_order) + + with pytest.raises( + sa_exc.IntegrityError, match='business_hours', + ): + db_session.commit() + + @pytest.mark.parametrize( + 'comparison', + [ + 'placed_at < restaurant_notified_at', + 'placed_at < restaurant_confirmed_at', + 'placed_at < restaurant_ready_at', + 'placed_at < dispatch_at', + 'placed_at < first_estimated_delivery_at', + 'placed_at < courier_notified_at', + 'placed_at < courier_accepted_at', + 'placed_at < reached_pickup_at', + 'placed_at < pickup_at', + 'placed_at < left_pickup_at', + 'placed_at < reached_delivery_at', + 'placed_at < delivery_at', + 'restaurant_notified_at < restaurant_confirmed_at', + 'restaurant_notified_at < restaurant_ready_at', + 'restaurant_notified_at < pickup_at', + 'restaurant_confirmed_at < restaurant_ready_at', + 'restaurant_confirmed_at < pickup_at', + 'restaurant_ready_at < pickup_at', + 'dispatch_at < first_estimated_delivery_at', + 'dispatch_at < courier_notified_at', + 'dispatch_at < courier_accepted_at', + 'dispatch_at < reached_pickup_at', + 'dispatch_at < pickup_at', + 'dispatch_at < left_pickup_at', + 'dispatch_at < reached_delivery_at', + 'dispatch_at < delivery_at', + 'courier_notified_at < courier_accepted_at', + 'courier_notified_at < reached_pickup_at', + 'courier_notified_at < pickup_at', + 'courier_notified_at < left_pickup_at', + 'courier_notified_at < reached_delivery_at', + 'courier_notified_at < delivery_at', + 'courier_accepted_at < reached_pickup_at', + 'courier_accepted_at < pickup_at', + 'courier_accepted_at < left_pickup_at', + 'courier_accepted_at < reached_delivery_at', + 'courier_accepted_at < delivery_at', + 'reached_pickup_at < pickup_at', + 'reached_pickup_at < left_pickup_at', + 'reached_pickup_at < reached_delivery_at', + 'reached_pickup_at < delivery_at', + 'pickup_at < left_pickup_at', + 'pickup_at < reached_delivery_at', + 'pickup_at < delivery_at', + 'left_pickup_at < reached_delivery_at', + 'left_pickup_at < delivery_at', + 'reached_delivery_at < delivery_at', + ], + ) + def test_timestamps_unordered( + self, db_session, replayed_order, comparison, + ): + """Insert an instance with invalid data. + + There are two special cases for this test case below, + where other attributes on `replayed_order` must be unset. + """ + smaller, bigger = comparison.split(' < ') + + assert smaller is not None + + violating_timestamp = getattr(replayed_order, smaller) - dt.timedelta(seconds=1) + setattr(replayed_order, bigger, violating_timestamp) + + db_session.add(replayed_order) + + with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'): + db_session.commit() + + def test_timestamps_unordered_scheduled(self, db_session, make_replay_order): + """Insert an instance with invalid data. + + This is one of two special cases. See the generic case above. + """ + replayed_pre_order = make_replay_order(scheduled=True) + # As we subtract 1 second in the generic case, + # choose one second after a quarter of an hour. + replayed_pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1) + self.test_timestamps_unordered( + db_session, replayed_pre_order, 'placed_at < scheduled_delivery_at', + ) + + @pytest.mark.parametrize( + 'comparison', + [ + 'placed_at < cancelled_at', + 'restaurant_notified_at < cancelled_at', + 'restaurant_confirmed_at < cancelled_at', + 'restaurant_ready_at < cancelled_at', + 'dispatch_at < cancelled_at', + 'courier_notified_at < cancelled_at', + 'courier_accepted_at < cancelled_at', + 'reached_pickup_at < cancelled_at', + ], + ) + def test_timestamps_unordered_cancelled( + self, db_session, replayed_order, comparison, + ): + """Insert an instance with invalid data. + + This is one of two special cases. See the generic case above. + """ + replayed_order.pickup_at = None + replayed_order.left_pickup_at = None + replayed_order.reached_delivery_at = None + replayed_order.delivery_at = None + + self.test_timestamps_unordered(db_session, replayed_order, comparison) + + +class TestProperties: + """Test properties in `ReplayedOrder`. + + The `replayed_order` fixture uses the defaults specified in + `factories.ReplayedOrderFactory` and provided by the `make_replay_order` fixture. + """ + + def test_has_customer(self, replayed_order): + """Test `ReplayedOrder.customer` property.""" + result = replayed_order.customer + + assert result is replayed_order.actual.customer + + def test_has_restaurant(self, replayed_order): + """Test `ReplayedOrder.restaurant` property.""" + result = replayed_order.restaurant + + assert result is replayed_order.actual.restaurant + + def test_has_pickup_address(self, replayed_order): + """Test `ReplayedOrder.pickup_address` property.""" + result = replayed_order.pickup_address + + assert result is replayed_order.actual.pickup_address + + def test_has_delivery_address(self, replayed_order): + """Test `ReplayedOrder.delivery_address` property.""" + result = replayed_order.delivery_address + + assert result is replayed_order.actual.delivery_address diff --git a/tests/db/replay/test_simulations.py b/tests/db/replay/test_simulations.py new file mode 100644 index 0000000..5d2ce08 --- /dev/null +++ b/tests/db/replay/test_simulations.py @@ -0,0 +1,17 @@ +"""Test the ORM's `ReplaySimulation` model.""" + + +class TestSpecialMethods: + """Test special methods in `ReplaySimulation`.""" + + def test_create_simulation(self, simulation): + """Test instantiation of a new `ReplaySimulation` object.""" + assert simulation is not None + + def test_text_representation(self, simulation): + """`ReplaySimulation` has a non-literal text representation.""" + result = repr(simulation) + + assert result.startswith(' datetime.timedelta(0) + assert result > dt.timedelta(0) def test_time_to_react_no_courier_notified(self, order): """Test `Order.time_to_react` property.""" @@ -157,7 +755,7 @@ class TestProperties: """Test `Order.time_to_react` property.""" 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): """Test `Order.time_to_pickup` property.""" @@ -177,7 +775,7 @@ class TestProperties: """Test `Order.time_to_pickup` property.""" 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): """Test `Order.time_at_pickup` property.""" @@ -197,7 +795,7 @@ class TestProperties: """Test `Order.time_at_pickup` property.""" 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 """Test `Order.scheduled_pickup_at` property.""" @@ -309,7 +907,7 @@ class TestProperties: """Test `Order.time_to_delivery` property.""" 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 """Test `Order.time_at_delivery` property.""" @@ -329,9 +927,9 @@ class TestProperties: """Test `Order.time_at_delivery` property.""" 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.""" order._courier_waited_at_delivery = True @@ -357,7 +955,7 @@ class TestProperties: """Test `Order.delivery_early` property.""" order = make_order(scheduled=True) # 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 @@ -367,7 +965,7 @@ class TestProperties: """Test `Order.delivery_early` property.""" order = make_order(scheduled=True) # 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 @@ -383,7 +981,7 @@ class TestProperties: """Test `Order.delivery_early` property.""" order = make_order(scheduled=True) # 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 @@ -393,7 +991,7 @@ class TestProperties: """Test `Order.delivery_early` property.""" order = make_order(scheduled=True) # 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 @@ -417,17 +1015,17 @@ class TestProperties: """Test `Order.total_time` property.""" result = order.total_time - assert result > datetime.timedelta(0) + assert result > dt.timedelta(0) @pytest.mark.db @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, ): """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. """ # noqa:D202 # Generate a large number of `Order`s to obtain a large variance of data. diff --git a/tests/forecasts/conftest.py b/tests/forecasts/conftest.py index f258a3c..522eb0f 100644 --- a/tests/forecasts/conftest.py +++ b/tests/forecasts/conftest.py @@ -17,9 +17,7 @@ def horizontal_datetime_index(): The times resemble a horizontal time series with a `frequency` of `7`. All observations take place at `NOON`. """ - first_start_at = dt.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, test_config.NOON, 0, - ) + first_start_at = dt.datetime(*test_config.DATE, test_config.NOON, 0) gen = ( start_at diff --git a/tests/forecasts/timify/test_aggregate_orders.py b/tests/forecasts/timify/test_aggregate_orders.py index 325db74..b515e45 100644 --- a/tests/forecasts/timify/test_aggregate_orders.py +++ b/tests/forecasts/timify/test_aggregate_orders.py @@ -73,9 +73,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order) @@ -104,9 +102,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order) @@ -137,9 +133,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order) @@ -169,21 +163,15 @@ class TestAggregateOrders: ad_hoc_order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, 11, 11), ) db_session.add(ad_hoc_order) pre_order = make_order( scheduled=True, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, 9, 0, - ), - scheduled_delivery_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 0, - ), + placed_at=datetime.datetime(*test_config.DATE, 9, 0), + scheduled_delivery_at=datetime.datetime(*test_config.DATE, 12, 0), ) db_session.add(pre_order) @@ -215,9 +203,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order) @@ -252,9 +238,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order) @@ -333,9 +317,7 @@ class TestAggregateOrders: order = make_order( scheduled=False, restaurant=restaurant1, - placed_at=datetime.datetime( - test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11, - ), + placed_at=datetime.datetime(*test_config.DATE, hour, 11), ) db_session.add(order)