Compare commits

..

No commits in common. "develop" and "main" have entirely different histories.

40 changed files with 300 additions and 2588 deletions

View file

@ -16,16 +16,16 @@ that iteratively build on each other.
### Data Cleaning
The UDP provided its raw data as a PostgreSQL dump.
This [notebook](https://nbviewer.jupyter.org/github/webartifex/urban-meal-delivery/blob/develop/research/01_clean_data.ipynb)
This [notebook](https://nbviewer.jupyter.org/github/webartifex/urban-meal-delivery/blob/main/research/01_clean_data.ipynb)
cleans the data extensively
and maps them onto the [ORM models](https://github.com/webartifex/urban-meal-delivery/tree/develop/src/urban_meal_delivery/db)
and maps them onto the [ORM models](https://github.com/webartifex/urban-meal-delivery/tree/main/src/urban_meal_delivery/db)
defined in the `urban-meal-delivery` package
that is developed in the [src/](https://github.com/webartifex/urban-meal-delivery/tree/develop/src) folder
that is developed in the [src/](https://github.com/webartifex/urban-meal-delivery/tree/main/src) folder
and contains all source code to drive the analyses.
Due to a non-disclosure agreement with the UDP,
neither the raw nor the cleaned data are published as of now.
However, previews of the data can be seen throughout the [research/](https://github.com/webartifex/urban-meal-delivery/tree/develop/research) folder.
However, previews of the data can be seen throughout the [research/](https://github.com/webartifex/urban-meal-delivery/tree/main/research) folder.
### Tactical Demand Forecasting
@ -34,9 +34,9 @@ Before any optimizations of the UDP's operations are done,
a **demand forecasting** system for *tactical* purposes is implemented.
To achieve that, the cities first undergo a **gridification** step
where each *pickup* location is assigned into a pixel on a "checker board"-like grid.
The main part of the source code that implements that is in this [file](https://github.com/webartifex/urban-meal-delivery/blob/develop/src/urban_meal_delivery/db/grids.py#L60).
Visualizations of the various grids can be found in the [visualizations/](https://github.com/webartifex/urban-meal-delivery/tree/develop/research/visualizations) folder
and in this [notebook](https://nbviewer.jupyter.org/github/webartifex/urban-meal-delivery/blob/develop/research/03_grid_visualizations.ipynb).
The main part of the source code that implements that is in this [file](https://github.com/webartifex/urban-meal-delivery/blob/main/src/urban_meal_delivery/db/grids.py#L60).
Visualizations of the various grids can be found in the [visualizations/](https://github.com/webartifex/urban-meal-delivery/tree/main/research/visualizations) folder
and in this [notebook](https://nbviewer.jupyter.org/github/webartifex/urban-meal-delivery/blob/main/research/03_grid_visualizations.ipynb).
Then, demand is aggregated on a per-pixel level
and different kinds of order time series are generated.
@ -47,7 +47,7 @@ The details of how this works can be found in the first academic paper
published in the context of this research project
and titled "*Real-time Demand Forecasting for an Urban Delivery Platform*"
(cf., the [repository](https://github.com/webartifex/urban-meal-delivery-demand-forecasting) with the LaTeX files).
All demand forecasting related code is in the [forecasts/](https://github.com/webartifex/urban-meal-delivery/tree/develop/src/urban_meal_delivery/forecasts) sub-package.
All demand forecasting related code is in the [forecasts/](https://github.com/webartifex/urban-meal-delivery/tree/main/src/urban_meal_delivery/forecasts) sub-package.
### Predictive Routing
@ -71,11 +71,11 @@ and
`poetry install --extras research`
The `--extras` option is necessary as the non-develop dependencies
are structured in the [pyproject.toml](https://github.com/webartifex/urban-meal-delivery/blob/develop/pyproject.toml) file
are structured in the [pyproject.toml](https://github.com/webartifex/urban-meal-delivery/blob/main/pyproject.toml) file
into dependencies related to only the `urban-meal-delivery` source code package
and dependencies used to run the [Jupyter](https://jupyter.org/) environment
with the analyses.
Contributions are welcome.
Use the [issues](https://github.com/webartifex/urban-meal-delivery/issues) tab.
The project is licensed under the [MIT license](https://github.com/webartifex/urban-meal-delivery/blob/develop/LICENSE.txt).
The project is licensed under the [MIT license](https://github.com/webartifex/urban-meal-delivery/blob/main/LICENSE.txt).

View file

@ -1,413 +0,0 @@
"""Add simulation data.
Revision: # 81aa304d7a6e at 2021-09-14 13:47:03
Revises: # b4dd0b8903a5
"""
import os
import sqlalchemy as sa
from alembic import op
from urban_meal_delivery import configuration
revision = '81aa304d7a6e'
down_revision = 'b4dd0b8903a5'
branch_labels = None
depends_on = None
config = configuration.make_config('testing' if os.getenv('TESTING') else 'production')
def upgrade():
"""Upgrade to revision 81aa304d7a6e."""
# Drop unnecessary check constraint.
op.execute( # `.delivery_at` is not set for `.cancelled` orders anyways.
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
DROP CONSTRAINT ck_orders_on_ordered_timestamps_21;
""",
) # noqa:WPS355
# Add forgotten check constraints to the `orders` table.
op.execute(
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
ADD CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at
CHECK (placed_at < pickup_at);
""",
) # noqa:WPS355
op.execute(
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
ADD CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour
CHECK (
(
EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER
% 15 = 0
)
AND
(
EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER
= 0
)
);
""",
) # noqa:WPS355
# This `UniqueConstraint` is needed by the `replayed_orders` table below.
op.create_unique_constraint(
'uq_orders_on_id_ad_hoc',
'orders',
['id', 'ad_hoc'],
schema=config.CLEAN_SCHEMA,
)
op.create_table(
'replay_simulations',
sa.Column('id', sa.Integer, autoincrement=True),
sa.Column('city_id', sa.SmallInteger, nullable=False),
sa.Column('strategy', sa.Unicode(length=100), nullable=False),
sa.Column('run', sa.SmallInteger, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_replay_simulations')),
sa.ForeignKeyConstraint(
['city_id'],
[f'{config.CLEAN_SCHEMA}.cities.id'],
name=op.f('fk_replay_simulations_to_cities_via_city_id'),
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.UniqueConstraint(
'city_id',
'strategy',
'run',
name=op.f('uq_replay_simulations_on_city_id_strategy_run'),
),
sa.CheckConstraint('run >= 0', name=op.f('run_is_a_count')),
schema=config.CLEAN_SCHEMA,
)
op.create_table(
'replayed_orders',
sa.Column('simulation_id', sa.Integer, primary_key=True),
sa.Column('actual_order_id', sa.Integer, primary_key=True),
sa.Column('ad_hoc', sa.Boolean, nullable=False),
sa.Column('placed_at', sa.DateTime, nullable=False),
sa.Column('scheduled_delivery_at', sa.DateTime),
sa.Column('cancelled_at', sa.DateTime),
sa.Column('estimated_prep_duration', sa.SmallInteger),
sa.Column('restaurant_notified_at', sa.DateTime),
sa.Column('restaurant_confirmed_at', sa.DateTime),
sa.Column('restaurant_ready_at', sa.DateTime),
sa.Column('dispatch_at', sa.DateTime),
sa.Column('first_estimated_delivery_at', sa.DateTime),
sa.Column('courier_id', sa.Integer),
sa.Column('courier_notified_at', sa.DateTime),
sa.Column('courier_accepted_at', sa.DateTime),
sa.Column('utilization', sa.SmallInteger),
sa.Column('reached_pickup_at', sa.DateTime),
sa.Column('pickup_at', sa.DateTime),
sa.Column('left_pickup_at', sa.DateTime),
sa.Column('reached_delivery_at', sa.DateTime),
sa.Column('delivery_at', sa.DateTime),
sa.PrimaryKeyConstraint(
'simulation_id', 'actual_order_id', name=op.f('pk_replayed_orders'),
),
sa.ForeignKeyConstraint(
['simulation_id'],
[f'{config.CLEAN_SCHEMA}.replay_simulations.id'],
name=op.f('fk_replayed_orders_to_replay_simulations_via_simulation_id'),
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.ForeignKeyConstraint(
# Needs the `UniqueConstraint` from above.
['actual_order_id', 'ad_hoc'],
[
f'{config.CLEAN_SCHEMA}.orders.id',
f'{config.CLEAN_SCHEMA}.orders.ad_hoc',
],
name=op.f('fk_replayed_orders_to_orders_via_actual_order_id_ad_hoc'),
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.ForeignKeyConstraint(
['courier_id'],
[f'{config.CLEAN_SCHEMA}.couriers.id'],
name=op.f('fk_replayed_orders_to_couriers_via_courier_id'),
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.CheckConstraint(
"""
(
ad_hoc IS TRUE
AND
scheduled_delivery_at IS NULL
)
OR
(
ad_hoc IS FALSE
AND
scheduled_delivery_at IS NOT NULL
)
""",
name=op.f('either_ad_hoc_or_scheduled_order'),
),
sa.CheckConstraint(
"""
NOT (
ad_hoc IS TRUE
AND (
EXTRACT(HOUR FROM placed_at) < 11
OR
EXTRACT(HOUR FROM placed_at) > 22
)
)
""",
name=op.f('ad_hoc_orders_within_business_hours'),
),
sa.CheckConstraint(
"""
NOT (
ad_hoc IS FALSE
AND (
(
EXTRACT(HOUR FROM scheduled_delivery_at) <= 11
AND
NOT (
EXTRACT(HOUR FROM scheduled_delivery_at) = 11
AND
EXTRACT(MINUTE FROM scheduled_delivery_at) = 45
)
)
OR
EXTRACT(HOUR FROM scheduled_delivery_at) > 22
)
)
""",
name=op.f('scheduled_orders_within_business_hours'),
),
sa.CheckConstraint(
"""
(
EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER
% 15 = 0
)
AND
(
EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER
= 0
)
""",
name=op.f('scheduled_orders_must_be_at_quarters_of_an_hour'),
),
sa.CheckConstraint(
"""
NOT (
EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800
)
""",
name=op.f('scheduled_orders_not_within_30_minutes'),
),
sa.CheckConstraint(
"""
cancelled_at IS NOT NULL
OR
(
restaurant_notified_at IS NOT NULL
AND
restaurant_confirmed_at IS NOT NULL
AND
restaurant_ready_at IS NOT NULL
AND
dispatch_at IS NOT NULL
AND
courier_id IS NOT NULL
AND
courier_notified_at IS NOT NULL
AND
courier_accepted_at IS NOT NULL
AND
reached_pickup_at IS NOT NULL
AND
pickup_at IS NOT NULL
AND
left_pickup_at IS NOT NULL
AND
reached_delivery_at IS NOT NULL
AND
delivery_at IS NOT NULL
)
""",
name=op.f('orders_must_be_either_cancelled_or_fully_simulated'),
),
sa.CheckConstraint(
"""
NOT ( -- Only occurred in 528 of 660,608 orders in the actual data.
cancelled_at IS NOT NULL
AND
pickup_at IS NOT NULL
)
AND
NOT ( -- Only occurred in 176 of 660,608 orders in the actual data.
cancelled_at IS NOT NULL
AND
left_pickup_at IS NOT NULL
)
AND
NOT ( -- Never occurred in the actual data.
cancelled_at IS NOT NULL
AND
reached_delivery_at IS NOT NULL
)
AND
NOT ( -- Never occurred in the actual data.
cancelled_at IS NOT NULL
AND
delivery_at IS NOT NULL
)
""",
name=op.f('cancellations_may_only_occur_before_pickup'),
),
sa.CheckConstraint(
'0 <= estimated_prep_duration AND estimated_prep_duration <= 3600',
name=op.f('estimated_prep_duration_between_0_and_3600'),
),
sa.CheckConstraint(
'estimated_prep_duration % 60 = 0',
name=op.f('estimated_prep_duration_must_be_whole_minutes'),
),
sa.CheckConstraint(
'0 <= utilization AND utilization <= 100',
name=op.f('utilization_between_0_and_100'),
),
sa.CheckConstraint(
"""
NOT (
EXTRACT(HOUR FROM restaurant_notified_at) < 11
OR
EXTRACT(HOUR FROM dispatch_at) < 11
)
""",
name=op.f('orders_dispatched_in_business_hours'),
),
*(
sa.CheckConstraint(
constraint, name='ordered_timestamps_{index}'.format(index=index),
)
for index, constraint in enumerate(
(
'placed_at < scheduled_delivery_at',
'placed_at < cancelled_at',
'placed_at < restaurant_notified_at',
'placed_at < restaurant_confirmed_at',
'placed_at < restaurant_ready_at',
'placed_at < dispatch_at',
'placed_at < first_estimated_delivery_at',
'placed_at < courier_notified_at',
'placed_at < courier_accepted_at',
'placed_at < reached_pickup_at',
'placed_at < pickup_at',
'placed_at < left_pickup_at',
'placed_at < reached_delivery_at',
'placed_at < delivery_at',
'cancelled_at > restaurant_notified_at',
'cancelled_at > restaurant_confirmed_at',
'cancelled_at > restaurant_ready_at',
'cancelled_at > dispatch_at',
'cancelled_at > courier_notified_at',
'cancelled_at > courier_accepted_at',
'cancelled_at > reached_pickup_at',
'restaurant_notified_at < restaurant_confirmed_at',
'restaurant_notified_at < restaurant_ready_at',
'restaurant_notified_at < pickup_at',
'restaurant_confirmed_at < restaurant_ready_at',
'restaurant_confirmed_at < pickup_at',
'restaurant_ready_at < pickup_at',
'dispatch_at < first_estimated_delivery_at',
'dispatch_at < courier_notified_at',
'dispatch_at < courier_accepted_at',
'dispatch_at < reached_pickup_at',
'dispatch_at < pickup_at',
'dispatch_at < left_pickup_at',
'dispatch_at < reached_delivery_at',
'dispatch_at < delivery_at',
'courier_notified_at < courier_accepted_at',
'courier_notified_at < reached_pickup_at',
'courier_notified_at < pickup_at',
'courier_notified_at < left_pickup_at',
'courier_notified_at < reached_delivery_at',
'courier_notified_at < delivery_at',
'courier_accepted_at < reached_pickup_at',
'courier_accepted_at < pickup_at',
'courier_accepted_at < left_pickup_at',
'courier_accepted_at < reached_delivery_at',
'courier_accepted_at < delivery_at',
'reached_pickup_at < pickup_at',
'reached_pickup_at < left_pickup_at',
'reached_pickup_at < reached_delivery_at',
'reached_pickup_at < delivery_at',
'pickup_at < left_pickup_at',
'pickup_at < reached_delivery_at',
'pickup_at < delivery_at',
'left_pickup_at < reached_delivery_at',
'left_pickup_at < delivery_at',
'reached_delivery_at < delivery_at',
),
)
),
schema=config.CLEAN_SCHEMA,
)
op.create_index(
op.f('ix_replay_simulations_on_city_id'),
'replay_simulations',
['city_id'],
unique=False,
schema=config.CLEAN_SCHEMA,
)
op.create_index(
op.f('ix_replay_simulations_on_strategy'),
'replay_simulations',
['strategy'],
unique=False,
schema=config.CLEAN_SCHEMA,
)
op.create_index(
op.f('ix_replayed_orders_on_courier_id'),
'replayed_orders',
['courier_id'],
unique=False,
schema=config.CLEAN_SCHEMA,
)
def downgrade():
"""Downgrade to revision b4dd0b8903a5."""
op.drop_table('replayed_orders', schema=config.CLEAN_SCHEMA)
op.drop_table('replay_simulations', schema=config.CLEAN_SCHEMA)
op.drop_constraint(
'uq_orders_on_id_ad_hoc', 'orders', type_=None, schema=config.CLEAN_SCHEMA,
)
op.execute(
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
DROP CONSTRAINT check_orders_on_scheduled_orders_must_be_at_quarters_of_an_hour;
""",
) # noqa:WPS355
op.execute(
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
DROP CONSTRAINT check_orders_on_ordered_timestamps_placed_at_before_pickup_at;
""",
) # noqa:WPS355
op.execute(
f"""
ALTER TABLE {config.CLEAN_SCHEMA}.orders
ADD CONSTRAINT ck_orders_on_ordered_timestamps_21
CHECK (cancelled_at > delivery_at);
""",
) # noqa:WPS355

View file

@ -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
def fix_branch_references(session): # noqa:WPS210,WPS231
"""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):
def clean_pwd(session): # noqa:WPS231
"""Remove (almost) all glob patterns listed in .gitignore.
The difference compared to `git clean -X` is that this task

View file

@ -9,7 +9,7 @@ target-version = ["py38"]
[tool.poetry]
name = "urban-meal-delivery"
version = "0.5.0.dev0"
version = "0.4.0"
authors = ["Alexander Hess <alexander@webartifex.biz>"]
description = "Optimizing an urban meal delivery platform"

View file

@ -19,7 +19,7 @@
"- numeric columns are checked for plausibility\n",
"- foreign key relationships are strictly enforced\n",
"\n",
"The structure of the data can be viewed at the [ORM layer](https://github.com/webartifex/urban-meal-delivery/tree/develop/src/urban_meal_delivery/db) in the package."
"The structure of the data can be viewed at the [ORM layer](https://github.com/webartifex/urban-meal-delivery/tree/main/src/urban_meal_delivery/db) in the package."
]
},
{

View file

@ -621,7 +621,7 @@
"id": "fb6a035d",
"metadata": {},
"source": [
"In the application's code base, the above API calls and the related data are modeled as `Path` objects connecting two `Address` objects (cf. [Path class](https://github.com/webartifex/urban-meal-delivery/blob/develop/src/urban_meal_delivery/db/addresses_addresses.py) in the code).\n",
"In the application's code base, the above API calls and the related data are modeled as `Path` objects connecting two `Address` objects (cf. [Path class](https://github.com/webartifex/urban-meal-delivery/blob/main/src/urban_meal_delivery/db/addresses_addresses.py) in the code).\n",
"\n",
"Let's look at two examples addresses, one from a `Restaurant` and one from a `Customer`."
]

View file

@ -93,15 +93,8 @@ 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.
@ -111,8 +104,6 @@ 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,
@ -150,9 +141,24 @@ 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,
@ -172,8 +178,6 @@ 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.

View file

@ -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
def tactical_heuristic( # noqa:C901,WPS213,WPS216,WPS231
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.

View file

@ -14,6 +14,4 @@ from urban_meal_delivery.db.grids import Grid
from urban_meal_delivery.db.meta import Base
from urban_meal_delivery.db.orders import Order
from urban_meal_delivery.db.pixels import Pixel
from urban_meal_delivery.db.replay import ReplayedOrder
from urban_meal_delivery.db.replay import ReplaySimulation
from urban_meal_delivery.db.restaurants import Restaurant

View file

@ -39,7 +39,6 @@ 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
@ -118,7 +117,7 @@ class City(meta.Base):
return self._map
def draw_restaurants(
def draw_restaurants( # noqa:WPS231
self, order_counts: bool = False, # pragma: no cover
) -> folium.Map:
"""Draw all restaurants on the`.map`.
@ -170,15 +169,15 @@ class City(meta.Base):
)
# ... and adjust the size of the red dot on the `.map`.
if n_orders >= 1000:
radius = 20
radius = 20 # noqa:WPS220
elif n_orders >= 500:
radius = 15
radius = 15 # noqa:WPS220
elif n_orders >= 100:
radius = 10
radius = 10 # noqa:WPS220
elif n_orders >= 10:
radius = 5
radius = 5 # noqa:WPS220
else:
radius = 1
radius = 1 # noqa:WPS220
tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

@ -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
def draw( # noqa:C901,WPS210,WPS231
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
radius = 20 # noqa:WPS220
elif n_orders >= 10:
radius = 15
radius = 15 # noqa:WPS220
elif n_orders >= 5:
radius = 10
radius = 10 # noqa:WPS220
elif n_orders > 1:
radius = 5
radius = 5 # noqa:WPS220
else:
radius = 1
radius = 1 # noqa:WPS220
address.draw(
radius=radius,
@ -156,15 +156,15 @@ class Customer(meta.Base):
.count()
)
if n_orders >= 25:
radius = 20
radius = 20 # noqa:WPS220
elif n_orders >= 10:
radius = 15
radius = 15 # noqa:WPS220
elif n_orders >= 5:
radius = 10
radius = 10 # noqa:WPS220
elif n_orders > 1:
radius = 5
radius = 5 # noqa:WPS220
else:
radius = 1
radius = 1 # noqa:WPS220
tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

@ -105,8 +105,6 @@ 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)
@ -149,20 +147,6 @@ 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 (
@ -272,7 +256,6 @@ 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',
@ -285,6 +268,7 @@ 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',
@ -339,7 +323,6 @@ class Order(meta.Base): # noqa:WPS214
back_populates='orders_delivered',
foreign_keys='[Order.delivery_address_id]',
)
replays = orm.relationship('ReplayedOrder', back_populates='actual')
# Convenience properties

View file

@ -136,7 +136,7 @@ class Pixel(meta.Base):
"""Shortcut to the `.city.map` object."""
return self.grid.city.map
def draw( # noqa:C901,WPS210
def draw( # noqa:C901,WPS210,WPS231
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
radius = 20 # noqa:WPS220
elif n_orders >= 500:
radius = 15
radius = 15 # noqa:WPS220
elif n_orders >= 100:
radius = 10
radius = 10 # noqa:WPS220
elif n_orders >= 10:
radius = 5
radius = 5 # noqa:WPS220
else:
radius = 1
radius = 1 # noqa:WPS220
tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

@ -1,4 +0,0 @@
"""Provide the ORM models regarding the replay simulations."""
from urban_meal_delivery.db.replay.orders import ReplayedOrder
from urban_meal_delivery.db.replay.simulations import ReplaySimulation

View file

@ -1,364 +0,0 @@
"""Provide the ORM's `ReplayedOrder` model for the replay simulations."""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy import orm
from urban_meal_delivery.db import meta
class ReplayedOrder(meta.Base):
"""A simulated order by a `Customer` of the UDP."""
__tablename__ = 'replayed_orders'
# Generic columns
simulation_id = sa.Column(sa.Integer, primary_key=True)
actual_order_id = sa.Column(sa.Integer, primary_key=True)
# `Order`-type related columns
# `.ad_hoc` is part of a `ForeignKeyConstraint` to ensure the type
# of the `ReplayedOrder` (i.e., ad-hoc or scheduled) is in line
# with the `.actual` order.
ad_hoc = sa.Column(sa.Boolean, nullable=False)
placed_at = sa.Column(sa.DateTime, nullable=False)
# Depending on `.ad_hoc`, `.scheduled_delivery_at` is either filled in or not.
scheduled_delivery_at = sa.Column(sa.DateTime)
# If an order is cancelled in a simulation,
# some of the columns below do not apply any more.
cancelled_at = sa.Column(sa.DateTime)
# Restaurant-related columns
estimated_prep_duration = sa.Column(sa.SmallInteger)
restaurant_notified_at = sa.Column(sa.DateTime)
restaurant_confirmed_at = sa.Column(sa.DateTime)
restaurant_ready_at = sa.Column(sa.DateTime)
# Dispatch-related columns
dispatch_at = sa.Column(sa.DateTime)
first_estimated_delivery_at = sa.Column(sa.DateTime)
courier_id = sa.Column(sa.Integer, index=True)
courier_notified_at = sa.Column(sa.DateTime)
courier_accepted_at = sa.Column(sa.DateTime)
utilization = sa.Column(sa.SmallInteger)
# Pickup-related columns
reached_pickup_at = sa.Column(sa.DateTime)
pickup_at = sa.Column(sa.DateTime)
left_pickup_at = sa.Column(sa.DateTime)
# Delivery-related columns
reached_delivery_at = sa.Column(sa.DateTime)
delivery_at = sa.Column(sa.DateTime)
# Constraints
__table_args__ = (
sa.ForeignKeyConstraint(
['simulation_id'],
['replay_simulations.id'],
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.ForeignKeyConstraint(
['actual_order_id', 'ad_hoc'],
['orders.id', 'orders.ad_hoc'],
onupdate='RESTRICT',
ondelete='RESTRICT',
),
sa.ForeignKeyConstraint(
['courier_id'], ['couriers.id'], onupdate='RESTRICT', ondelete='RESTRICT',
),
# The check constraints model some of the assumptions made in the simulations.
sa.CheckConstraint(
# Align the order's type, ad-hoc or scheduled, with the type of the
# `.actual` order and ensure the corresponding datetime column is set
# or not set.
"""
(
ad_hoc IS TRUE
AND
scheduled_delivery_at IS NULL
)
OR
(
ad_hoc IS FALSE
AND
scheduled_delivery_at IS NOT NULL
)
""",
name='either_ad_hoc_or_scheduled_order',
),
sa.CheckConstraint(
"""
NOT (
ad_hoc IS TRUE
AND (
EXTRACT(HOUR FROM placed_at) < 11
OR
EXTRACT(HOUR FROM placed_at) > 22
)
)
""",
name='ad_hoc_orders_within_business_hours',
),
sa.CheckConstraint(
"""
NOT (
ad_hoc IS FALSE
AND (
(
EXTRACT(HOUR FROM scheduled_delivery_at) <= 11
AND
NOT (
EXTRACT(HOUR FROM scheduled_delivery_at) = 11
AND
EXTRACT(MINUTE FROM scheduled_delivery_at) = 45
)
)
OR
EXTRACT(HOUR FROM scheduled_delivery_at) > 22
)
)
""",
name='scheduled_orders_within_business_hours',
),
sa.CheckConstraint(
"""
(
EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER
% 15 = 0
)
AND
(
EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER
= 0
)
""",
name='scheduled_orders_must_be_at_quarters_of_an_hour',
),
sa.CheckConstraint(
"""
NOT (
EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800
)
""",
name='scheduled_orders_not_within_30_minutes',
),
sa.CheckConstraint(
# A simulation must fill in at least all the timing and dispatch related
# columns, unless an order is cancelled in a run.
# Only `.estimated_prep_duration`, `.first_estimated_delivery_at`, and
# `.utilization` are truly optional.
# This constraint does allow missing columns for cancelled orders
# in any combination of the below columns. So, no precedence constraints
# are enforced (e.g., `.restaurant_notified_at` and `.restaurant_ready_at`
# could be set without `.restaurant_confirmed_at`). That is acceptable
# as cancelled orders are left out in the optimized KPIs.
"""
cancelled_at IS NOT NULL
OR
(
restaurant_notified_at IS NOT NULL
AND
restaurant_confirmed_at IS NOT NULL
AND
restaurant_ready_at IS NOT NULL
AND
dispatch_at IS NOT NULL
AND
courier_id IS NOT NULL
AND
courier_notified_at IS NOT NULL
AND
courier_accepted_at IS NOT NULL
AND
reached_pickup_at IS NOT NULL
AND
pickup_at IS NOT NULL
AND
left_pickup_at IS NOT NULL
AND
reached_delivery_at IS NOT NULL
AND
delivery_at IS NOT NULL
)
""",
name='orders_must_be_either_cancelled_or_fully_simulated',
),
sa.CheckConstraint(
# 23,487 out of the 660,608 orders were cancelled. In 685 of them, the
# cancellation occurred after the pickup (as modeled by the `.pickup_at`
# and `.left_pickup_at` columns, where the latter may be set with the
# former being unset). As a simplification, we only allow cancellations
# before the pickup in the simulations. Then, the restaurant throws away
# the meal and the courier is free for other orders right away. Orders
# cancelled after pickup in the actual data are assumed to be delivered
# to the customer's door (and then still accepted or thrown away by the
# customer). So, the courier becomes free only after the "fake" delivery.
"""
NOT ( -- Only occurred in 528 of 660,608 orders in the actual data.
cancelled_at IS NOT NULL
AND
pickup_at IS NOT NULL
)
AND
NOT ( -- Only occurred in 176 of 660,608 orders in the actual data.
cancelled_at IS NOT NULL
AND
left_pickup_at IS NOT NULL
)
AND
NOT ( -- Never occurred in the actual data.
cancelled_at IS NOT NULL
AND
reached_delivery_at IS NOT NULL
)
AND
NOT ( -- Never occurred in the actual data.
cancelled_at IS NOT NULL
AND
delivery_at IS NOT NULL
)
""",
name='cancellations_may_only_occur_before_pickup',
),
sa.CheckConstraint(
# The actual `.estimated_prep_duration` and `.estimated_prep_buffer`
# are modeled together as only one value. Therefore, the individual
# upper limits of 2700 and 900 are added and result in 3600.
'0 <= estimated_prep_duration AND estimated_prep_duration <= 3600',
name='estimated_prep_duration_between_0_and_3600',
),
sa.CheckConstraint(
# We still round estimates of the preparation time to whole minutes.
# Other estimates are unlikely to change the simulation results in
# a significant way.
'estimated_prep_duration % 60 = 0',
name='estimated_prep_duration_must_be_whole_minutes',
),
sa.CheckConstraint(
# If a simulation's `.strategy` models `.utilization`, it must be
# realistic. The value can be deduced from the actual order's
# courier's `.capacity` and the actual order's `.utilization`.
'0 <= utilization AND utilization <= 100',
name='utilization_between_0_and_100',
),
sa.CheckConstraint(
# The UDP is open from 11 am to 11 pm. So, before 11 am there is no
# activity. After 11 pm, the last orders of a day are all assumed to be
# dispatched before midnight.
"""
NOT (
EXTRACT(HOUR FROM restaurant_notified_at) < 11
OR
EXTRACT(HOUR FROM dispatch_at) < 11
)
""",
name='orders_dispatched_in_business_hours',
),
*(
# The timestamps must be in a logically correct order. That is the same as
# in the actual `Order` model with an extra `restaurant_ready_at` column
# and the non-simulated columns removed.
sa.CheckConstraint(
constraint, name='ordered_timestamps_{index}'.format(index=index),
)
for index, constraint in enumerate(
(
'placed_at < scheduled_delivery_at',
'placed_at < cancelled_at',
'placed_at < restaurant_notified_at',
'placed_at < restaurant_confirmed_at',
'placed_at < restaurant_ready_at',
'placed_at < dispatch_at',
'placed_at < first_estimated_delivery_at',
'placed_at < courier_notified_at',
'placed_at < courier_accepted_at',
'placed_at < reached_pickup_at',
'placed_at < pickup_at',
'placed_at < left_pickup_at',
'placed_at < reached_delivery_at',
'placed_at < delivery_at',
'cancelled_at > restaurant_notified_at',
'cancelled_at > restaurant_confirmed_at',
'cancelled_at > restaurant_ready_at',
'cancelled_at > dispatch_at',
'cancelled_at > courier_notified_at',
'cancelled_at > courier_accepted_at',
'cancelled_at > reached_pickup_at',
'restaurant_notified_at < restaurant_confirmed_at',
'restaurant_notified_at < restaurant_ready_at',
'restaurant_notified_at < pickup_at',
'restaurant_confirmed_at < restaurant_ready_at',
'restaurant_confirmed_at < pickup_at',
'restaurant_ready_at < pickup_at',
'dispatch_at < first_estimated_delivery_at',
'dispatch_at < courier_notified_at',
'dispatch_at < courier_accepted_at',
'dispatch_at < reached_pickup_at',
'dispatch_at < pickup_at',
'dispatch_at < left_pickup_at',
'dispatch_at < reached_delivery_at',
'dispatch_at < delivery_at',
'courier_notified_at < courier_accepted_at',
'courier_notified_at < reached_pickup_at',
'courier_notified_at < pickup_at',
'courier_notified_at < left_pickup_at',
'courier_notified_at < reached_delivery_at',
'courier_notified_at < delivery_at',
'courier_accepted_at < reached_pickup_at',
'courier_accepted_at < pickup_at',
'courier_accepted_at < left_pickup_at',
'courier_accepted_at < reached_delivery_at',
'courier_accepted_at < delivery_at',
'reached_pickup_at < pickup_at',
'reached_pickup_at < left_pickup_at',
'reached_pickup_at < reached_delivery_at',
'reached_pickup_at < delivery_at',
'pickup_at < left_pickup_at',
'pickup_at < reached_delivery_at',
'pickup_at < delivery_at',
'left_pickup_at < reached_delivery_at',
'left_pickup_at < delivery_at',
'reached_delivery_at < delivery_at',
),
)
),
)
# Relationships
simulation = orm.relationship('ReplaySimulation', back_populates='orders')
actual = orm.relationship('Order', back_populates='replays')
courier = orm.relationship('Courier')
def __repr__(self) -> str:
"""Non-literal text representation."""
return '<{cls}(#{order_id})>'.format(
cls=self.__class__.__name__, order_id=self.actual.id,
)
# Convenience properties
@property
def customer(self) -> db.Customer:
"""`.customer` of the actual `Order`."""
return self.actual.customer
@property
def restaurant(self) -> db.Restaurant:
"""`.restaurant` of the actual `Order`."""
return self.actual.restaurant
@property
def pickup_address(self) -> db.Address:
"""`.pickup_address` of the actual `Order`."""
return self.actual.pickup_address
@property
def delivery_address(self) -> db.Address:
"""`.delivery_address` of the actual `Order`."""
return self.actual.delivery_address
from urban_meal_delivery import db # noqa:E402 isort:skip

View file

@ -1,46 +0,0 @@
"""Provide the ORM's `ReplaySimulation` model for the replay simulations."""
import sqlalchemy as sa
from sqlalchemy import orm
from urban_meal_delivery.db import meta
class ReplaySimulation(meta.Base):
"""A simulation of the UDP's routing given a strategy ...
... for the orders as they arrived in reality.
"""
__tablename__ = 'replay_simulations'
# Columns
id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) # noqa:WPS125
city_id = sa.Column(sa.SmallInteger, nullable=False, index=True)
strategy = sa.Column(sa.Unicode(length=100), nullable=False, index=True)
# `.run` may be used as random seed.
run = sa.Column(sa.SmallInteger, nullable=False)
# Constraints
__table_args__ = (
sa.ForeignKeyConstraint(
['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT',
),
# A `.strategy` can be replayed several times per `.city`.
sa.UniqueConstraint('city_id', 'strategy', 'run'),
sa.CheckConstraint('run >= 0', name='run_is_a_count'),
)
# Relationships
city = orm.relationship('City', back_populates='replays')
orders = orm.relationship('ReplayedOrder', back_populates='simulation')
def __repr__(self) -> str:
"""Non-literal text representation."""
return '<{cls}({strategy} #{run} in {city})>'.format(
cls=self.__class__.__name__,
strategy=self.strategy,
run=self.run,
city=self.city.name,
)

View file

@ -69,7 +69,7 @@ class Restaurant(meta.Base):
"""Shortcut to the `.address.city.map` object."""
return self.address.city.map
def draw(
def draw( # noqa:WPS231
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
radius = 20 # noqa:WPS220
elif n_orders >= 10:
radius = 15
radius = 15 # noqa:WPS220
elif n_orders >= 5:
radius = 10
radius = 10 # noqa:WPS220
elif n_orders > 1:
radius = 5
radius = 5 # noqa:WPS220
else:
radius = 1
radius = 1 # noqa:WPS220
address.draw(
radius=radius,

View file

@ -11,7 +11,7 @@ from rpy2 import robjects
from rpy2.robjects import pandas2ri
def stl( # noqa:C901,WPS210,WPS211
def stl( # noqa:C901,WPS210,WPS211,WPS231
time_series: pd.Series,
*,
frequency: int,

View file

@ -7,8 +7,6 @@ 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

View file

@ -106,7 +106,6 @@ 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
@ -115,10 +114,6 @@ city_data = fake_data.city_data
courier = fake_data.courier
customer = fake_data.customer
order = fake_data.order
pre_order = fake_data.pre_order
replayed_order = fake_data.replayed_order
restaurant = fake_data.restaurant
grid = fake_data.grid
pixel = fake_data.pixel
simulation = fake_data.simulation
simulation_data = fake_data.simulation_data

View file

@ -4,7 +4,6 @@ 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
@ -14,8 +13,4 @@ from tests.db.fake_data.static_fixtures import customer
from tests.db.fake_data.static_fixtures import grid
from tests.db.fake_data.static_fixtures import order
from tests.db.fake_data.static_fixtures import pixel
from tests.db.fake_data.static_fixtures import pre_order
from tests.db.fake_data.static_fixtures import replayed_order
from tests.db.fake_data.static_fixtures import restaurant
from tests.db.fake_data.static_fixtures import simulation
from tests.db.fake_data.static_fixtures import simulation_data

View file

@ -1,17 +1,114 @@
"""Factory to create ad-hoc `Order` instances."""
"""Factories to create instances for the SQLAlchemy models."""
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.
@ -48,7 +145,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
cancel_=True,
cancelled_at=factory.LazyAttribute(
lambda obj: obj.dispatch_at
+ utils.random_timespan(
+ _random_timespan(
max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(),
),
),
@ -57,7 +154,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
cancel_=True,
cancelled_at=factory.LazyAttribute(
lambda obj: obj.pickup_at
+ utils.random_timespan(
+ _random_timespan(
max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(),
),
),
@ -70,8 +167,10 @@ 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.DATE, 11, 45)
+ utils.random_timespan(max_hours=2, max_minutes=30),
lambda: dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 45,
)
+ _random_timespan(max_hours=2, max_minutes=30),
)
ad_hoc = True
scheduled_delivery_at = None
@ -95,19 +194,14 @@ 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
+ 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,
lambda obj: obj.placed_at + _random_timespan(min_seconds=30, max_seconds=90),
)
restaurant_notified_at_corrected = False
restaurant_confirmed_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ 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,
+ _random_timespan(min_seconds=30, max_seconds=150),
)
restaurant_confirmed_at_corrected = False
# Use the database defaults of the historic data.
estimated_prep_duration = 900
estimated_prep_duration_corrected = False
@ -116,26 +210,19 @@ 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
+ utils.random_timespan(min_seconds=600, max_seconds=1080),
)
dispatch_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.dispatch_at else None,
lambda obj: obj.placed_at + _random_timespan(min_seconds=600, max_seconds=1080),
)
dispatch_at_corrected = False
courier_notified_at = factory.LazyAttribute(
lambda obj: obj.dispatch_at
+ 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,
+ _random_timespan(min_seconds=100, max_seconds=140),
)
courier_notified_at_corrected = False
courier_accepted_at = factory.LazyAttribute(
lambda obj: obj.courier_notified_at
+ 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,
+ _random_timespan(min_seconds=15, max_seconds=45),
)
courier_accepted_at_corrected = False
# Sample a realistic utilization.
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
@ -143,37 +230,30 @@ 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
+ utils.random_timespan(min_seconds=300, max_seconds=600),
+ _random_timespan(min_seconds=300, max_seconds=600),
)
pickup_at = factory.LazyAttribute(
lambda obj: obj.reached_pickup_at
+ utils.random_timespan(min_seconds=120, max_seconds=600),
)
pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.pickup_at else None,
+ _random_timespan(min_seconds=120, max_seconds=600),
)
pickup_at_corrected = False
pickup_not_confirmed = False
left_pickup_at = factory.LazyAttribute(
lambda obj: obj.pickup_at
+ utils.random_timespan(min_seconds=60, max_seconds=180),
)
left_pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.left_pickup_at else None,
lambda obj: obj.pickup_at + _random_timespan(min_seconds=60, max_seconds=180),
)
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
+ utils.random_timespan(min_seconds=240, max_seconds=480),
+ _random_timespan(min_seconds=240, max_seconds=480),
)
delivery_at = factory.LazyAttribute(
lambda obj: obj.reached_delivery_at
+ utils.random_timespan(min_seconds=240, max_seconds=660),
)
delivery_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None,
+ _random_timespan(min_seconds=240, max_seconds=660),
)
delivery_at_corrected = False
delivery_not_confirmed = False
_courier_waited_at_delivery = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None,
@ -200,7 +280,9 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
)
@factory.post_generation
def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
def post( # noqa:C901,WPS231
obj, create, extracted, **kwargs, # noqa:B902,N805
):
"""Discard timestamps that occur after cancellation."""
if obj.cancelled:
if obj.cancelled_at <= obj.restaurant_notified_at:
@ -234,3 +316,63 @@ 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),
)

View file

@ -1,9 +0,0 @@
"""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

View file

@ -1,40 +0,0 @@
"""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')

View file

@ -1,24 +0,0 @@
"""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

View file

@ -1,16 +0,0 @@
"""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

View file

@ -1,5 +0,0 @@
"""Factory to create `Order` instances."""
from tests.db.fake_data.factories.orders.ad_hoc import AdHocOrderFactory
from tests.db.fake_data.factories.orders.replayed import ReplayedOrderFactory
from tests.db.fake_data.factories.orders.scheduled import ScheduledOrderFactory

View file

@ -1,120 +0,0 @@
"""Factory to create `Order` and `ReplayedOrder` instances."""
import datetime as dt
import factory
from factory import alchemy
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db
class ReplayedOrderFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.ReplayedOrder` model.
For simplicity, we assume the underlying `.order` to be an ad-hoc `Order`.
"""
class Meta:
model = db.ReplayedOrder
sqlalchemy_get_or_create = ('simulation_id', 'order_id')
# Generic columns
# simulation -> set by the `make_replay_order` fixture for better control
# actual (`Order`) -> set by the `make_replay_order` fixture for better control
# `Order`-type related columns
ad_hoc = factory.LazyAttribute(lambda obj: obj.actual.ad_hoc)
placed_at = factory.LazyAttribute(lambda obj: obj.actual.placed_at)
scheduled_delivery_at = factory.LazyAttribute(
lambda obj: obj.actual.scheduled_delivery_at,
)
cancelled_at = None
# Restaurant-related columns
estimated_prep_duration = 1200
restaurant_notified_at = factory.LazyAttribute(
lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=1, max_seconds=30),
)
restaurant_confirmed_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
restaurant_ready_at = factory.LazyAttribute(
lambda obj: obj.restaurant_confirmed_at
+ dt.timedelta(seconds=obj.estimated_prep_duration)
+ utils.random_timespan(min_seconds=300, max_seconds=300),
)
# Dispatch-related columns
dispatch_at = factory.LazyAttribute(
lambda obj: obj.actual.placed_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
first_estimated_delivery_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ dt.timedelta(seconds=obj.estimated_prep_duration)
+ dt.timedelta(minutes=10),
)
# courier -> set by the `make_replay_order` fixture for better control
courier_notified_at = factory.LazyAttribute(
lambda obj: obj.dispatch_at
+ utils.random_timespan(min_seconds=1, max_seconds=30),
)
courier_accepted_at = factory.LazyAttribute(
lambda obj: obj.courier_notified_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
utilization = None
# Pickup-related columns
reached_pickup_at = factory.LazyAttribute(
lambda obj: obj.restaurant_ready_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
pickup_at = factory.LazyAttribute(
lambda obj: obj.reached_pickup_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
left_pickup_at = factory.LazyAttribute(
lambda obj: obj.pickup_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
# Delivery-related columns
reached_delivery_at = factory.LazyAttribute(
lambda obj: obj.left_pickup_at
+ utils.random_timespan(min_minutes=5, max_minutes=10),
)
delivery_at = factory.LazyAttribute(
lambda obj: obj.reached_delivery_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
@factory.post_generation
def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
"""Discard timestamps that occur after cancellation."""
if obj.cancelled_at:
if obj.cancelled_at <= obj.restaurant_notified_at:
obj.restaurant_notified_at = None
if obj.cancelled_at <= obj.restaurant_confirmed_at:
obj.restaurant_confirmed_at = None
if obj.cancelled_at <= obj.restaurant_ready_at:
obj.restaurant_ready_at = None
if obj.cancelled_at <= obj.dispatch_at:
obj.dispatch_at = None
if obj.cancelled_at <= obj.courier_notified_at:
obj.courier_notified_at = None
if obj.cancelled_at <= obj.courier_accepted_at:
obj.courier_accepted_at = None
if obj.cancelled_at <= obj.reached_pickup_at:
obj.reached_pickup_at = None
if obj.cancelled_at <= obj.pickup_at:
obj.pickup_at = None
if obj.cancelled_at <= obj.left_pickup_at:
obj.left_pickup_at = None
if obj.cancelled_at <= obj.reached_delivery_at:
obj.reached_delivery_at = None
if obj.cancelled_at <= obj.delivery_at:
obj.delivery_at = None

View file

@ -1,56 +0,0 @@
"""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),
)

View file

@ -1,27 +0,0 @@
"""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

View file

@ -1,27 +0,0 @@
"""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)

View file

@ -103,41 +103,3 @@ def make_order(make_address, make_courier, make_customer, make_restaurant):
)
return func
@pytest.fixture
def make_replay_order(make_order, simulation):
"""Replaces `ReplayedOrderFactory.build()`: Create a `ReplayedOrder`."""
# Reset the identifiers before every test.
factories.ReplayedOrderFactory.reset_sequence(1)
def func(scheduled=False, order=None, **kwargs):
"""Create a new `ReplayedOrder` object.
Each `ReplayOrder` is made by a new `Customer` with a unique `Address`.
Args:
scheduled: if an `Order` is a pre-order
order: the `Order` that is replayed
kwargs: keyword arguments forwarded to the `ReplayedOrderFactory`
Returns:
order
Raises:
RuntimeError: if `scheduled=True` is passed in
together with an ad-hoc `order`
"""
if scheduled:
if order and order.ad_hoc is False:
raise RuntimeError('`order` must be scheduled')
elif order is None:
order = make_order(scheduled=True)
elif order is None:
order = make_order()
return factories.ReplayedOrderFactory.build(
simulation=simulation, actual=order, courier=order.courier, **kwargs,
)
return func

View file

@ -54,22 +54,10 @@ def restaurant(address, make_restaurant):
@pytest.fixture
def order(make_order, restaurant):
"""An ad-hoc `Order` object for the `restaurant`."""
"""An `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."""
@ -80,20 +68,3 @@ def grid(city):
def pixel(grid):
"""The `Pixel` in the lower-left corner of the `grid`."""
return db.Pixel(id=1, grid=grid, n_x=0, n_y=0)
@pytest.fixture
def simulation_data(city):
"""The data for the one and only `ReplaySimulation` object as a `dict`."""
return {
'id': 1,
'city': city,
'strategy': 'best_possible_routing',
'run': 0,
}
@pytest.fixture
def simulation(simulation_data):
"""The one and only `ReplaySimulation` object."""
return db.ReplaySimulation(**simulation_data)

View file

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

View file

@ -1,585 +0,0 @@
"""Test the ORM's `ReplayedOrder` model."""
import datetime as dt
import pytest
import sqlalchemy as sqla
from sqlalchemy import exc as sa_exc
from tests import config as test_config
from urban_meal_delivery import db
class TestSpecialMethods:
"""Test special methods in `ReplayedOrder`."""
def test_create_order(self, replayed_order):
"""Test instantiation of a new `ReplayedOrder` object."""
assert replayed_order is not None
def test_text_representation(self, replayed_order):
"""`ReplayedOrder` has a non-literal text representation."""
result = repr(replayed_order)
assert result == f'<ReplayedOrder(#{replayed_order.actual.id})>'
@pytest.mark.db
@pytest.mark.no_cover
class TestConstraints:
"""Test the database constraints defined in `ReplayedOrder`."""
def test_insert_into_into_database(self, db_session, replayed_order):
"""Insert an instance into the (empty) database."""
assert db_session.query(db.ReplayedOrder).count() == 0
db_session.add(replayed_order)
db_session.commit()
assert db_session.query(db.ReplayedOrder).count() == 1
def test_delete_a_referenced_simulation(self, db_session, replayed_order):
"""Remove a record that is referenced with a FK."""
db_session.add(replayed_order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.ReplaySimulation).where(
db.ReplaySimulation.id == replayed_order.simulation.id,
)
with pytest.raises(
sa_exc.IntegrityError, match='fk_replayed_orders_to_replay_simulations',
):
db_session.execute(stmt)
def test_delete_a_referenced_actual_order(self, db_session, replayed_order):
"""Remove a record that is referenced with a FK."""
db_session.add(replayed_order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Order).where(db.Order.id == replayed_order.actual.id)
with pytest.raises(sa_exc.IntegrityError, match='fk_replayed_orders_to_orders'):
db_session.execute(stmt)
def test_delete_a_referenced_courier(
self, db_session, replayed_order, make_courier,
):
"""Remove a record that is referenced with a FK."""
# Need a second courier, one that is
# not associated with the `.actual` order.
replayed_order.courier = make_courier()
db_session.add(replayed_order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Courier).where(db.Courier.id == replayed_order.courier.id)
with pytest.raises(
sa_exc.IntegrityError, match='fk_replayed_orders_to_couriers',
):
db_session.execute(stmt)
def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, replayed_order):
"""Insert an instance with invalid data."""
assert replayed_order.ad_hoc is True
replayed_order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0)
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
):
db_session.commit()
def test_scheduled_order_without_scheduled_delivery_at(
self, db_session, make_replay_order,
):
"""Insert an instance with invalid data."""
replay_order = make_replay_order(scheduled=True)
assert replay_order.ad_hoc is False
replay_order.scheduled_delivery_at = None
db_session.add(replay_order)
with pytest.raises(
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
):
db_session.commit()
def test_ad_hoc_order_too_early(self, db_session, make_replay_order):
"""Insert an instance with invalid data."""
replay_order = make_replay_order(
placed_at=dt.datetime(*test_config.DATE, 10, 0),
)
db_session.add(replay_order)
with pytest.raises(
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
):
db_session.commit()
def test_ad_hoc_order_too_late(self, db_session, make_replay_order):
"""Insert an instance with invalid data."""
replay_order = make_replay_order(
placed_at=dt.datetime(*test_config.DATE, 23, 0),
)
db_session.add(replay_order)
with pytest.raises(
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_way_too_early(self, db_session, make_replay_order):
"""Insert an instance with invalid data."""
scheduled_delivery_at = dt.datetime(*test_config.DATE, 10, 0)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=12),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
)
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_a_bit_too_early(self, db_session, make_replay_order):
"""Insert an instance with invalid data."""
scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 30)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=14),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
)
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_not_too_early(self, db_session, make_replay_order):
"""Insert an instance with invalid data.
11.45 is the only time outside noon to 11 pm when a scheduled order is allowed.
"""
scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 45)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=14),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
)
assert db_session.query(db.Order).count() == 0
db_session.add(replay_pre_order)
db_session.commit()
assert db_session.query(db.Order).count() == 1
def test_scheduled_order_too_late(self, db_session, make_replay_order):
"""Insert an instance with invalid data."""
scheduled_delivery_at = dt.datetime(*test_config.DATE, 23, 0)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
)
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
@pytest.mark.parametrize('minute', [min_ for min_ in range(60) if min_ % 15 != 0])
def test_scheduled_order_at_non_quarter_of_an_hour(
self, db_session, make_replay_order, minute,
):
"""Insert an instance with invalid data."""
scheduled_delivery_at = dt.datetime( # `minute` is not 0, 15, 30, or 45
*test_config.DATE, test_config.NOON, minute,
)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
)
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError,
match='scheduled_orders_must_be_at_quart', # constraint name too long
):
db_session.commit()
@pytest.mark.parametrize('second', list(range(1, 60)))
def test_scheduled_order_at_non_quarter_of_an_hour_by_seconds(
self, db_session, make_replay_order, second,
):
"""Insert an instance with invalid data."""
scheduled_delivery_at = dt.datetime(
*test_config.DATE, test_config.NOON, 0, second,
)
replay_pre_order = make_replay_order(
scheduled=True,
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
scheduled_delivery_at=scheduled_delivery_at,
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
)
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError,
match='scheduled_orders_must_be_at_quart', # constraint name too long
):
db_session.commit()
def test_scheduled_order_too_soon(self, db_session, make_replay_order):
"""Insert an instance with invalid data.
Scheduled orders must be at least 30 minutes into the future.
"""
# Create an ad-hoc order first and then make that a scheduled order.
# This way, it is less work to keep the timestamps consistent.
replay_pre_order = make_replay_order(scheduled=False)
# Make the `scheduled_delivery_at` the quarter of an hour
# following the next quarter of an hour (i.e., the timestamp
# is between 15 and 30 minutes into the future).
replay_pre_order.ad_hoc = False
replay_pre_order.actual.ad_hoc = False
minutes_to_next_quarter = 15 - (replay_pre_order.placed_at.minute % 15)
scheduled_delivery_at = (
# `.placed_at` may have non-0 seconds.
replay_pre_order.placed_at.replace(second=0)
+ dt.timedelta(minutes=(minutes_to_next_quarter + 15))
)
replay_pre_order.scheduled_delivery_at = scheduled_delivery_at
replay_pre_order.actual.scheduled_delivery_at = scheduled_delivery_at
replay_pre_order.actual.scheduled_delivery_at_corrected = False
db_session.add(replay_pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes',
):
db_session.commit()
@pytest.mark.parametrize(
'column',
[
'restaurant_notified_at',
'restaurant_confirmed_at',
'restaurant_ready_at',
'dispatch_at',
'courier', # not `.courier_id`
'courier_notified_at',
'courier_accepted_at',
'reached_pickup_at',
'pickup_at',
'left_pickup_at',
'reached_delivery_at',
'delivery_at',
],
)
def test_not_fully_simulated_order(self, db_session, replayed_order, column):
"""Insert an instance with invalid data."""
assert replayed_order.cancelled_at is None
setattr(replayed_order, column, None)
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError,
match='either_cancelled_o', # constraint name too long
):
db_session.commit()
@pytest.mark.parametrize(
'column',
[
'restaurant_notified_at',
'restaurant_confirmed_at',
'restaurant_ready_at',
'dispatch_at',
'courier_id',
'courier_notified_at',
'courier_accepted_at',
'reached_pickup_at',
],
)
def test_simulated_cancellation(self, db_session, replayed_order, column):
"""Insert an instance with invalid data.
Cancelled orders may have missing timestamps
"""
replayed_order.cancelled_at = replayed_order.pickup_at
replayed_order.pickup_at = None
replayed_order.left_pickup_at = None
replayed_order.reached_delivery_at = None
replayed_order.delivery_at = None
setattr(replayed_order, column, None)
db_session.add(replayed_order)
db_session.commit()
@pytest.mark.parametrize(
'column', ['pickup_at', 'left_pickup_at', 'reached_delivery_at', 'delivery_at'],
)
def test_no_simulated_cancellation_after_pickup(
self, db_session, replayed_order, column,
):
"""Insert an instance with invalid data."""
# Setting `.cancelled_at` to the end of a day
# ensures the timestamps are logically ok.
replayed_order.cancelled_at = dt.datetime(*test_config.DATE, 23)
# Set all timestamps after `.reached_pickup_at` to NULL
# except the one under test.
for unset_column in (
'pickup_at',
'left_pickup_at',
'reached_delivery_at',
'delivery_at',
):
if unset_column != column:
setattr(replayed_order, unset_column, None)
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError,
match='cancellations_may_only_occur_befo', # constraint name too long
):
db_session.commit()
@pytest.mark.parametrize('duration', [-1, 3601])
def test_estimated_prep_duration_out_of_range(
self, db_session, replayed_order, duration,
):
"""Insert an instance with invalid data."""
replayed_order.estimated_prep_duration = duration
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError, match='between_0', # constraint name too long
):
db_session.commit()
@pytest.mark.parametrize('duration', [1, 59, 119, 3599])
def test_estimated_prep_duration_not_whole_minute(
self, db_session, replayed_order, duration,
):
"""Insert an instance with invalid data."""
replayed_order.estimated_prep_duration = duration
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError, match='must_be_w', # constraint name too long
):
db_session.commit()
@pytest.mark.parametrize('utilization', [-1, 101])
def test_utilization_out_of_range(self, db_session, replayed_order, utilization):
"""Insert an instance with invalid data."""
replayed_order.utilization = utilization
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError, match='between_0_and_100',
):
db_session.commit()
@pytest.mark.parametrize('column', ['restaurant_notified_at', 'dispatch_at'])
@pytest.mark.parametrize('hour', [0, 10])
def test_order_dispatched_in_non_business_hour(
self, db_session, replayed_order, column, hour,
):
"""Insert an instance with invalid data."""
orig_timestamp = getattr(replayed_order, column)
new_timestamp = orig_timestamp.replace(hour=hour)
replayed_order.placed_at = new_timestamp - dt.timedelta(minutes=1)
setattr(replayed_order, column, new_timestamp)
db_session.add(replayed_order)
with pytest.raises(
sa_exc.IntegrityError, match='business_hours',
):
db_session.commit()
@pytest.mark.parametrize(
'comparison',
[
'placed_at < restaurant_notified_at',
'placed_at < restaurant_confirmed_at',
'placed_at < restaurant_ready_at',
'placed_at < dispatch_at',
'placed_at < first_estimated_delivery_at',
'placed_at < courier_notified_at',
'placed_at < courier_accepted_at',
'placed_at < reached_pickup_at',
'placed_at < pickup_at',
'placed_at < left_pickup_at',
'placed_at < reached_delivery_at',
'placed_at < delivery_at',
'restaurant_notified_at < restaurant_confirmed_at',
'restaurant_notified_at < restaurant_ready_at',
'restaurant_notified_at < pickup_at',
'restaurant_confirmed_at < restaurant_ready_at',
'restaurant_confirmed_at < pickup_at',
'restaurant_ready_at < pickup_at',
'dispatch_at < first_estimated_delivery_at',
'dispatch_at < courier_notified_at',
'dispatch_at < courier_accepted_at',
'dispatch_at < reached_pickup_at',
'dispatch_at < pickup_at',
'dispatch_at < left_pickup_at',
'dispatch_at < reached_delivery_at',
'dispatch_at < delivery_at',
'courier_notified_at < courier_accepted_at',
'courier_notified_at < reached_pickup_at',
'courier_notified_at < pickup_at',
'courier_notified_at < left_pickup_at',
'courier_notified_at < reached_delivery_at',
'courier_notified_at < delivery_at',
'courier_accepted_at < reached_pickup_at',
'courier_accepted_at < pickup_at',
'courier_accepted_at < left_pickup_at',
'courier_accepted_at < reached_delivery_at',
'courier_accepted_at < delivery_at',
'reached_pickup_at < pickup_at',
'reached_pickup_at < left_pickup_at',
'reached_pickup_at < reached_delivery_at',
'reached_pickup_at < delivery_at',
'pickup_at < left_pickup_at',
'pickup_at < reached_delivery_at',
'pickup_at < delivery_at',
'left_pickup_at < reached_delivery_at',
'left_pickup_at < delivery_at',
'reached_delivery_at < delivery_at',
],
)
def test_timestamps_unordered(
self, db_session, replayed_order, comparison,
):
"""Insert an instance with invalid data.
There are two special cases for this test case below,
where other attributes on `replayed_order` must be unset.
"""
smaller, bigger = comparison.split(' < ')
assert smaller is not None
violating_timestamp = getattr(replayed_order, smaller) - dt.timedelta(seconds=1)
setattr(replayed_order, bigger, violating_timestamp)
db_session.add(replayed_order)
with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'):
db_session.commit()
def test_timestamps_unordered_scheduled(self, db_session, make_replay_order):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
replayed_pre_order = make_replay_order(scheduled=True)
# As we subtract 1 second in the generic case,
# choose one second after a quarter of an hour.
replayed_pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1)
self.test_timestamps_unordered(
db_session, replayed_pre_order, 'placed_at < scheduled_delivery_at',
)
@pytest.mark.parametrize(
'comparison',
[
'placed_at < cancelled_at',
'restaurant_notified_at < cancelled_at',
'restaurant_confirmed_at < cancelled_at',
'restaurant_ready_at < cancelled_at',
'dispatch_at < cancelled_at',
'courier_notified_at < cancelled_at',
'courier_accepted_at < cancelled_at',
'reached_pickup_at < cancelled_at',
],
)
def test_timestamps_unordered_cancelled(
self, db_session, replayed_order, comparison,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
replayed_order.pickup_at = None
replayed_order.left_pickup_at = None
replayed_order.reached_delivery_at = None
replayed_order.delivery_at = None
self.test_timestamps_unordered(db_session, replayed_order, comparison)
class TestProperties:
"""Test properties in `ReplayedOrder`.
The `replayed_order` fixture uses the defaults specified in
`factories.ReplayedOrderFactory` and provided by the `make_replay_order` fixture.
"""
def test_has_customer(self, replayed_order):
"""Test `ReplayedOrder.customer` property."""
result = replayed_order.customer
assert result is replayed_order.actual.customer
def test_has_restaurant(self, replayed_order):
"""Test `ReplayedOrder.restaurant` property."""
result = replayed_order.restaurant
assert result is replayed_order.actual.restaurant
def test_has_pickup_address(self, replayed_order):
"""Test `ReplayedOrder.pickup_address` property."""
result = replayed_order.pickup_address
assert result is replayed_order.actual.pickup_address
def test_has_delivery_address(self, replayed_order):
"""Test `ReplayedOrder.delivery_address` property."""
result = replayed_order.delivery_address
assert result is replayed_order.actual.delivery_address

View file

@ -1,17 +0,0 @@
"""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

View file

@ -377,10 +377,7 @@ class TestSyncWithGoogleMaps:
'copyrights': 'Map data ©2021',
'legs': [
{
# 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},
'distance': {'text': '3.0 km', 'value': 2999},
'duration': {'text': '10 mins', 'value': 596},
'end_address': '13 Place Paul et Jean Paul Avisseau, ...',
'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105},
@ -636,7 +633,7 @@ class TestSyncWithGoogleMaps:
path.sync_with_google_maps()
assert path.bicycle_distance == 9_999
assert path.bicycle_distance == 2_999
assert path.bicycle_duration == 596
assert path._directions is not None

View file

@ -1,13 +1,10 @@
"""Test the ORM's `Order` model."""
import datetime as dt
import datetime
import random
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
@ -30,10 +27,8 @@ class TestSpecialMethods:
class TestConstraints:
"""Test the database constraints defined in `Order`."""
def test_insert_ad_hoc_order_into_into_database(self, db_session, order):
def test_insert_into_database(self, db_session, order):
"""Insert an instance into the (empty) database."""
assert order.ad_hoc is True
assert db_session.query(db.Order).count() == 0
db_session.add(order)
@ -41,602 +36,9 @@ class TestConstraints:
assert db_session.query(db.Order).count() == 1
def test_insert_scheduled_order_into_into_database(self, db_session, pre_order):
"""Insert an instance into the (empty) database."""
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)
# TODO (order-constraints): the various Foreign Key and Check Constraints
# should be tested eventually. This is not of highest importance as
# we have a lot of confidence from the data cleaning notebook.
class TestProperties:
@ -735,7 +137,7 @@ class TestProperties:
"""Test `Order.time_to_accept` property."""
result = order.time_to_accept
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_time_to_react_no_courier_notified(self, order):
"""Test `Order.time_to_react` property."""
@ -755,7 +157,7 @@ class TestProperties:
"""Test `Order.time_to_react` property."""
result = order.time_to_react
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_time_to_pickup_no_reached_pickup_at(self, order):
"""Test `Order.time_to_pickup` property."""
@ -775,7 +177,7 @@ class TestProperties:
"""Test `Order.time_to_pickup` property."""
result = order.time_to_pickup
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_time_at_pickup_no_reached_pickup_at(self, order):
"""Test `Order.time_at_pickup` property."""
@ -795,7 +197,7 @@ class TestProperties:
"""Test `Order.time_at_pickup` property."""
result = order.time_at_pickup
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118
"""Test `Order.scheduled_pickup_at` property."""
@ -907,7 +309,7 @@ class TestProperties:
"""Test `Order.time_to_delivery` property."""
result = order.time_to_delivery
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118
"""Test `Order.time_at_delivery` property."""
@ -927,9 +329,9 @@ class TestProperties:
"""Test `Order.time_at_delivery` property."""
result = order.time_at_delivery
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
def test_courier_waited_at_delivery(self, order):
def test_courier_waited_at_delviery(self, order):
"""Test `Order.courier_waited_at_delivery` property."""
order._courier_waited_at_delivery = True
@ -955,7 +357,7 @@ class TestProperties:
"""Test `Order.delivery_early` property."""
order = make_order(scheduled=True)
# Schedule the order to a lot later.
order.scheduled_delivery_at += dt.timedelta(hours=2)
order.scheduled_delivery_at += datetime.timedelta(hours=2)
result = order.delivery_early
@ -965,7 +367,7 @@ class TestProperties:
"""Test `Order.delivery_early` property."""
order = make_order(scheduled=True)
# Schedule the order to a lot earlier.
order.scheduled_delivery_at -= dt.timedelta(hours=2)
order.scheduled_delivery_at -= datetime.timedelta(hours=2)
result = order.delivery_early
@ -981,7 +383,7 @@ class TestProperties:
"""Test `Order.delivery_early` property."""
order = make_order(scheduled=True)
# Schedule the order to a lot earlier.
order.scheduled_delivery_at -= dt.timedelta(hours=2)
order.scheduled_delivery_at -= datetime.timedelta(hours=2)
result = order.delivery_late
@ -991,7 +393,7 @@ class TestProperties:
"""Test `Order.delivery_early` property."""
order = make_order(scheduled=True)
# Schedule the order to a lot later.
order.scheduled_delivery_at += dt.timedelta(hours=2)
order.scheduled_delivery_at += datetime.timedelta(hours=2)
result = order.delivery_late
@ -1015,17 +417,17 @@ class TestProperties:
"""Test `Order.total_time` property."""
result = order.total_time
assert result > dt.timedelta(0)
assert result > datetime.timedelta(0)
@pytest.mark.db
@pytest.mark.no_cover
def test_make_random_orders( # noqa:C901,WPS211,WPS213
def test_make_random_orders( # noqa:C901,WPS211,WPS213,WPS231
db_session, make_address, make_courier, make_restaurant, make_order,
):
"""Sanity check the all the `make_*` fixtures.
Ensure that all generated `Address`, `Courier`, `Customer`, `Restaurant`,
Ensure that all generated `Address`, `Courier`, `Customer`, `Restauarant`,
and `Order` objects adhere to the database constraints.
""" # noqa:D202
# Generate a large number of `Order`s to obtain a large variance of data.

View file

@ -17,7 +17,9 @@ 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.DATE, test_config.NOON, 0)
first_start_at = dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, test_config.NOON, 0,
)
gen = (
start_at

View file

@ -73,7 +73,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)
@ -102,7 +104,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)
@ -133,7 +137,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)
@ -163,15 +169,21 @@ class TestAggregateOrders:
ad_hoc_order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, 11, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 11,
),
)
db_session.add(ad_hoc_order)
pre_order = make_order(
scheduled=True,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, 9, 0),
scheduled_delivery_at=datetime.datetime(*test_config.DATE, 12, 0),
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,
),
)
db_session.add(pre_order)
@ -203,7 +215,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)
@ -238,7 +252,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)
@ -317,7 +333,9 @@ class TestAggregateOrders:
order = make_order(
scheduled=False,
restaurant=restaurant1,
placed_at=datetime.datetime(*test_config.DATE, hour, 11),
placed_at=datetime.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, hour, 11,
),
)
db_session.add(order)