Compare commits

...

11 commits

Author SHA1 Message Date
830f44e3cc
Merge branch 'simulation-data' into develop
Some checks failed
CI / fast (without R) (push) Has been cancelled
CI / slow (with R) (push) Has been cancelled
2021-09-16 13:21:42 +02:00
419c7e6bf6
Add tests for the Order ORM model
- so far, the check constraints were untested
  + the tests mirror the tests for the `ReplayedOrder` model
    with some variations
- shorten some import names
- fix some typos
2021-09-16 12:05:22 +02:00
e4e543bd40
Add ORM models for the simulation data
- add new ORM models `ReplaySimulation` and `ReplayedOrder`
  to store the data generated by the routing simulations
- add migrations script to create the corresponding database tables
  + create "replay_simulations" and "replayed_orders" tables
  + add missing check constraints to "orders" table
  + add unique constraint to "orders" table to enable compound key
  + drop unnecessary check constraints from the "orders" table
- add tests for the new ORM models
  + add `simulation`, `replayed_order`, `make_replay_order()`, and
    `pre_order` fixtures
  + add `ReplayedOrderFactor` faker class
- fix some typos
2021-09-16 11:58:55 +02:00
41f75f507d
Use a unified DATE constant for convenience 2021-09-16 11:53:40 +02:00
f0eb9d3b6f
There is no 'too complex function'
We check a function's cognitive complexity only with `mccabe` (=C901)
and not with WPS231 as the two overlap in most cases.
2021-09-15 14:51:56 +02:00
6091ad95c6
Fix failing tests due to randomization 2021-09-15 12:00:03 +02:00
f891fac3dc
Refactor tests.db.fake_data.factories into a package 2021-09-15 11:58:17 +02:00
7e23033d84
There is no 'too deep nesting' 2021-09-15 11:58:13 +02:00
5978b0e92f
Modules are never 'too complex' 2021-09-15 11:47:38 +02:00
d03c2f91fb
Merge branch 'main' into develop 2021-09-13 11:37:37 +02:00
5eabd541e8
Merge branch 'release-0.4.0' into develop 2021-09-13 11:22:01 +02:00
40 changed files with 2588 additions and 300 deletions

View file

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

View file

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

View file

@ -384,7 +384,7 @@ def test_suite(session):
@nox.session(name='fix-branch-references', python=PYTHON, venv_backend='none') @nox.session(name='fix-branch-references', python=PYTHON, venv_backend='none')
def fix_branch_references(session): # noqa:WPS210,WPS231 def fix_branch_references(session): # noqa:WPS210
"""Replace branch references with the current branch. """Replace branch references with the current branch.
Intended to be run as a pre-commit hook. Intended to be run as a pre-commit hook.
@ -511,7 +511,7 @@ def init_project(session):
@nox.session(name='clean-pwd', python=PYTHON, venv_backend='none') @nox.session(name='clean-pwd', python=PYTHON, venv_backend='none')
def clean_pwd(session): # noqa:WPS231 def clean_pwd(session):
"""Remove (almost) all glob patterns listed in .gitignore. """Remove (almost) all glob patterns listed in .gitignore.
The difference compared to `git clean -X` is that this task The difference compared to `git clean -X` is that this task

View file

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

View file

@ -19,7 +19,7 @@
"- numeric columns are checked for plausibility\n", "- numeric columns are checked for plausibility\n",
"- foreign key relationships are strictly enforced\n", "- foreign key relationships are strictly enforced\n",
"\n", "\n",
"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." "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."
] ]
}, },
{ {

View file

@ -621,7 +621,7 @@
"id": "fb6a035d", "id": "fb6a035d",
"metadata": {}, "metadata": {},
"source": [ "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/main/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/develop/src/urban_meal_delivery/db/addresses_addresses.py) in the code).\n",
"\n", "\n",
"Let's look at two examples addresses, one from a `Restaurant` and one from a `Customer`." "Let's look at two examples addresses, one from a `Restaurant` and one from a `Customer`."
] ]

View file

@ -93,8 +93,15 @@ extend-ignore =
# until after being processed by Sphinx Napoleon. # until after being processed by Sphinx Napoleon.
# Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17 # Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17
RST201,RST203,RST210,RST213,RST301, RST201,RST203,RST210,RST213,RST301,
# The "too deep nesting" violation fires off "too early" and cannot be configured.
WPS220,
# String constant over-use is checked visually by the programmer. # String constant over-use is checked visually by the programmer.
WPS226, WPS226,
# Function complexity is checked by `mccabe` (=C901),
# which yields the same result in most cases.
WPS231,
# Modules as a whole are assumed to be not "too complex".
WPS232,
# Allow underscores in numbers. # Allow underscores in numbers.
WPS303, WPS303,
# f-strings are ok. # f-strings are ok.
@ -104,6 +111,8 @@ extend-ignore =
# Let's be modern: The Walrus is ok. # Let's be modern: The Walrus is ok.
WPS332, WPS332,
# Let's not worry about the number of noqa's. # Let's not worry about the number of noqa's.
# Multi-line loops are ok.
WPS352,
WPS402, WPS402,
# Putting logic into __init__.py files may be justified. # Putting logic into __init__.py files may be justified.
WPS412, WPS412,
@ -141,24 +150,9 @@ per-file-ignores =
src/urban_meal_delivery/configuration.py: src/urban_meal_delivery/configuration.py:
# Allow upper case class variables within classes. # Allow upper case class variables within classes.
WPS115, WPS115,
src/urban_meal_delivery/console/forecasts.py:
# The module is not too complex.
WPS232,
src/urban_meal_delivery/db/addresses_addresses.py: src/urban_meal_delivery/db/addresses_addresses.py:
# The module does not have too many imports. # The module does not have too many imports.
WPS201, WPS201,
src/urban_meal_delivery/db/customers.py:
# The module is not too complex.
WPS232,
src/urban_meal_delivery/db/restaurants.py:
# The module is not too complex.
WPS232,
src/urban_meal_delivery/forecasts/methods/decomposition.py:
# The module is not too complex.
WPS232,
src/urban_meal_delivery/forecasts/methods/extrapolate_season.py:
# The module is not too complex.
WPS232,
src/urban_meal_delivery/forecasts/models/tactical/horizontal.py: src/urban_meal_delivery/forecasts/models/tactical/horizontal.py:
# The many noqa's are ok. # The many noqa's are ok.
WPS403, WPS403,
@ -178,6 +172,8 @@ per-file-ignores =
S311, S311,
# Shadowing outer scopes occurs naturally with mocks. # Shadowing outer scopes occurs naturally with mocks.
WPS442, WPS442,
# `utils` should be a valid module name.
WPS100,
# Test names may be longer than 40 characters. # Test names may be longer than 40 characters.
WPS118, WPS118,
# Modules may have many test cases. # Modules may have many test cases.

View file

@ -24,7 +24,7 @@ from urban_meal_delivery.forecasts import timify
@click.argument('time_step', default=60, type=int) @click.argument('time_step', default=60, type=int)
@click.argument('train_horizon', default=8, type=int) @click.argument('train_horizon', default=8, type=int)
@decorators.db_revision('8bfb928a31f8') @decorators.db_revision('8bfb928a31f8')
def tactical_heuristic( # noqa:C901,WPS213,WPS216,WPS231 def tactical_heuristic( # noqa:C901,WPS213,WPS216
city: str, side_length: int, time_step: int, train_horizon: int, city: str, side_length: int, time_step: int, train_horizon: int,
) -> None: # pragma: no cover ) -> None: # pragma: no cover
"""Predict demand for all pixels and days in a city. """Predict demand for all pixels and days in a city.

View file

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

View file

@ -39,6 +39,7 @@ class City(meta.Base):
# Relationships # Relationships
addresses = orm.relationship('Address', back_populates='city') addresses = orm.relationship('Address', back_populates='city')
grids = orm.relationship('Grid', back_populates='city') grids = orm.relationship('Grid', back_populates='city')
replays = orm.relationship('ReplaySimulation', back_populates='city')
# We do not implement a `.__init__()` method and use SQLAlchemy's default. # We do not implement a `.__init__()` method and use SQLAlchemy's default.
# The uninitialized attribute `._map` is computed on the fly. note:d334120ei # The uninitialized attribute `._map` is computed on the fly. note:d334120ei
@ -117,7 +118,7 @@ class City(meta.Base):
return self._map return self._map
def draw_restaurants( # noqa:WPS231 def draw_restaurants(
self, order_counts: bool = False, # pragma: no cover self, order_counts: bool = False, # pragma: no cover
) -> folium.Map: ) -> folium.Map:
"""Draw all restaurants on the`.map`. """Draw all restaurants on the`.map`.
@ -169,15 +170,15 @@ class City(meta.Base):
) )
# ... and adjust the size of the red dot on the `.map`. # ... and adjust the size of the red dot on the `.map`.
if n_orders >= 1000: if n_orders >= 1000:
radius = 20 # noqa:WPS220 radius = 20
elif n_orders >= 500: elif n_orders >= 500:
radius = 15 # noqa:WPS220 radius = 15
elif n_orders >= 100: elif n_orders >= 100:
radius = 10 # noqa:WPS220 radius = 10
elif n_orders >= 10: elif n_orders >= 10:
radius = 5 # noqa:WPS220 radius = 5
else: else:
radius = 1 # noqa:WPS220 radius = 1
tooltip += f' | n_orders={n_orders}' # noqa:WPS336 tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

@ -42,7 +42,7 @@ class Customer(meta.Base):
"""Shortcut to the `...city.map` object.""" """Shortcut to the `...city.map` object."""
return self.orders[0].pickup_address.city.map # noqa:WPS219 return self.orders[0].pickup_address.city.map # noqa:WPS219
def draw( # noqa:C901,WPS210,WPS231 def draw( # noqa:C901,WPS210
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
) -> folium.Map: ) -> folium.Map:
"""Draw all the customer's delivery addresses on the `...city.map`. """Draw all the customer's delivery addresses on the `...city.map`.
@ -90,15 +90,15 @@ class Customer(meta.Base):
.count() .count()
) )
if n_orders >= 25: if n_orders >= 25:
radius = 20 # noqa:WPS220 radius = 20
elif n_orders >= 10: elif n_orders >= 10:
radius = 15 # noqa:WPS220 radius = 15
elif n_orders >= 5: elif n_orders >= 5:
radius = 10 # noqa:WPS220 radius = 10
elif n_orders > 1: elif n_orders > 1:
radius = 5 # noqa:WPS220 radius = 5
else: else:
radius = 1 # noqa:WPS220 radius = 1
address.draw( address.draw(
radius=radius, radius=radius,
@ -156,15 +156,15 @@ class Customer(meta.Base):
.count() .count()
) )
if n_orders >= 25: if n_orders >= 25:
radius = 20 # noqa:WPS220 radius = 20
elif n_orders >= 10: elif n_orders >= 10:
radius = 15 # noqa:WPS220 radius = 15
elif n_orders >= 5: elif n_orders >= 5:
radius = 10 # noqa:WPS220 radius = 10
elif n_orders > 1: elif n_orders > 1:
radius = 5 # noqa:WPS220 radius = 5
else: else:
radius = 1 # noqa:WPS220 radius = 1
tooltip += f' | n_orders={n_orders}' # noqa:WPS336 tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

@ -105,6 +105,8 @@ class Order(meta.Base): # noqa:WPS214
onupdate='RESTRICT', onupdate='RESTRICT',
ondelete='RESTRICT', ondelete='RESTRICT',
), ),
# Needed by a `ForeignKeyConstraint` in `ReplayedOrder`.
sa.UniqueConstraint('id', 'ad_hoc'),
sa.CheckConstraint( sa.CheckConstraint(
""" """
(ad_hoc IS TRUE AND scheduled_delivery_at IS NULL) (ad_hoc IS TRUE AND scheduled_delivery_at IS NULL)
@ -147,6 +149,20 @@ class Order(meta.Base): # noqa:WPS214
""", """,
name='scheduled_orders_within_business_hours', name='scheduled_orders_within_business_hours',
), ),
sa.CheckConstraint(
"""
(
EXTRACT(MINUTES FROM scheduled_delivery_at)::INTEGER
% 15 = 0
)
AND
(
EXTRACT(SECONDS FROM scheduled_delivery_at)::INTEGER
= 0
)
""",
name='scheduled_orders_must_be_at_quarters_of_an_hour',
),
sa.CheckConstraint( sa.CheckConstraint(
""" """
NOT ( NOT (
@ -256,6 +272,7 @@ class Order(meta.Base): # noqa:WPS214
'placed_at < courier_notified_at', 'placed_at < courier_notified_at',
'placed_at < courier_accepted_at', 'placed_at < courier_accepted_at',
'placed_at < reached_pickup_at', 'placed_at < reached_pickup_at',
'placed_at < pickup_at',
'placed_at < left_pickup_at', 'placed_at < left_pickup_at',
'placed_at < reached_delivery_at', 'placed_at < reached_delivery_at',
'placed_at < delivery_at', 'placed_at < delivery_at',
@ -268,7 +285,6 @@ class Order(meta.Base): # noqa:WPS214
'cancelled_at > pickup_at', 'cancelled_at > pickup_at',
'cancelled_at > left_pickup_at', 'cancelled_at > left_pickup_at',
'cancelled_at > reached_delivery_at', 'cancelled_at > reached_delivery_at',
'cancelled_at > delivery_at',
'restaurant_notified_at < restaurant_confirmed_at', 'restaurant_notified_at < restaurant_confirmed_at',
'restaurant_notified_at < pickup_at', 'restaurant_notified_at < pickup_at',
'restaurant_confirmed_at < pickup_at', 'restaurant_confirmed_at < pickup_at',
@ -323,6 +339,7 @@ class Order(meta.Base): # noqa:WPS214
back_populates='orders_delivered', back_populates='orders_delivered',
foreign_keys='[Order.delivery_address_id]', foreign_keys='[Order.delivery_address_id]',
) )
replays = orm.relationship('ReplayedOrder', back_populates='actual')
# Convenience properties # Convenience properties

View file

@ -136,7 +136,7 @@ class Pixel(meta.Base):
"""Shortcut to the `.city.map` object.""" """Shortcut to the `.city.map` object."""
return self.grid.city.map return self.grid.city.map
def draw( # noqa:C901,WPS210,WPS231 def draw( # noqa:C901,WPS210
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
) -> folium.Map: ) -> folium.Map:
"""Draw the pixel on the `.grid.city.map`. """Draw the pixel on the `.grid.city.map`.
@ -226,15 +226,15 @@ class Pixel(meta.Base):
) )
# ... and adjust the size of the red dot on the `.map`. # ... and adjust the size of the red dot on the `.map`.
if n_orders >= 1000: if n_orders >= 1000:
radius = 20 # noqa:WPS220 radius = 20
elif n_orders >= 500: elif n_orders >= 500:
radius = 15 # noqa:WPS220 radius = 15
elif n_orders >= 100: elif n_orders >= 100:
radius = 10 # noqa:WPS220 radius = 10
elif n_orders >= 10: elif n_orders >= 10:
radius = 5 # noqa:WPS220 radius = 5
else: else:
radius = 1 # noqa:WPS220 radius = 1
tooltip += f' | n_orders={n_orders}' # noqa:WPS336 tooltip += f' | n_orders={n_orders}' # noqa:WPS336

View file

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

View file

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

View file

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

View file

@ -69,7 +69,7 @@ class Restaurant(meta.Base):
"""Shortcut to the `.address.city.map` object.""" """Shortcut to the `.address.city.map` object."""
return self.address.city.map return self.address.city.map
def draw( # noqa:WPS231 def draw(
self, customers: bool = True, order_counts: bool = False, # pragma: no cover self, customers: bool = True, order_counts: bool = False, # pragma: no cover
) -> folium.Map: ) -> folium.Map:
"""Draw the restaurant on the `.address.city.map`. """Draw the restaurant on the `.address.city.map`.
@ -116,15 +116,15 @@ class Restaurant(meta.Base):
.count() .count()
) )
if n_orders >= 25: if n_orders >= 25:
radius = 20 # noqa:WPS220 radius = 20
elif n_orders >= 10: elif n_orders >= 10:
radius = 15 # noqa:WPS220 radius = 15
elif n_orders >= 5: elif n_orders >= 5:
radius = 10 # noqa:WPS220 radius = 10
elif n_orders > 1: elif n_orders > 1:
radius = 5 # noqa:WPS220 radius = 5
else: else:
radius = 1 # noqa:WPS220 radius = 1
address.draw( address.draw(
radius=radius, radius=radius,

View file

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

View file

@ -7,6 +7,8 @@ from urban_meal_delivery import config
# The day on which most test cases take place. # The day on which most test cases take place.
YEAR, MONTH, DAY = 2016, 7, 1 YEAR, MONTH, DAY = 2016, 7, 1
# Same day as a `tuple`.
DATE = (YEAR, MONTH, DAY)
# The hour when most test cases take place. # The hour when most test cases take place.
NOON = 12 NOON = 12

View file

@ -106,6 +106,7 @@ make_address = fake_data.make_address
make_courier = fake_data.make_courier make_courier = fake_data.make_courier
make_customer = fake_data.make_customer make_customer = fake_data.make_customer
make_order = fake_data.make_order make_order = fake_data.make_order
make_replay_order = fake_data.make_replay_order
make_restaurant = fake_data.make_restaurant make_restaurant = fake_data.make_restaurant
address = fake_data.address address = fake_data.address
@ -114,6 +115,10 @@ city_data = fake_data.city_data
courier = fake_data.courier courier = fake_data.courier
customer = fake_data.customer customer = fake_data.customer
order = fake_data.order order = fake_data.order
pre_order = fake_data.pre_order
replayed_order = fake_data.replayed_order
restaurant = fake_data.restaurant restaurant = fake_data.restaurant
grid = fake_data.grid grid = fake_data.grid
pixel = fake_data.pixel pixel = fake_data.pixel
simulation = fake_data.simulation
simulation_data = fake_data.simulation_data

View file

@ -4,6 +4,7 @@ from tests.db.fake_data.fixture_makers import make_address
from tests.db.fake_data.fixture_makers import make_courier from tests.db.fake_data.fixture_makers import make_courier
from tests.db.fake_data.fixture_makers import make_customer from tests.db.fake_data.fixture_makers import make_customer
from tests.db.fake_data.fixture_makers import make_order from tests.db.fake_data.fixture_makers import make_order
from tests.db.fake_data.fixture_makers import make_replay_order
from tests.db.fake_data.fixture_makers import make_restaurant from tests.db.fake_data.fixture_makers import make_restaurant
from tests.db.fake_data.static_fixtures import address from tests.db.fake_data.static_fixtures import address
from tests.db.fake_data.static_fixtures import city from tests.db.fake_data.static_fixtures import city
@ -13,4 +14,8 @@ from tests.db.fake_data.static_fixtures import customer
from tests.db.fake_data.static_fixtures import grid from tests.db.fake_data.static_fixtures import grid
from tests.db.fake_data.static_fixtures import order from tests.db.fake_data.static_fixtures import order
from tests.db.fake_data.static_fixtures import pixel from tests.db.fake_data.static_fixtures import pixel
from tests.db.fake_data.static_fixtures import pre_order
from tests.db.fake_data.static_fixtures import replayed_order
from tests.db.fake_data.static_fixtures import restaurant from tests.db.fake_data.static_fixtures import restaurant
from tests.db.fake_data.static_fixtures import simulation
from tests.db.fake_data.static_fixtures import simulation_data

View file

@ -0,0 +1,9 @@
"""Factories to create instances for the SQLAlchemy models."""
from tests.db.fake_data.factories.addresses import AddressFactory
from tests.db.fake_data.factories.couriers import CourierFactory
from tests.db.fake_data.factories.customers import CustomerFactory
from tests.db.fake_data.factories.orders import AdHocOrderFactory
from tests.db.fake_data.factories.orders import ReplayedOrderFactory
from tests.db.fake_data.factories.orders import ScheduledOrderFactory
from tests.db.fake_data.factories.restaurants import RestaurantFactory

View file

@ -0,0 +1,40 @@
"""Factory to create `Address` instances."""
import random
import string
import factory
from factory import alchemy
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db
class AddressFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Address` model."""
class Meta:
model = db.Address
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(utils.early_in_the_morning)
# When testing, all addresses are considered primary ones.
# As non-primary addresses have no different behavior and
# the property is only kept from the original dataset for
# completeness sake, that is ok to do.
primary_id = factory.LazyAttribute(lambda obj: obj.id)
# Mimic a Google Maps Place ID with just random characters.
place_id = factory.LazyFunction(
lambda: ''.join(random.choice(string.ascii_lowercase) for _ in range(20)),
)
# Place the addresses somewhere in downtown Paris.
latitude = factory.Faker('coordinate', center=48.855, radius=0.01)
longitude = factory.Faker('coordinate', center=2.34, radius=0.03)
# city -> set by the `make_address` fixture as there is only one `city`
city_name = 'Paris'
zip_code = factory.LazyFunction(lambda: random.randint(75001, 75020))
street = factory.Faker('street_address', locale='fr_FR')

View file

@ -0,0 +1,24 @@
"""Factory to create `Courier` instances."""
import factory
from factory import alchemy
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db
class CourierFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Courier` model."""
class Meta:
model = db.Courier
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(utils.early_in_the_morning)
vehicle = 'bicycle'
historic_speed = 7.89
capacity = 100
pay_per_hour = 750
pay_per_order = 200

View file

@ -0,0 +1,16 @@
"""Factory to create `Customers` instances."""
import factory
from factory import alchemy
from urban_meal_delivery import db
class CustomerFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Customer` model."""
class Meta:
model = db.Customer
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125

View file

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

View file

@ -1,114 +1,17 @@
"""Factories to create instances for the SQLAlchemy models.""" """Factory to create ad-hoc `Order` instances."""
import datetime as dt import datetime as dt
import random import random
import string
import factory import factory
import faker
from factory import alchemy from factory import alchemy
from geopy import distance from geopy import distance
from tests import config as test_config from tests import config as test_config
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db from urban_meal_delivery import db
def _random_timespan( # noqa:WPS211
*,
min_hours=0,
min_minutes=0,
min_seconds=0,
max_hours=0,
max_minutes=0,
max_seconds=0,
):
"""A randomized `timedelta` object between the specified arguments."""
total_min_seconds = min_hours * 3600 + min_minutes * 60 + min_seconds
total_max_seconds = max_hours * 3600 + max_minutes * 60 + max_seconds
return dt.timedelta(seconds=random.randint(total_min_seconds, total_max_seconds))
def _early_in_the_morning():
"""A randomized `datetime` object early in the morning."""
early = dt.datetime(test_config.YEAR, test_config.MONTH, test_config.DAY, 3, 0)
return early + _random_timespan(max_hours=2)
class AddressFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Address` model."""
class Meta:
model = db.Address
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(_early_in_the_morning)
# When testing, all addresses are considered primary ones.
# As non-primary addresses have no different behavior and
# the property is only kept from the original dataset for
# completeness sake, that is ok to do.
primary_id = factory.LazyAttribute(lambda obj: obj.id)
# Mimic a Google Maps Place ID with just random characters.
place_id = factory.LazyFunction(
lambda: ''.join(random.choice(string.ascii_lowercase) for _ in range(20)),
)
# Place the addresses somewhere in downtown Paris.
latitude = factory.Faker('coordinate', center=48.855, radius=0.01)
longitude = factory.Faker('coordinate', center=2.34, radius=0.03)
# city -> set by the `make_address` fixture as there is only one `city`
city_name = 'Paris'
zip_code = factory.LazyFunction(lambda: random.randint(75001, 75020))
street = factory.Faker('street_address', locale='fr_FR')
class CourierFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Courier` model."""
class Meta:
model = db.Courier
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(_early_in_the_morning)
vehicle = 'bicycle'
historic_speed = 7.89
capacity = 100
pay_per_hour = 750
pay_per_order = 200
class CustomerFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Customer` model."""
class Meta:
model = db.Customer
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
_restaurant_names = faker.Faker()
class RestaurantFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Restaurant` model."""
class Meta:
model = db.Restaurant
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(_early_in_the_morning)
name = factory.LazyFunction(
lambda: f"{_restaurant_names.first_name()}'s Restaurant",
)
# address -> set by the `make_restaurant` fixture as there is only one `city`
estimated_prep_duration = 1000
class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Order` model. """Create instances of the `db.Order` model.
@ -145,7 +48,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
cancel_=True, cancel_=True,
cancelled_at=factory.LazyAttribute( cancelled_at=factory.LazyAttribute(
lambda obj: obj.dispatch_at lambda obj: obj.dispatch_at
+ _random_timespan( + utils.random_timespan(
max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(), max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(),
), ),
), ),
@ -154,7 +57,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
cancel_=True, cancel_=True,
cancelled_at=factory.LazyAttribute( cancelled_at=factory.LazyAttribute(
lambda obj: obj.pickup_at lambda obj: obj.pickup_at
+ _random_timespan( + utils.random_timespan(
max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(), max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(),
), ),
), ),
@ -167,10 +70,8 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled. # Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
# Ad-hoc `Order`s are placed between 11.45 and 14.15. # Ad-hoc `Order`s are placed between 11.45 and 14.15.
placed_at = factory.LazyFunction( placed_at = factory.LazyFunction(
lambda: dt.datetime( lambda: dt.datetime(*test_config.DATE, 11, 45)
test_config.YEAR, test_config.MONTH, test_config.DAY, 11, 45, + utils.random_timespan(max_hours=2, max_minutes=30),
)
+ _random_timespan(max_hours=2, max_minutes=30),
) )
ad_hoc = True ad_hoc = True
scheduled_delivery_at = None scheduled_delivery_at = None
@ -194,14 +95,19 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
# Restaurant-related attributes # Restaurant-related attributes
# restaurant -> set by the `make_order` fixture for better control # restaurant -> set by the `make_order` fixture for better control
restaurant_notified_at = factory.LazyAttribute( restaurant_notified_at = factory.LazyAttribute(
lambda obj: obj.placed_at + _random_timespan(min_seconds=30, max_seconds=90), lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=30, max_seconds=90),
)
restaurant_notified_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.restaurant_notified_at else None,
) )
restaurant_notified_at_corrected = False
restaurant_confirmed_at = factory.LazyAttribute( restaurant_confirmed_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at lambda obj: obj.restaurant_notified_at
+ _random_timespan(min_seconds=30, max_seconds=150), + utils.random_timespan(min_seconds=30, max_seconds=150),
)
restaurant_confirmed_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.restaurant_confirmed_at else None,
) )
restaurant_confirmed_at_corrected = False
# Use the database defaults of the historic data. # Use the database defaults of the historic data.
estimated_prep_duration = 900 estimated_prep_duration = 900
estimated_prep_duration_corrected = False estimated_prep_duration_corrected = False
@ -210,19 +116,26 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
# Dispatch-related columns # Dispatch-related columns
# courier -> set by the `make_order` fixture for better control # courier -> set by the `make_order` fixture for better control
dispatch_at = factory.LazyAttribute( dispatch_at = factory.LazyAttribute(
lambda obj: obj.placed_at + _random_timespan(min_seconds=600, max_seconds=1080), lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=600, max_seconds=1080),
)
dispatch_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.dispatch_at else None,
) )
dispatch_at_corrected = False
courier_notified_at = factory.LazyAttribute( courier_notified_at = factory.LazyAttribute(
lambda obj: obj.dispatch_at lambda obj: obj.dispatch_at
+ _random_timespan(min_seconds=100, max_seconds=140), + utils.random_timespan(min_seconds=100, max_seconds=140),
)
courier_notified_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.courier_notified_at else None,
) )
courier_notified_at_corrected = False
courier_accepted_at = factory.LazyAttribute( courier_accepted_at = factory.LazyAttribute(
lambda obj: obj.courier_notified_at lambda obj: obj.courier_notified_at
+ _random_timespan(min_seconds=15, max_seconds=45), + utils.random_timespan(min_seconds=15, max_seconds=45),
)
courier_accepted_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.courier_accepted_at else None,
) )
courier_accepted_at_corrected = False
# Sample a realistic utilization. # Sample a realistic utilization.
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100])) utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
@ -230,30 +143,37 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
# pickup_address -> aligned with `restaurant.address` by the `make_order` fixture # pickup_address -> aligned with `restaurant.address` by the `make_order` fixture
reached_pickup_at = factory.LazyAttribute( reached_pickup_at = factory.LazyAttribute(
lambda obj: obj.courier_accepted_at lambda obj: obj.courier_accepted_at
+ _random_timespan(min_seconds=300, max_seconds=600), + utils.random_timespan(min_seconds=300, max_seconds=600),
) )
pickup_at = factory.LazyAttribute( pickup_at = factory.LazyAttribute(
lambda obj: obj.reached_pickup_at lambda obj: obj.reached_pickup_at
+ _random_timespan(min_seconds=120, max_seconds=600), + utils.random_timespan(min_seconds=120, max_seconds=600),
)
pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.pickup_at else None,
) )
pickup_at_corrected = False
pickup_not_confirmed = False pickup_not_confirmed = False
left_pickup_at = factory.LazyAttribute( left_pickup_at = factory.LazyAttribute(
lambda obj: obj.pickup_at + _random_timespan(min_seconds=60, max_seconds=180), lambda obj: obj.pickup_at
+ utils.random_timespan(min_seconds=60, max_seconds=180),
)
left_pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.left_pickup_at else None,
) )
left_pickup_at_corrected = False
# Delivery-related attributes # Delivery-related attributes
# delivery_address -> set by the `make_order` fixture as there is only one `city` # delivery_address -> set by the `make_order` fixture as there is only one `city`
reached_delivery_at = factory.LazyAttribute( reached_delivery_at = factory.LazyAttribute(
lambda obj: obj.left_pickup_at lambda obj: obj.left_pickup_at
+ _random_timespan(min_seconds=240, max_seconds=480), + utils.random_timespan(min_seconds=240, max_seconds=480),
) )
delivery_at = factory.LazyAttribute( delivery_at = factory.LazyAttribute(
lambda obj: obj.reached_delivery_at lambda obj: obj.reached_delivery_at
+ _random_timespan(min_seconds=240, max_seconds=660), + utils.random_timespan(min_seconds=240, max_seconds=660),
)
delivery_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None,
) )
delivery_at_corrected = False
delivery_not_confirmed = False delivery_not_confirmed = False
_courier_waited_at_delivery = factory.LazyAttribute( _courier_waited_at_delivery = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None, lambda obj: False if obj.delivery_at else None,
@ -280,9 +200,7 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
) )
@factory.post_generation @factory.post_generation
def post( # noqa:C901,WPS231 def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
obj, create, extracted, **kwargs, # noqa:B902,N805
):
"""Discard timestamps that occur after cancellation.""" """Discard timestamps that occur after cancellation."""
if obj.cancelled: if obj.cancelled:
if obj.cancelled_at <= obj.restaurant_notified_at: if obj.cancelled_at <= obj.restaurant_notified_at:
@ -316,63 +234,3 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
obj.delivery_at_corrected = None obj.delivery_at_corrected = None
obj.delivery_not_confirmed = None obj.delivery_not_confirmed = None
obj._courier_waited_at_delivery = None obj._courier_waited_at_delivery = None
class ScheduledOrderFactory(AdHocOrderFactory):
"""Create instances of the `db.Order` model.
This class takes care of the various timestamps for pre-orders.
Pre-orders are placed long before the test day's lunch time starts.
All timestamps are relative to either `.dispatch_at` or `.restaurant_notified_at`
and calculated backwards from `.scheduled_delivery_at`.
"""
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
placed_at = factory.LazyFunction(_early_in_the_morning)
ad_hoc = False
# Discrete `datetime` objects in the "core" lunch time are enough.
scheduled_delivery_at = factory.LazyFunction(
lambda: random.choice(
[
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 0,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 15,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 30,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 12, 45,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 0,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 15,
),
dt.datetime(
test_config.YEAR, test_config.MONTH, test_config.DAY, 13, 30,
),
],
),
)
scheduled_delivery_at_corrected = False
# Assume the `Order` is on time.
first_estimated_delivery_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at,
)
# Restaurant-related attributes
restaurant_notified_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at
- _random_timespan(min_minutes=45, max_minutes=50),
)
# Dispatch-related attributes
dispatch_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at
- _random_timespan(min_minutes=40, max_minutes=45),
)

View file

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

View file

@ -0,0 +1,56 @@
"""Factory to create scheduled `Order` instances."""
import datetime as dt
import random
import factory
from tests import config as test_config
from tests.db.fake_data.factories import utils
from tests.db.fake_data.factories.orders import ad_hoc
class ScheduledOrderFactory(ad_hoc.AdHocOrderFactory):
"""Create instances of the `db.Order` model.
This class takes care of the various timestamps for pre-orders.
Pre-orders are placed long before the test day's lunch time starts.
All timestamps are relative to either `.dispatch_at` or `.restaurant_notified_at`
and calculated backwards from `.scheduled_delivery_at`.
"""
# Attributes regarding the specialization of an `Order`: ad-hoc or scheduled.
placed_at = factory.LazyFunction(utils.early_in_the_morning)
ad_hoc = False
# Discrete `datetime` objects in the "core" lunch time are enough.
scheduled_delivery_at = factory.LazyFunction(
lambda: random.choice(
[
dt.datetime(*test_config.DATE, 12, 0),
dt.datetime(*test_config.DATE, 12, 15),
dt.datetime(*test_config.DATE, 12, 30),
dt.datetime(*test_config.DATE, 12, 45),
dt.datetime(*test_config.DATE, 13, 0),
dt.datetime(*test_config.DATE, 13, 15),
dt.datetime(*test_config.DATE, 13, 30),
],
),
)
scheduled_delivery_at_corrected = False
# Assume the `Order` is on time.
first_estimated_delivery_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at,
)
# Restaurant-related attributes
restaurant_notified_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at
- utils.random_timespan(min_minutes=45, max_minutes=50),
)
# Dispatch-related attributes
dispatch_at = factory.LazyAttribute(
lambda obj: obj.scheduled_delivery_at
- utils.random_timespan(min_minutes=40, max_minutes=45),
)

View file

@ -0,0 +1,27 @@
"""Factory to create `Restaurant` instances."""
import factory
import faker
from factory import alchemy
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db
_restaurant_names = faker.Faker()
class RestaurantFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.Restaurant` model."""
class Meta:
model = db.Restaurant
sqlalchemy_get_or_create = ('id',)
id = factory.Sequence(lambda num: num) # noqa:WPS125
created_at = factory.LazyFunction(utils.early_in_the_morning)
name = factory.LazyFunction(
lambda: f"{_restaurant_names.first_name()}'s Restaurant",
)
# address -> set by the `make_restaurant` fixture as there is only one `city`
estimated_prep_duration = 1000

View file

@ -0,0 +1,27 @@
"""Utilities used in all `*Factory` classes."""
import datetime as dt
import random
from tests import config as test_config
def random_timespan( # noqa:WPS211
*,
min_hours=0,
min_minutes=0,
min_seconds=0,
max_hours=0,
max_minutes=0,
max_seconds=0,
):
"""A randomized `timedelta` object between the specified arguments."""
total_min_seconds = min_hours * 3600 + min_minutes * 60 + min_seconds
total_max_seconds = max_hours * 3600 + max_minutes * 60 + max_seconds
return dt.timedelta(seconds=random.randint(total_min_seconds, total_max_seconds))
def early_in_the_morning():
"""A randomized `datetime` object early in the morning."""
early = dt.datetime(*test_config.DATE, 3, 0)
return early + random_timespan(max_hours=2)

View file

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

View file

@ -54,10 +54,22 @@ def restaurant(address, make_restaurant):
@pytest.fixture @pytest.fixture
def order(make_order, restaurant): def order(make_order, restaurant):
"""An `Order` object for the `restaurant`.""" """An ad-hoc `Order` object for the `restaurant`."""
return make_order(restaurant=restaurant) return make_order(restaurant=restaurant)
@pytest.fixture
def pre_order(make_order, restaurant):
"""A scheduled `Order` object for the `restaurant`."""
return make_order(restaurant=restaurant, scheduled=True)
@pytest.fixture
def replayed_order(make_replay_order, order):
"""A `ReplayedOrder` object for the `restaurant`."""
return make_replay_order(order=order)
@pytest.fixture @pytest.fixture
def grid(city): def grid(city):
"""A `Grid` with a pixel area of 1 square kilometer.""" """A `Grid` with a pixel area of 1 square kilometer."""
@ -68,3 +80,20 @@ def grid(city):
def pixel(grid): def pixel(grid):
"""The `Pixel` in the lower-left corner of the `grid`.""" """The `Pixel` in the lower-left corner of the `grid`."""
return db.Pixel(id=1, grid=grid, n_x=0, n_y=0) return db.Pixel(id=1, grid=grid, n_x=0, n_y=0)
@pytest.fixture
def simulation_data(city):
"""The data for the one and only `ReplaySimulation` object as a `dict`."""
return {
'id': 1,
'city': city,
'strategy': 'best_possible_routing',
'run': 0,
}
@pytest.fixture
def simulation(simulation_data):
"""The one and only `ReplaySimulation` object."""
return db.ReplaySimulation(**simulation_data)

View file

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

View file

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

View file

@ -0,0 +1,17 @@
"""Test the ORM's `ReplaySimulation` model."""
class TestSpecialMethods:
"""Test special methods in `ReplaySimulation`."""
def test_create_simulation(self, simulation):
"""Test instantiation of a new `ReplaySimulation` object."""
assert simulation is not None
def test_text_representation(self, simulation):
"""`ReplaySimulation` has a non-literal text representation."""
result = repr(simulation)
assert result.startswith('<ReplaySimulation(')
assert simulation.city.name in result
assert simulation.strategy in result

View file

@ -377,7 +377,10 @@ class TestSyncWithGoogleMaps:
'copyrights': 'Map data ©2021', 'copyrights': 'Map data ©2021',
'legs': [ 'legs': [
{ {
'distance': {'text': '3.0 km', 'value': 2999}, # We choose an artificially high distance of `9999`
# so that the synced bicycle distance is longer than
# the randomized air distance (i.e., fake data) for sure.
'distance': {'text': '3.0 km', 'value': 9999},
'duration': {'text': '10 mins', 'value': 596}, 'duration': {'text': '10 mins', 'value': 596},
'end_address': '13 Place Paul et Jean Paul Avisseau, ...', 'end_address': '13 Place Paul et Jean Paul Avisseau, ...',
'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105}, 'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105},
@ -633,7 +636,7 @@ class TestSyncWithGoogleMaps:
path.sync_with_google_maps() path.sync_with_google_maps()
assert path.bicycle_distance == 2_999 assert path.bicycle_distance == 9_999
assert path.bicycle_duration == 596 assert path.bicycle_duration == 596
assert path._directions is not None assert path._directions is not None

View file

@ -1,10 +1,13 @@
"""Test the ORM's `Order` model.""" """Test the ORM's `Order` model."""
import datetime import datetime as dt
import random import random
import pytest import pytest
import sqlalchemy as sqla
from sqlalchemy import exc as sa_exc
from tests import config as test_config
from urban_meal_delivery import db from urban_meal_delivery import db
@ -27,8 +30,10 @@ class TestSpecialMethods:
class TestConstraints: class TestConstraints:
"""Test the database constraints defined in `Order`.""" """Test the database constraints defined in `Order`."""
def test_insert_into_database(self, db_session, order): def test_insert_ad_hoc_order_into_into_database(self, db_session, order):
"""Insert an instance into the (empty) database.""" """Insert an instance into the (empty) database."""
assert order.ad_hoc is True
assert db_session.query(db.Order).count() == 0 assert db_session.query(db.Order).count() == 0
db_session.add(order) db_session.add(order)
@ -36,9 +41,602 @@ class TestConstraints:
assert db_session.query(db.Order).count() == 1 assert db_session.query(db.Order).count() == 1
# TODO (order-constraints): the various Foreign Key and Check Constraints def test_insert_scheduled_order_into_into_database(self, db_session, pre_order):
# should be tested eventually. This is not of highest importance as """Insert an instance into the (empty) database."""
# we have a lot of confidence from the data cleaning notebook. assert pre_order.ad_hoc is False
assert db_session.query(db.Order).count() == 0
db_session.add(pre_order)
db_session.commit()
assert db_session.query(db.Order).count() == 1
def test_delete_a_referenced_customer(self, db_session, order):
"""Remove a record that is referenced with a FK."""
db_session.add(order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Customer).where(db.Customer.id == order.customer.id)
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_customers'):
db_session.execute(stmt)
def test_delete_a_referenced_courier(self, db_session, order):
"""Remove a record that is referenced with a FK."""
db_session.add(order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Courier).where(db.Courier.id == order.courier.id)
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_couriers'):
db_session.execute(stmt)
def test_delete_a_referenced_restaurant(self, db_session, order):
"""Remove a record that is referenced with a FK."""
db_session.add(order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Restaurant).where(db.Restaurant.id == order.restaurant.id)
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_restaurants'):
db_session.execute(stmt)
def test_change_a_referenced_pickup_address(self, db_session, order, make_address):
"""Remove a record that is referenced with a FK.
Each `Restaurant` may only have one `Address` in the dataset.
"""
db_session.add(order)
db_session.commit()
# Give the `restaurant` another `address`.
order.restaurant.address = make_address()
db_session.add(order.restaurant.address)
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_restaurants'):
db_session.commit()
# Here should be a test to check deletion of a referenced pickup address, so
# `test_delete_a_referenced_pickup_address(self, db_session, order)`.
# The corresponding "fk_orders_to_addresses_on_pickup_address_id" constraint
# is very hard to test in isolation as the above "fk_orders_to_restaurants_..."
# constraint ensures its integrity already.
def test_delete_a_referenced_delivery_address(self, db_session, order):
"""Remove a record that is referenced with a FK."""
db_session.add(order)
db_session.commit()
# Must delete without ORM as otherwise an UPDATE statement is emitted.
stmt = sqla.delete(db.Address).where(db.Address.id == order.delivery_address.id)
with pytest.raises(sa_exc.IntegrityError, match='fk_orders_to_addresses'):
db_session.execute(stmt)
def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, order):
"""Insert an instance with invalid data."""
assert order.ad_hoc is True
order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0)
order.scheduled_delivery_at_corrected = False
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
):
db_session.commit()
def test_scheduled_order_without_scheduled_delivery_at(self, db_session, pre_order):
"""Insert an instance with invalid data."""
assert pre_order.ad_hoc is False
pre_order.scheduled_delivery_at = None
pre_order.scheduled_delivery_at_corrected = None
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
):
db_session.commit()
def test_ad_hoc_order_too_early(self, db_session, make_order):
"""Insert an instance with invalid data."""
order = make_order(placed_at=dt.datetime(*test_config.DATE, 10, 0))
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
):
db_session.commit()
def test_ad_hoc_order_too_late(self, db_session, make_order):
"""Insert an instance with invalid data."""
order = make_order(placed_at=dt.datetime(*test_config.DATE, 23, 0))
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_way_too_early(self, db_session, make_order):
"""Insert an instance with invalid data."""
pre_order = make_order(
scheduled=True, scheduled_delivery_at=dt.datetime(*test_config.DATE, 10, 0),
)
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_a_bit_too_early(self, db_session, make_order):
"""Insert an instance with invalid data."""
pre_order = make_order(
scheduled=True,
scheduled_delivery_at=dt.datetime(*test_config.DATE, 11, 30),
)
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
def test_scheduled_order_not_too_early(self, db_session, make_order):
"""Insert an instance with invalid data.
11.45 is the only time outside noon to 11 pm when a scheduled order is allowed.
"""
pre_order = make_order(
scheduled=True,
scheduled_delivery_at=dt.datetime(*test_config.DATE, 11, 45),
)
assert db_session.query(db.Order).count() == 0
db_session.add(pre_order)
db_session.commit()
assert db_session.query(db.Order).count() == 1
def test_scheduled_order_too_late(self, db_session, make_order):
"""Insert an instance with invalid data."""
pre_order = make_order(
scheduled=True, scheduled_delivery_at=dt.datetime(*test_config.DATE, 23, 0),
)
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
):
db_session.commit()
@pytest.mark.parametrize('minute', [min_ for min_ in range(60) if min_ % 15 != 0])
def test_scheduled_order_at_non_quarter_of_an_hour(
self, db_session, make_order, minute,
):
"""Insert an instance with invalid data."""
pre_order = make_order(
scheduled=True,
scheduled_delivery_at=dt.datetime( # `minute` is not 0, 15, 30, or 45
*test_config.DATE, test_config.NOON, minute,
),
)
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError,
match='scheduled_orders_must_be_at_quarters_of_an_hour',
):
db_session.commit()
@pytest.mark.parametrize('second', list(range(1, 60)))
def test_scheduled_order_at_non_quarter_of_an_hour_by_seconds(
self, db_session, make_order, second,
):
"""Insert an instance with invalid data."""
pre_order = make_order(
scheduled=True,
scheduled_delivery_at=dt.datetime(
*test_config.DATE, test_config.NOON, 0, second,
),
)
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError,
match='scheduled_orders_must_be_at_quarters_of_an_hour',
):
db_session.commit()
def test_scheduled_order_too_soon(self, db_session, make_order):
"""Insert an instance with invalid data.
Scheduled orders must be at least 30 minutes into the future.
"""
# Create an ad-hoc order first and then make that a scheduled order.
# This way, it is less work to keep the timestamps consistent.
pre_order = make_order(scheduled=False)
# Make the `scheduled_delivery_at` the quarter of an hour
# following the next quarter of an hour (i.e., the timestamp
# is between 15 and 30 minutes into the future).
pre_order.ad_hoc = False
minutes_to_next_quarter = 15 - (pre_order.placed_at.minute % 15)
pre_order.scheduled_delivery_at = (
# `.placed_at` may have non-0 seconds.
pre_order.placed_at.replace(second=0)
+ dt.timedelta(minutes=(minutes_to_next_quarter + 15))
)
pre_order.scheduled_delivery_at_corrected = False
db_session.add(pre_order)
with pytest.raises(
sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes',
):
db_session.commit()
def test_uncancelled_order_has_cancelled_at(self, db_session, order):
"""Insert an instance with invalid data."""
order.cancelled_at = order.delivery_at
order.cancelled_at_corrected = False
order.delivery_at = None
order.delivery_at_corrected = None
order.delivery_not_confirmed = None
order._courier_waited_at_delivery = None
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='only_cancelled_orders_may_have_cancelled_at',
):
db_session.commit()
def test_cancelled_order_is_delivered(self, db_session, order):
"""Insert an instance with invalid data."""
order.cancelled = True
order.cancelled_at = order.delivery_at + dt.timedelta(seconds=1)
order.cancelled_at_corrected = False
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='cancelled_orders_must_not_be_delivered',
):
db_session.commit()
@pytest.mark.parametrize('duration', [-1, 2701])
def test_estimated_prep_duration_out_of_range(self, db_session, order, duration):
"""Insert an instance with invalid data."""
order.estimated_prep_duration = duration
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='between_0_and_2700',
):
db_session.commit()
@pytest.mark.parametrize('duration', [1, 59, 119, 2699])
def test_estimated_prep_duration_not_whole_minute(
self, db_session, order, duration,
):
"""Insert an instance with invalid data."""
order.estimated_prep_duration = duration
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='must_be_whole_minutes',
):
db_session.commit()
@pytest.mark.parametrize('duration', [-1, 901])
def test_estimated_prep_buffer_out_of_range(self, db_session, order, duration):
"""Insert an instance with invalid data."""
order.estimated_prep_buffer = duration
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='between_0_and_900',
):
db_session.commit()
@pytest.mark.parametrize('duration', [1, 59, 119, 899])
def test_estimated_prep_buffer_not_whole_minute(self, db_session, order, duration):
"""Insert an instance with invalid data."""
order.estimated_prep_buffer = duration
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='must_be_whole_minutes',
):
db_session.commit()
@pytest.mark.parametrize('utilization', [-1, 101])
def test_utilization_out_of_range(self, db_session, order, utilization):
"""Insert an instance with invalid data."""
order.utilization = utilization
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='between_0_and_100',
):
db_session.commit()
@pytest.mark.parametrize(
'column',
[
'scheduled_delivery_at',
'cancelled_at',
'restaurant_notified_at',
'restaurant_confirmed_at',
'estimated_prep_duration',
'dispatch_at',
'courier_notified_at',
'courier_accepted_at',
'left_pickup_at',
],
)
def test_unset_timestamp_column_not_marked_as_uncorrected( # noqa:WPS213
self, db_session, order, column,
):
"""Insert an instance with invalid data.
There are two special cases for this test case below,
where other attributes on `order` must be unset.
"""
# Set the actual timestamp to NULL.
setattr(order, column, None)
# Setting both the timestamp and its correction column to NULL is allowed.
setattr(order, f'{column}_corrected', None)
db_session.add(order)
db_session.commit()
# Also, an unset timestamp column may always be marked as corrected.
setattr(order, f'{column}_corrected', True)
db_session.add(order)
db_session.commit()
# Without a timestamp set, a column may not be marked as uncorrected.
setattr(order, f'{column}_corrected', False)
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='corrections_only_for_set_value',
):
db_session.commit()
def test_unset_timestamp_column_not_marked_as_uncorrected_special_case1(
self, db_session, order,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
order.pickup_not_confirmed = None
self.test_unset_timestamp_column_not_marked_as_uncorrected(
db_session, order, 'pickup_at',
)
def test_unset_timestamp_column_not_marked_as_uncorrected_special_case2(
self, db_session, order,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
order.delivery_not_confirmed = None
order._courier_waited_at_delivery = None
self.test_unset_timestamp_column_not_marked_as_uncorrected(
db_session, order, 'delivery_at',
)
@pytest.mark.parametrize(
'column',
[
'restaurant_notified_at',
'restaurant_confirmed_at',
'estimated_prep_duration',
'dispatch_at',
'courier_notified_at',
'courier_accepted_at',
'pickup_at',
'left_pickup_at',
'delivery_at',
],
)
def test_set_timestamp_column_is_not_unmarked( # noqa:WPS213
self, db_session, order, column,
):
"""Insert an instance with invalid data.
There are two special cases for this test case below,
where other attributes on `order` must be unset.
"""
# Ensure the timestamp is set.
assert getattr(order, column) is not None
# A set timestamp may be marked as either corrected or uncorrected.
setattr(order, f'{column}_corrected', True)
db_session.add(order)
db_session.commit()
setattr(order, f'{column}_corrected', False)
db_session.add(order)
db_session.commit()
# A set timestamp may not be left unmarked.
setattr(order, f'{column}_corrected', None)
db_session.add(order)
with pytest.raises(
sa_exc.IntegrityError, match='corrections_only_for_set_value',
):
db_session.commit()
def test_set_timestamp_column_is_not_unmarked_special_case1(
self, db_session, pre_order,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
self.test_set_timestamp_column_is_not_unmarked(
db_session, pre_order, 'scheduled_delivery_at',
)
def test_set_timestamp_column_is_not_unmarked_special_case2(
self, db_session, order,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
order.cancelled = True
order.cancelled_at = order.delivery_at
order.delivery_at = None
order.delivery_at_corrected = None
order.delivery_not_confirmed = None
order._courier_waited_at_delivery = None
self.test_set_timestamp_column_is_not_unmarked(
db_session, order, 'cancelled_at',
)
@pytest.mark.parametrize(
'comparison',
[
'placed_at < first_estimated_delivery_at',
'placed_at < restaurant_notified_at',
'placed_at < restaurant_confirmed_at',
'placed_at < dispatch_at',
'placed_at < courier_notified_at',
'placed_at < courier_accepted_at',
'placed_at < reached_pickup_at',
'placed_at < pickup_at',
'placed_at < left_pickup_at',
'placed_at < reached_delivery_at',
'placed_at < delivery_at',
'restaurant_notified_at < restaurant_confirmed_at',
'restaurant_notified_at < pickup_at',
'restaurant_confirmed_at < pickup_at',
'dispatch_at < courier_notified_at',
'dispatch_at < courier_accepted_at',
'dispatch_at < reached_pickup_at',
'dispatch_at < pickup_at',
'dispatch_at < left_pickup_at',
'dispatch_at < reached_delivery_at',
'dispatch_at < delivery_at',
'courier_notified_at < courier_accepted_at',
'courier_notified_at < reached_pickup_at',
'courier_notified_at < pickup_at',
'courier_notified_at < left_pickup_at',
'courier_notified_at < reached_delivery_at',
'courier_notified_at < delivery_at',
'courier_accepted_at < reached_pickup_at',
'courier_accepted_at < pickup_at',
'courier_accepted_at < left_pickup_at',
'courier_accepted_at < reached_delivery_at',
'courier_accepted_at < delivery_at',
'reached_pickup_at < pickup_at',
'reached_pickup_at < left_pickup_at',
'reached_pickup_at < reached_delivery_at',
'reached_pickup_at < delivery_at',
'pickup_at < left_pickup_at',
'pickup_at < reached_delivery_at',
'pickup_at < delivery_at',
'left_pickup_at < reached_delivery_at',
'left_pickup_at < delivery_at',
'reached_delivery_at < delivery_at',
],
)
def test_timestamps_unordered(
self, db_session, order, comparison,
):
"""Insert an instance with invalid data.
There are two special cases for this test case below,
where other attributes on `order` must be unset.
"""
smaller, bigger = comparison.split(' < ')
assert smaller is not None
violating_timestamp = getattr(order, smaller) - dt.timedelta(seconds=1)
setattr(order, bigger, violating_timestamp)
setattr(order, f'{bigger}_corrected', False)
db_session.add(order)
with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'):
db_session.commit()
def test_timestamps_unordered_scheduled(self, db_session, pre_order):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
# As we subtract 1 second in the generic case,
# choose one second after a quarter of an hour.
pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1)
self.test_timestamps_unordered(
db_session, pre_order, 'placed_at < scheduled_delivery_at',
)
@pytest.mark.parametrize(
'comparison',
[
'placed_at < cancelled_at',
'restaurant_notified_at < cancelled_at',
'restaurant_confirmed_at < cancelled_at',
'dispatch_at < cancelled_at',
'courier_notified_at < cancelled_at',
'courier_accepted_at < cancelled_at',
'reached_pickup_at < cancelled_at',
'pickup_at < cancelled_at',
'left_pickup_at < cancelled_at',
'reached_delivery_at < cancelled_at',
],
)
def test_timestamps_unordered_cancelled(
self, db_session, order, comparison,
):
"""Insert an instance with invalid data.
This is one of two special cases. See the generic case above.
"""
order.cancelled = True
order.delivery_at = None
order.delivery_at_corrected = None
order.delivery_not_confirmed = None
order._courier_waited_at_delivery = None
self.test_timestamps_unordered(db_session, order, comparison)
class TestProperties: class TestProperties:
@ -137,7 +735,7 @@ class TestProperties:
"""Test `Order.time_to_accept` property.""" """Test `Order.time_to_accept` property."""
result = order.time_to_accept result = order.time_to_accept
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_time_to_react_no_courier_notified(self, order): def test_time_to_react_no_courier_notified(self, order):
"""Test `Order.time_to_react` property.""" """Test `Order.time_to_react` property."""
@ -157,7 +755,7 @@ class TestProperties:
"""Test `Order.time_to_react` property.""" """Test `Order.time_to_react` property."""
result = order.time_to_react result = order.time_to_react
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_time_to_pickup_no_reached_pickup_at(self, order): def test_time_to_pickup_no_reached_pickup_at(self, order):
"""Test `Order.time_to_pickup` property.""" """Test `Order.time_to_pickup` property."""
@ -177,7 +775,7 @@ class TestProperties:
"""Test `Order.time_to_pickup` property.""" """Test `Order.time_to_pickup` property."""
result = order.time_to_pickup result = order.time_to_pickup
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_time_at_pickup_no_reached_pickup_at(self, order): def test_time_at_pickup_no_reached_pickup_at(self, order):
"""Test `Order.time_at_pickup` property.""" """Test `Order.time_at_pickup` property."""
@ -197,7 +795,7 @@ class TestProperties:
"""Test `Order.time_at_pickup` property.""" """Test `Order.time_at_pickup` property."""
result = order.time_at_pickup result = order.time_at_pickup
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118 def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118
"""Test `Order.scheduled_pickup_at` property.""" """Test `Order.scheduled_pickup_at` property."""
@ -309,7 +907,7 @@ class TestProperties:
"""Test `Order.time_to_delivery` property.""" """Test `Order.time_to_delivery` property."""
result = order.time_to_delivery result = order.time_to_delivery
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118 def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118
"""Test `Order.time_at_delivery` property.""" """Test `Order.time_at_delivery` property."""
@ -329,9 +927,9 @@ class TestProperties:
"""Test `Order.time_at_delivery` property.""" """Test `Order.time_at_delivery` property."""
result = order.time_at_delivery result = order.time_at_delivery
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
def test_courier_waited_at_delviery(self, order): def test_courier_waited_at_delivery(self, order):
"""Test `Order.courier_waited_at_delivery` property.""" """Test `Order.courier_waited_at_delivery` property."""
order._courier_waited_at_delivery = True order._courier_waited_at_delivery = True
@ -357,7 +955,7 @@ class TestProperties:
"""Test `Order.delivery_early` property.""" """Test `Order.delivery_early` property."""
order = make_order(scheduled=True) order = make_order(scheduled=True)
# Schedule the order to a lot later. # Schedule the order to a lot later.
order.scheduled_delivery_at += datetime.timedelta(hours=2) order.scheduled_delivery_at += dt.timedelta(hours=2)
result = order.delivery_early result = order.delivery_early
@ -367,7 +965,7 @@ class TestProperties:
"""Test `Order.delivery_early` property.""" """Test `Order.delivery_early` property."""
order = make_order(scheduled=True) order = make_order(scheduled=True)
# Schedule the order to a lot earlier. # Schedule the order to a lot earlier.
order.scheduled_delivery_at -= datetime.timedelta(hours=2) order.scheduled_delivery_at -= dt.timedelta(hours=2)
result = order.delivery_early result = order.delivery_early
@ -383,7 +981,7 @@ class TestProperties:
"""Test `Order.delivery_early` property.""" """Test `Order.delivery_early` property."""
order = make_order(scheduled=True) order = make_order(scheduled=True)
# Schedule the order to a lot earlier. # Schedule the order to a lot earlier.
order.scheduled_delivery_at -= datetime.timedelta(hours=2) order.scheduled_delivery_at -= dt.timedelta(hours=2)
result = order.delivery_late result = order.delivery_late
@ -393,7 +991,7 @@ class TestProperties:
"""Test `Order.delivery_early` property.""" """Test `Order.delivery_early` property."""
order = make_order(scheduled=True) order = make_order(scheduled=True)
# Schedule the order to a lot later. # Schedule the order to a lot later.
order.scheduled_delivery_at += datetime.timedelta(hours=2) order.scheduled_delivery_at += dt.timedelta(hours=2)
result = order.delivery_late result = order.delivery_late
@ -417,17 +1015,17 @@ class TestProperties:
"""Test `Order.total_time` property.""" """Test `Order.total_time` property."""
result = order.total_time result = order.total_time
assert result > datetime.timedelta(0) assert result > dt.timedelta(0)
@pytest.mark.db @pytest.mark.db
@pytest.mark.no_cover @pytest.mark.no_cover
def test_make_random_orders( # noqa:C901,WPS211,WPS213,WPS231 def test_make_random_orders( # noqa:C901,WPS211,WPS213
db_session, make_address, make_courier, make_restaurant, make_order, db_session, make_address, make_courier, make_restaurant, make_order,
): ):
"""Sanity check the all the `make_*` fixtures. """Sanity check the all the `make_*` fixtures.
Ensure that all generated `Address`, `Courier`, `Customer`, `Restauarant`, Ensure that all generated `Address`, `Courier`, `Customer`, `Restaurant`,
and `Order` objects adhere to the database constraints. and `Order` objects adhere to the database constraints.
""" # noqa:D202 """ # noqa:D202
# Generate a large number of `Order`s to obtain a large variance of data. # Generate a large number of `Order`s to obtain a large variance of data.

View file

@ -17,9 +17,7 @@ def horizontal_datetime_index():
The times resemble a horizontal time series with a `frequency` of `7`. The times resemble a horizontal time series with a `frequency` of `7`.
All observations take place at `NOON`. All observations take place at `NOON`.
""" """
first_start_at = dt.datetime( first_start_at = dt.datetime(*test_config.DATE, test_config.NOON, 0)
test_config.YEAR, test_config.MONTH, test_config.DAY, test_config.NOON, 0,
)
gen = ( gen = (
start_at start_at

View file

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