diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4aacbae --- /dev/null +++ b/alembic.ini @@ -0,0 +1,44 @@ +[alembic] +file_template = rev_%%(year)d%%(month).2d%%(day).2d_%%(hour).2d_%%(rev)s_%%(slug)s +script_location = %(here)s/migrations + +[post_write_hooks] +hooks=black +black.type=console_scripts +black.entrypoint=black + +# The following is taken from the default alembic.ini file. + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..243a2ee --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,4 @@ +# Database Migrations + +This project uses [alembic](https://alembic.sqlalchemy.org/en/latest) +to run the database migrations diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..15c79e3 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,45 @@ +"""Configure Alembic's migration environment.""" + +import os +from logging import config as log_config + +import sqlalchemy as sa +from alembic import context + +from urban_meal_delivery import config as umd_config +from urban_meal_delivery import db + + +# Disable the --sql option, a.k.a, the "offline mode". +if context.is_offline_mode(): + raise NotImplementedError('The --sql option is not implemented in this project') + + +# Set up the default Python logger from the alembic.ini file. +log_config.fileConfig(context.config.config_file_name) + + +def include_object(obj, _name, type_, _reflected, _compare_to): + """Only include the clean schema into --autogenerate migrations.""" + if type_ in {'table', 'column'} and obj.schema != umd_config.DATABASE_SCHEMA: + return False + + return True + + +engine = sa.create_engine(umd_config.DATABASE_URI) + +with engine.connect() as connection: + context.configure( + connection=connection, + include_object=include_object, + target_metadata=db.Base.metadata, + version_table='{alembic_table}{test_schema}'.format( + alembic_table=umd_config.ALEMBIC_TABLE, + test_schema=(f'_{umd_config.CLEAN_SCHEMA}' if os.getenv('TESTING') else ''), + ), + version_table_schema=umd_config.ALEMBIC_TABLE_SCHEMA, + ) + + with context.begin_transaction(): + context.run_migrations() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1b03c53 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,31 @@ +"""${message}. + +Revision: # ${up_revision} at ${create_date} +Revises: # ${down_revision | comma,n} +""" + +import os + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} + +from urban_meal_delivery import configuration + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +config = configuration.make_config('testing' if os.getenv('TESTING') else 'production') + + +def upgrade(): + """Upgrade to revision ${up_revision}.""" + ${upgrades if upgrades else "pass"} + + +def downgrade(): + """Downgrade to revision ${down_revision}.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/rev_20200806_23_f11cd76d2f45_create_the_database.py b/migrations/versions/rev_20200806_23_f11cd76d2f45_create_the_database.py new file mode 100644 index 0000000..a03e1dc --- /dev/null +++ b/migrations/versions/rev_20200806_23_f11cd76d2f45_create_the_database.py @@ -0,0 +1,802 @@ +"""Create the database from scratch. + +Revision: #f11cd76d2f45 at 2020-08-06 23:24:32 +""" + +import os + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +from urban_meal_delivery import configuration + + +revision = 'f11cd76d2f45' +down_revision = None +branch_labels = None +depends_on = None + + +config = configuration.make_config('testing' if os.getenv('TESTING') else 'production') + + +def upgrade(): + """Upgrade to revision f11cd76d2f45.""" + op.execute(f'CREATE SCHEMA {config.CLEAN_SCHEMA};') + op.create_table( # noqa:ECE001 + 'cities', + sa.Column('id', sa.SmallInteger(), autoincrement=False, nullable=False), + sa.Column('name', sa.Unicode(length=10), nullable=False), + sa.Column('kml', sa.UnicodeText(), nullable=False), + sa.Column('center_latitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('center_longitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('northeast_latitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('northeast_longitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('southwest_latitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('southwest_longitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('initial_zoom', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_cities')), + *( + [ # noqa:WPS504 + sa.ForeignKeyConstraint( + ['id'], + [f'{config.ORIGINAL_SCHEMA}.cities.id'], + name=op.f('pk_cities_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ] + if not config.TESTING + else [] + ), + schema=config.CLEAN_SCHEMA, + ) + op.create_table( # noqa:ECE001 + 'couriers', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('vehicle', sa.Unicode(length=10), nullable=False), + sa.Column('speed', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('capacity', sa.SmallInteger(), nullable=False), + sa.Column('pay_per_hour', sa.SmallInteger(), nullable=False), + sa.Column('pay_per_order', sa.SmallInteger(), nullable=False), + sa.CheckConstraint( + "vehicle IN ('bicycle', 'motorcycle')", + name=op.f('ck_couriers_on_available_vehicle_types'), + ), + sa.CheckConstraint( + '0 <= capacity AND capacity <= 200', + name=op.f('ck_couriers_on_capacity_under_200_liters'), + ), + sa.CheckConstraint( + '0 <= pay_per_hour AND pay_per_hour <= 1500', + name=op.f('ck_couriers_on_realistic_pay_per_hour'), + ), + sa.CheckConstraint( + '0 <= pay_per_order AND pay_per_order <= 650', + name=op.f('ck_couriers_on_realistic_pay_per_order'), + ), + sa.CheckConstraint( + '0 <= speed AND speed <= 30', name=op.f('ck_couriers_on_realistic_speed'), + ), + sa.PrimaryKeyConstraint('id', name=op.f('pk_couriers')), + *( + [ # noqa:WPS504 + sa.ForeignKeyConstraint( + ['id'], + [f'{config.ORIGINAL_SCHEMA}.couriers.id'], + name=op.f('pk_couriers_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ] + if not config.TESTING + else [] + ), + schema=config.CLEAN_SCHEMA, + ) + op.create_table( + 'customers', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_customers')), + schema=config.CLEAN_SCHEMA, + ) + op.create_table( # noqa:ECE001 + 'addresses', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('primary_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('place_id', sa.Unicode(length=120), nullable=False), # noqa:WPS432 + sa.Column('latitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('longitude', postgresql.DOUBLE_PRECISION(), nullable=False), + sa.Column('city_id', sa.SmallInteger(), nullable=False), + sa.Column('city', sa.Unicode(length=25), nullable=False), # noqa:WPS432 + sa.Column('zip_code', sa.Integer(), nullable=False), + sa.Column('street', sa.Unicode(length=80), nullable=False), # noqa:WPS432 + sa.Column('floor', sa.SmallInteger(), nullable=True), + sa.CheckConstraint( + '-180 <= longitude AND longitude <= 180', + name=op.f('ck_addresses_on_longitude_between_180_degrees'), + ), + sa.CheckConstraint( + '-90 <= latitude AND latitude <= 90', + name=op.f('ck_addresses_on_latitude_between_90_degrees'), + ), + sa.CheckConstraint( + '0 <= floor AND floor <= 40', name=op.f('ck_addresses_on_realistic_floor'), + ), + sa.CheckConstraint( + '30000 <= zip_code AND zip_code <= 99999', + name=op.f('ck_addresses_on_valid_zip_code'), + ), + sa.ForeignKeyConstraint( + ['city_id'], + [f'{config.CLEAN_SCHEMA}.cities.id'], + name=op.f('fk_addresses_to_cities_via_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['primary_id'], + [f'{config.CLEAN_SCHEMA}.addresses.id'], + name=op.f('fk_addresses_to_addresses_via_primary_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.PrimaryKeyConstraint('id', name=op.f('pk_addresses')), + *( + [ # noqa:WPS504 + sa.ForeignKeyConstraint( + ['id'], + [f'{config.ORIGINAL_SCHEMA}.addresses.id'], + name=op.f('pk_addresses_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ] + if not config.TESTING + else [] + ), + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_addresses_on_city_id'), + 'addresses', + ['city_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_addresses_on_place_id'), + 'addresses', + ['place_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_addresses_on_primary_id'), + 'addresses', + ['primary_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_addresses_on_zip_code'), + 'addresses', + ['zip_code'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_table( # noqa:ECE001 + 'restaurants', + sa.Column('id', sa.SmallInteger(), autoincrement=False, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('name', sa.Unicode(length=45), nullable=False), # noqa:WPS432 + sa.Column('address_id', sa.Integer(), nullable=False), + sa.Column('estimated_prep_duration', sa.SmallInteger(), nullable=False), + sa.CheckConstraint( + '0 <= estimated_prep_duration AND estimated_prep_duration <= 2400', + name=op.f('ck_restaurants_on_realistic_estimated_prep_duration'), + ), + sa.ForeignKeyConstraint( + ['address_id'], + [f'{config.CLEAN_SCHEMA}.addresses.id'], + name=op.f('fk_restaurants_to_addresses_via_address_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.PrimaryKeyConstraint('id', name=op.f('pk_restaurants')), + *( + [ # noqa:WPS504 + sa.ForeignKeyConstraint( + ['id'], + [f'{config.ORIGINAL_SCHEMA}.businesses.id'], + name=op.f('pk_restaurants_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ] + if not config.TESTING + else [] + ), + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_restaurants_on_address_id'), + 'restaurants', + ['address_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_table( # noqa:ECE001 + 'orders', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('delivery_id', sa.Integer(), nullable=True), + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('placed_at', sa.DateTime(), nullable=False), + sa.Column('ad_hoc', sa.Boolean(), nullable=False), + sa.Column('scheduled_delivery_at', sa.DateTime(), nullable=True), + sa.Column('scheduled_delivery_at_corrected', sa.Boolean(), nullable=True), + sa.Column('first_estimated_delivery_at', sa.DateTime(), nullable=True), + sa.Column('cancelled', sa.Boolean(), nullable=False), + sa.Column('cancelled_at', sa.DateTime(), nullable=True), + sa.Column('cancelled_at_corrected', sa.Boolean(), nullable=True), + sa.Column('sub_total', sa.Integer(), nullable=False), + sa.Column('delivery_fee', sa.SmallInteger(), nullable=False), + sa.Column('total', sa.Integer(), nullable=False), + sa.Column('restaurant_id', sa.SmallInteger(), nullable=False), + sa.Column('restaurant_notified_at', sa.DateTime(), nullable=True), + sa.Column('restaurant_notified_at_corrected', sa.Boolean(), nullable=True), + sa.Column('restaurant_confirmed_at', sa.DateTime(), nullable=True), + sa.Column('restaurant_confirmed_at_corrected', sa.Boolean(), nullable=True), + sa.Column('estimated_prep_duration', sa.Integer(), nullable=True), + sa.Column('estimated_prep_duration_corrected', sa.Boolean(), nullable=True), + sa.Column('estimated_prep_buffer', sa.Integer(), nullable=False), + sa.Column('courier_id', sa.Integer(), nullable=True), + sa.Column('dispatch_at', sa.DateTime(), nullable=True), + sa.Column('dispatch_at_corrected', sa.Boolean(), nullable=True), + sa.Column('courier_notified_at', sa.DateTime(), nullable=True), + sa.Column('courier_notified_at_corrected', sa.Boolean(), nullable=True), + sa.Column('courier_accepted_at', sa.DateTime(), nullable=True), + sa.Column('courier_accepted_at_corrected', sa.Boolean(), nullable=True), + sa.Column('utilization', sa.SmallInteger(), nullable=False), + sa.Column('pickup_address_id', sa.Integer(), nullable=False), + sa.Column('reached_pickup_at', sa.DateTime(), nullable=True), + sa.Column('pickup_at', sa.DateTime(), nullable=True), + sa.Column('pickup_at_corrected', sa.Boolean(), nullable=True), + sa.Column('pickup_not_confirmed', sa.Boolean(), nullable=True), + sa.Column('left_pickup_at', sa.DateTime(), nullable=True), + sa.Column('left_pickup_at_corrected', sa.Boolean(), nullable=True), + sa.Column('delivery_address_id', sa.Integer(), nullable=False), + sa.Column('reached_delivery_at', sa.DateTime(), nullable=True), + sa.Column('delivery_at', sa.DateTime(), nullable=True), + sa.Column('delivery_at_corrected', sa.Boolean(), nullable=True), + sa.Column('delivery_not_confirmed', sa.Boolean(), nullable=True), + sa.Column('courier_waited_at_delivery', sa.Boolean(), nullable=True), + sa.Column('logged_delivery_distance', sa.SmallInteger(), nullable=True), + sa.Column('logged_avg_speed', postgresql.DOUBLE_PRECISION(), nullable=True), + sa.Column('logged_avg_speed_distance', sa.SmallInteger(), nullable=True), + sa.CheckConstraint( + '0 <= estimated_prep_buffer AND estimated_prep_buffer <= 900', + name=op.f('ck_orders_on_estimated_prep_buffer_between_0_and_900'), + ), + sa.CheckConstraint( + '0 <= estimated_prep_duration AND estimated_prep_duration <= 2700', + name=op.f('ck_orders_on_estimated_prep_duration_between_0_and_2700'), + ), + sa.CheckConstraint( + '0 <= utilization AND utilization <= 100', + name=op.f('ck_orders_on_utilization_between_0_and_100'), + ), + sa.CheckConstraint( + '(cancelled_at IS NULL AND cancelled_at_corrected IS NULL) OR (cancelled_at IS NULL AND cancelled_at_corrected IS TRUE) OR (cancelled_at IS NOT NULL AND cancelled_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_1'), + ), + sa.CheckConstraint( + '(courier_accepted_at IS NULL AND courier_accepted_at_corrected IS NULL) OR (courier_accepted_at IS NULL AND courier_accepted_at_corrected IS TRUE) OR (courier_accepted_at IS NOT NULL AND courier_accepted_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_7'), + ), + sa.CheckConstraint( + '(courier_notified_at IS NULL AND courier_notified_at_corrected IS NULL) OR (courier_notified_at IS NULL AND courier_notified_at_corrected IS TRUE) OR (courier_notified_at IS NOT NULL AND courier_notified_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_6'), + ), + sa.CheckConstraint( + '(delivery_at IS NULL AND delivery_at_corrected IS NULL) OR (delivery_at IS NULL AND delivery_at_corrected IS TRUE) OR (delivery_at IS NOT NULL AND delivery_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_10'), + ), + sa.CheckConstraint( + '(dispatch_at IS NULL AND dispatch_at_corrected IS NULL) OR (dispatch_at IS NULL AND dispatch_at_corrected IS TRUE) OR (dispatch_at IS NOT NULL AND dispatch_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_5'), + ), + sa.CheckConstraint( + '(estimated_prep_duration IS NULL AND estimated_prep_duration_corrected IS NULL) OR (estimated_prep_duration IS NULL AND estimated_prep_duration_corrected IS TRUE) OR (estimated_prep_duration IS NOT NULL AND estimated_prep_duration_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_4'), + ), + sa.CheckConstraint( + '(left_pickup_at IS NULL AND left_pickup_at_corrected IS NULL) OR (left_pickup_at IS NULL AND left_pickup_at_corrected IS TRUE) OR (left_pickup_at IS NOT NULL AND left_pickup_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_9'), + ), + sa.CheckConstraint( + '(pickup_at IS NULL AND pickup_at_corrected IS NULL) OR (pickup_at IS NULL AND pickup_at_corrected IS TRUE) OR (pickup_at IS NOT NULL AND pickup_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_8'), + ), + sa.CheckConstraint( + '(restaurant_confirmed_at IS NULL AND restaurant_confirmed_at_corrected IS NULL) OR (restaurant_confirmed_at IS NULL AND restaurant_confirmed_at_corrected IS TRUE) OR (restaurant_confirmed_at IS NOT NULL AND restaurant_confirmed_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_3'), + ), + sa.CheckConstraint( + '(restaurant_notified_at IS NULL AND restaurant_notified_at_corrected IS NULL) OR (restaurant_notified_at IS NULL AND restaurant_notified_at_corrected IS TRUE) OR (restaurant_notified_at IS NOT NULL AND restaurant_notified_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_2'), + ), + sa.CheckConstraint( + '(scheduled_delivery_at IS NULL AND scheduled_delivery_at_corrected IS NULL) OR (scheduled_delivery_at IS NULL AND scheduled_delivery_at_corrected IS TRUE) OR (scheduled_delivery_at IS NOT NULL AND scheduled_delivery_at_corrected IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_corrections_only_for_set_value_0'), + ), + sa.CheckConstraint( + '(ad_hoc IS TRUE AND scheduled_delivery_at IS NULL) OR (ad_hoc IS FALSE AND scheduled_delivery_at IS NOT NULL)', # noqa:E501 + name=op.f('ck_orders_on_either_ad_hoc_or_scheduled_order'), + ), + sa.CheckConstraint( + 'NOT (EXTRACT(EPOCH FROM scheduled_delivery_at - placed_at) < 1800)', + name=op.f('ck_orders_on_scheduled_orders_not_within_30_minutes'), + ), + 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))', # noqa:E501 + name=op.f('ck_orders_on_scheduled_orders_within_business_hours'), + ), + sa.CheckConstraint( + 'NOT (ad_hoc IS TRUE AND (EXTRACT(HOUR FROM placed_at) < 11 OR EXTRACT(HOUR FROM placed_at) > 22))', # noqa:E501 + name=op.f('ck_orders_on_ad_hoc_orders_within_business_hours'), + ), + sa.CheckConstraint( + 'NOT (cancelled IS FALSE AND cancelled_at IS NOT NULL)', + name=op.f('ck_orders_on_only_cancelled_orders_may_have_cancelled_at'), + ), + sa.CheckConstraint( + 'NOT (cancelled IS TRUE AND delivery_at IS NOT NULL)', + name=op.f('ck_orders_on_cancelled_orders_must_not_be_delivered'), + ), + sa.CheckConstraint( + 'cancelled_at > courier_accepted_at', + name=op.f('ck_orders_on_ordered_timestamps_16'), + ), + sa.CheckConstraint( + 'cancelled_at > courier_notified_at', + name=op.f('ck_orders_on_ordered_timestamps_15'), + ), + sa.CheckConstraint( + 'cancelled_at > delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_21'), + ), + sa.CheckConstraint( + 'cancelled_at > dispatch_at', + name=op.f('ck_orders_on_ordered_timestamps_14'), + ), + sa.CheckConstraint( + 'cancelled_at > left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_19'), + ), + sa.CheckConstraint( + 'cancelled_at > pickup_at', name=op.f('ck_orders_on_ordered_timestamps_18'), + ), + sa.CheckConstraint( + 'cancelled_at > reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_20'), + ), + sa.CheckConstraint( + 'cancelled_at > reached_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_17'), + ), + sa.CheckConstraint( + 'cancelled_at > restaurant_confirmed_at', + name=op.f('ck_orders_on_ordered_timestamps_13'), + ), + sa.CheckConstraint( + 'cancelled_at > restaurant_notified_at', + name=op.f('ck_orders_on_ordered_timestamps_12'), + ), + sa.CheckConstraint( + 'courier_accepted_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_42'), + ), + sa.CheckConstraint( + 'courier_accepted_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_40'), + ), + sa.CheckConstraint( + 'courier_accepted_at < pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_39'), + ), + sa.CheckConstraint( + 'courier_accepted_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_41'), + ), + sa.CheckConstraint( + 'courier_accepted_at < reached_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_38'), + ), + sa.CheckConstraint( + 'courier_notified_at < courier_accepted_at', + name=op.f('ck_orders_on_ordered_timestamps_32'), + ), + sa.CheckConstraint( + 'courier_notified_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_37'), + ), + sa.CheckConstraint( + 'courier_notified_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_35'), + ), + sa.CheckConstraint( + 'courier_notified_at < pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_34'), + ), + sa.CheckConstraint( + 'courier_notified_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_36'), + ), + sa.CheckConstraint( + 'courier_notified_at < reached_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_33'), + ), + sa.CheckConstraint( + 'dispatch_at < courier_accepted_at', + name=op.f('ck_orders_on_ordered_timestamps_26'), + ), + sa.CheckConstraint( + 'dispatch_at < courier_notified_at', + name=op.f('ck_orders_on_ordered_timestamps_25'), + ), + sa.CheckConstraint( + 'dispatch_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_31'), + ), + sa.CheckConstraint( + 'dispatch_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_29'), + ), + sa.CheckConstraint( + 'dispatch_at < pickup_at', name=op.f('ck_orders_on_ordered_timestamps_28'), + ), + sa.CheckConstraint( + 'dispatch_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_30'), + ), + sa.CheckConstraint( + 'dispatch_at < reached_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_27'), + ), + sa.CheckConstraint( + 'estimated_prep_buffer % 60 = 0', + name=op.f('ck_orders_on_estimated_prep_buffer_must_be_whole_minutes'), + ), + sa.CheckConstraint( + 'estimated_prep_duration % 60 = 0', + name=op.f('ck_orders_on_estimated_prep_duration_must_be_whole_minutes'), + ), + sa.CheckConstraint( + 'left_pickup_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_51'), + ), + sa.CheckConstraint( + 'left_pickup_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_50'), + ), + sa.CheckConstraint( + 'pickup_at < delivery_at', name=op.f('ck_orders_on_ordered_timestamps_49'), + ), + sa.CheckConstraint( + 'pickup_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_47'), + ), + sa.CheckConstraint( + 'pickup_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_48'), + ), + sa.CheckConstraint( + 'placed_at < cancelled_at', name=op.f('ck_orders_on_ordered_timestamps_2'), + ), + sa.CheckConstraint( + 'placed_at < courier_accepted_at', + name=op.f('ck_orders_on_ordered_timestamps_7'), + ), + sa.CheckConstraint( + 'placed_at < courier_notified_at', + name=op.f('ck_orders_on_ordered_timestamps_6'), + ), + sa.CheckConstraint( + 'placed_at < delivery_at', name=op.f('ck_orders_on_ordered_timestamps_11'), + ), + sa.CheckConstraint( + 'placed_at < dispatch_at', name=op.f('ck_orders_on_ordered_timestamps_5'), + ), + sa.CheckConstraint( + 'placed_at < first_estimated_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_1'), + ), + sa.CheckConstraint( + 'placed_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_9'), + ), + sa.CheckConstraint( + 'placed_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_10'), + ), + sa.CheckConstraint( + 'placed_at < reached_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_8'), + ), + sa.CheckConstraint( + 'placed_at < restaurant_confirmed_at', + name=op.f('ck_orders_on_ordered_timestamps_4'), + ), + sa.CheckConstraint( + 'placed_at < restaurant_notified_at', + name=op.f('ck_orders_on_ordered_timestamps_3'), + ), + sa.CheckConstraint( + 'placed_at < scheduled_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_0'), + ), + sa.CheckConstraint( + 'reached_delivery_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_52'), + ), + sa.CheckConstraint( + 'reached_pickup_at < delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_46'), + ), + sa.CheckConstraint( + 'reached_pickup_at < left_pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_44'), + ), + sa.CheckConstraint( + 'reached_pickup_at < pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_43'), + ), + sa.CheckConstraint( + 'reached_pickup_at < reached_delivery_at', + name=op.f('ck_orders_on_ordered_timestamps_45'), + ), + sa.CheckConstraint( + 'restaurant_confirmed_at < pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_24'), + ), + sa.CheckConstraint( + 'restaurant_notified_at < pickup_at', + name=op.f('ck_orders_on_ordered_timestamps_23'), + ), + sa.CheckConstraint( + 'restaurant_notified_at < restaurant_confirmed_at', + name=op.f('ck_orders_on_ordered_timestamps_22'), + ), + sa.CheckConstraint( + '(pickup_at IS NULL AND pickup_not_confirmed IS NULL) OR (pickup_at IS NOT NULL AND pickup_not_confirmed IS NOT NULL)', # noqa:E501 + name=op.f('pickup_not_confirmed_only_if_pickup'), + ), + sa.CheckConstraint( + '(delivery_at IS NULL AND delivery_not_confirmed IS NULL) OR (delivery_at IS NOT NULL AND delivery_not_confirmed IS NOT NULL)', # noqa:E501 + name=op.f('delivery_not_confirmed_only_if_delivery'), + ), + sa.CheckConstraint( + '(delivery_at IS NULL AND courier_waited_at_delivery IS NULL) OR (delivery_at IS NOT NULL AND courier_waited_at_delivery IS NOT NULL)', # noqa:E501 + name=op.f('courier_waited_at_delivery_only_if_delivery'), + ), + sa.ForeignKeyConstraint( + ['courier_id'], + [f'{config.CLEAN_SCHEMA}.couriers.id'], + name=op.f('fk_orders_to_couriers_via_courier_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['customer_id'], + [f'{config.CLEAN_SCHEMA}.customers.id'], + name=op.f('fk_orders_to_customers_via_customer_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['delivery_address_id'], + [f'{config.CLEAN_SCHEMA}.addresses.id'], + name=op.f('fk_orders_to_addresses_via_delivery_address_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['pickup_address_id'], + [f'{config.CLEAN_SCHEMA}.addresses.id'], + name=op.f('fk_orders_to_addresses_via_pickup_address_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['restaurant_id'], + [f'{config.CLEAN_SCHEMA}.restaurants.id'], + name=op.f('fk_orders_to_restaurants_via_restaurant_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.PrimaryKeyConstraint('id', name=op.f('pk_orders')), + *( + [ # noqa:WPS504 + sa.ForeignKeyConstraint( + ['id'], + [f'{config.ORIGINAL_SCHEMA}.orders.id'], + name=op.f('pk_orders_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['delivery_id'], + [f'{config.ORIGINAL_SCHEMA}.deliveries.id'], + name=op.f('pk_deliveries_sanity'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ] + if not config.TESTING + else [] + ), + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_cancelled'), + 'orders', + ['cancelled'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_cancelled_at_corrected'), + 'orders', + ['cancelled_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_courier_accepted_at_corrected'), + 'orders', + ['courier_accepted_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_courier_id'), + 'orders', + ['courier_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_courier_notified_at_corrected'), + 'orders', + ['courier_notified_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_customer_id'), + 'orders', + ['customer_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_delivery_address_id'), + 'orders', + ['delivery_address_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_delivery_at_corrected'), + 'orders', + ['delivery_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_delivery_id'), + 'orders', + ['delivery_id'], + unique=True, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_dispatch_at_corrected'), + 'orders', + ['dispatch_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_estimated_prep_buffer'), + 'orders', + ['estimated_prep_buffer'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_estimated_prep_duration'), + 'orders', + ['estimated_prep_duration'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_estimated_prep_duration_corrected'), + 'orders', + ['estimated_prep_duration_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_left_pickup_at_corrected'), + 'orders', + ['left_pickup_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_pickup_address_id'), + 'orders', + ['pickup_address_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_pickup_at_corrected'), + 'orders', + ['pickup_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_placed_at'), + 'orders', + ['placed_at'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_restaurant_confirmed_at_corrected'), + 'orders', + ['restaurant_confirmed_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_restaurant_id'), + 'orders', + ['restaurant_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_restaurant_notified_at_corrected'), + 'orders', + ['restaurant_notified_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_scheduled_delivery_at'), + 'orders', + ['scheduled_delivery_at'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_orders_on_scheduled_delivery_at_corrected'), + 'orders', + ['scheduled_delivery_at_corrected'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + + +def downgrade(): + """Downgrade to revision None.""" + op.execute(f'DROP SCHEMA {config.CLEAN_SCHEMA} CASCADE;') diff --git a/noxfile.py b/noxfile.py index b8fd263..bf97ca1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -74,7 +74,9 @@ PYTEST_LOCATION = 'tests/' # Paths with all *.py files. SRC_LOCATIONS = ( - f'{DOCS_SRC}/conf.py', + f'{DOCS_SRC}conf.py', + 'migrations/env.py', + 'migrations/versions/', 'noxfile.py', PACKAGE_SOURCE_LOCATION, PYTEST_LOCATION, @@ -235,7 +237,12 @@ def test(session): # non-develop dependencies be installed in the virtual environment. session.run('poetry', 'install', '--no-dev', external=True) _install_packages( - session, 'packaging', 'pytest', 'pytest-cov', 'xdoctest[optional]', + session, + 'packaging', + 'pytest', + 'pytest-cov', + 'pytest-env', + 'xdoctest[optional]', ) # Interpret extra arguments as options for pytest. diff --git a/poetry.lock b/poetry.lock index cac0b0a..54abad8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6,6 +6,20 @@ optional = false python-versions = "*" version = "0.7.12" +[[package]] +category = "main" +description = "A database migration tool for SQLAlchemy." +name = "alembic" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.2" + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.1.0" +python-dateutil = "*" +python-editor = ">=0.3" + [[package]] category = "dev" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." @@ -562,7 +576,22 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "1.4.3" [[package]] -category = "dev" +category = "main" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +name = "mako" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.1.3" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] + +[[package]] +category = "main" description = "Safely add untrusted strings to HTML/XML markup." name = "markupsafe" optional = false @@ -812,6 +841,28 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] +[[package]] +category = "dev" +description = "py.test plugin that allows you to add environment variables." +name = "pytest-env" +optional = false +python-versions = "*" +version = "0.6.2" + +[package.dependencies] +pytest = ">=2.6.0" + +[[package]] +category = "main" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" + +[package.dependencies] +six = ">=1.5" + [[package]] category = "main" description = "Add .env support to your django/flask apps in development and deployments" @@ -823,6 +874,14 @@ version = "0.14.0" [package.extras] cli = ["click (>=5.0)"] +[[package]] +category = "main" +description = "Programmatically open an editor, capture the result." +name = "python-editor" +optional = false +python-versions = "*" +version = "1.0.4" + [[package]] category = "dev" description = "World timezone definitions, modern and historical" @@ -877,7 +936,7 @@ version = "1.3.1" docutils = ">=0.11,<1.0" [[package]] -category = "dev" +category = "main" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false @@ -1179,7 +1238,7 @@ optional = ["pygments", "colorama"] tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11"] [metadata] -content-hash = "508cbaa3105e47cac64c68663ed8d4178ee752bf267cb24cf68264e73325e10b" +content-hash = "3227fd9a5706b1483adc9b6cb7350515ffda05c38ab9c9a83d63594b3f4f6673" lock-version = "1.0" python-versions = "^3.8" @@ -1188,6 +1247,9 @@ alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, ] +alembic = [ + {file = "alembic-1.4.2.tar.gz", hash = "sha256:035ab00497217628bf5d0be82d664d8713ab13d37b630084da8e1f98facf4dbf"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -1435,6 +1497,10 @@ lazy-object-proxy = [ {file = "lazy_object_proxy-1.4.3-cp38-cp38-win32.whl", hash = "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd"}, {file = "lazy_object_proxy-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239"}, ] +mako = [ + {file = "Mako-1.1.3-py2.py3-none-any.whl", hash = "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"}, + {file = "Mako-1.1.3.tar.gz", hash = "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27"}, +] markupsafe = [ {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, @@ -1580,10 +1646,24 @@ pytest-cov = [ {file = "pytest-cov-2.10.0.tar.gz", hash = "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87"}, {file = "pytest_cov-2.10.0-py2.py3-none-any.whl", hash = "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"}, ] +pytest-env = [ + {file = "pytest-env-0.6.2.tar.gz", hash = "sha256:7e94956aef7f2764f3c147d216ce066bf6c42948bb9e293169b1b1c880a580c2"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] python-dotenv = [ {file = "python-dotenv-0.14.0.tar.gz", hash = "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d"}, {file = "python_dotenv-0.14.0-py2.py3-none-any.whl", hash = "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"}, ] +python-editor = [ + {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"}, + {file = "python_editor-1.0.4-py2-none-any.whl", hash = "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8"}, + {file = "python_editor-1.0.4-py2.7.egg", hash = "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522"}, + {file = "python_editor-1.0.4-py3-none-any.whl", hash = "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d"}, + {file = "python_editor-1.0.4-py3.5.egg", hash = "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77"}, +] pytz = [ {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, diff --git a/pyproject.toml b/pyproject.toml index 4948d98..505526e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ repository = "https://github.com/webartifex/urban-meal-delivery" [tool.poetry.dependencies] python = "^3.8" +alembic = "^1.4.2" click = "^7.1.2" psycopg2 = "^2.8.5" # adapter for PostgreSQL python-dotenv = "^0.14.0" @@ -56,6 +57,7 @@ wemake-python-styleguide = "^0.14.1" # flake8 plug-in packaging = "^20.4" # used to test the packaged version pytest = "^6.0.1" pytest-cov = "^2.10.0" +pytest-env = "^0.6.2" xdoctest = { version="^0.13.0", extras=["optional"] } # Documentation diff --git a/setup.cfg b/setup.cfg index 612d12e..1bbb117 100644 --- a/setup.cfg +++ b/setup.cfg @@ -102,6 +102,20 @@ per-file-ignores = docs/conf.py: # Allow shadowing built-ins and reading __*__ variables. WPS125,WPS609, + migrations/env.py: + # Type annotations are not strictly enforced. + ANN0, ANN2, + migrations/versions/*.py: + # Type annotations are not strictly enforced. + ANN0, ANN2, + # File names of revisions are ok. + WPS114,WPS118, + # Revisions may have too many expressions. + WPS204,WPS213, + # No overuse of string constants (e.g., 'RESTRICT'). + WPS226, + # Too many noqa's are ok. + WPS402, noxfile.py: # Type annotations are not strictly enforced. ANN0, ANN2, @@ -111,7 +125,7 @@ per-file-ignores = WPS213, # No overuse of string constants (e.g., '--version'). WPS226, - src/urban_meal_delivery/_config.py: + src/urban_meal_delivery/configuration.py: # Allow upper case class variables within classes. WPS115, # Numbers are normal in config files. @@ -255,5 +269,7 @@ addopts = --strict-markers cache_dir = .cache/pytest console_output_style = count +env = + TESTING=true markers = e2e: integration tests, inlc., for example, tests touching a database diff --git a/src/urban_meal_delivery/__init__.py b/src/urban_meal_delivery/__init__.py index 676a458..943ba9b 100644 --- a/src/urban_meal_delivery/__init__.py +++ b/src/urban_meal_delivery/__init__.py @@ -9,7 +9,7 @@ Example: import os as _os from importlib import metadata as _metadata -from urban_meal_delivery import _config # noqa:WPS450 +from urban_meal_delivery import configuration as _configuration try: @@ -26,8 +26,8 @@ else: __version__ = _pkg_info['version'] -# Little Hack: "Overwrites" the config module so that the environment is already set. -config: _config.Config = _config.get_config( +# Global `config` object to be used in the package. +config: _configuration.Config = _configuration.make_config( 'testing' if _os.getenv('TESTING') else 'production', ) diff --git a/src/urban_meal_delivery/_config.py b/src/urban_meal_delivery/configuration.py similarity index 80% rename from src/urban_meal_delivery/_config.py rename to src/urban_meal_delivery/configuration.py index 482b95d..0e6eefa 100644 --- a/src/urban_meal_delivery/_config.py +++ b/src/urban_meal_delivery/configuration.py @@ -1,11 +1,12 @@ """Provide package-wide configuration. -This module is "protected" so that it is only used -via the `config` proxy at the package's top level. +This module provides utils to create new `Config` objects +on the fly, mainly for testing and migrating! -That already loads the correct configuration -depending on the current environment. +Within this package, use the `config` proxy at the package's top level +to access the current configuration! """ + import datetime import os import random @@ -20,8 +21,10 @@ dotenv.load_dotenv() def random_schema_name() -> str: """Generate a random PostgreSQL schema name for testing.""" - return ''.join( - random.choice(string.ascii_lowercase) for _ in range(10) # noqa:S311 + return 'temp_{name}'.format( + name=''.join( + (random.choice(string.ascii_lowercase) for _ in range(10)), # noqa:S311 + ), ) @@ -44,6 +47,9 @@ class Config: # The PostgreSQL schema that holds the tables with the cleaned data. CLEAN_SCHEMA = os.getenv('CLEAN_SCHEMA') or 'clean' + ALEMBIC_TABLE = 'alembic_version' + ALEMBIC_TABLE_SCHEMA = 'public' + def __repr__(self) -> str: """Non-literal text representation.""" return '' @@ -68,8 +74,8 @@ class TestingConfig(Config): CLEAN_SCHEMA = os.getenv('CLEAN_SCHEMA_TESTING') or random_schema_name() -def get_config(env: str = 'production') -> Config: - """Get the configuration for the package. +def make_config(env: str = 'production') -> Config: + """Create a new `Config` object. Args: env: either 'production' or 'testing'; defaults to the first diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1b91688 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Utils for testing the entire package.""" + +import os + +from urban_meal_delivery import config + + +if not os.getenv('TESTING'): + raise RuntimeError('Tests must be executed with TESTING set in the environment') + +if not config.TESTING: + raise RuntimeError('The testing configuration was not loaded') diff --git a/tests/db/conftest.py b/tests/db/conftest.py index eeca169..2508161 100644 --- a/tests/db/conftest.py +++ b/tests/db/conftest.py @@ -3,30 +3,49 @@ import datetime import pytest -from sqlalchemy import schema +from alembic import command as migrations_cmd +from alembic import config as migrations_config from urban_meal_delivery import config from urban_meal_delivery import db -@pytest.fixture(scope='session') -def db_engine(): +@pytest.fixture(scope='session', params=['all_at_once', 'sequentially']) +def db_engine(request): """Create all tables given the ORM models. The tables are put into a distinct PostgreSQL schema that is removed after all tests are over. The engine used to do that is yielded. + + There are two modes for this fixture: + + - "all_at_once": build up the tables all at once with MetaData.create_all() + - "sequentially": build up the tables sequentially with `alembic upgrade head` + + This ensures that Alembic's migration files are consistent. """ engine = db.make_engine() - engine.execute(schema.CreateSchema(config.CLEAN_SCHEMA)) - db.Base.metadata.create_all(engine) + + if request.param == 'all_at_once': + engine.execute(f'CREATE SCHEMA {config.CLEAN_SCHEMA};') + db.Base.metadata.create_all(engine) + else: + cfg = migrations_config.Config('alembic.ini') + migrations_cmd.upgrade(cfg, 'head') try: yield engine finally: - engine.execute(schema.DropSchema(config.CLEAN_SCHEMA, cascade=True)) + engine.execute(f'DROP SCHEMA {config.CLEAN_SCHEMA} CASCADE;') + + if request.param == 'sequentially': + tmp_alembic_version = f'{config.ALEMBIC_TABLE}_{config.CLEAN_SCHEMA}' + engine.execute( + f'DROP TABLE {config.ALEMBIC_TABLE_SCHEMA}.{tmp_alembic_version};', + ) @pytest.fixture diff --git a/tests/test_config.py b/tests/test_config.py index 04c79f5..6569161 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ import pytest -from urban_meal_delivery import _config as config_mod # noqa:WPS450 +from urban_meal_delivery import configuration envs = ['production', 'testing'] @@ -11,7 +11,7 @@ envs = ['production', 'testing'] @pytest.mark.parametrize('env', envs) def test_config_repr(env): """Config objects have the text representation ''.""" - config = config_mod.get_config(env) + config = configuration.make_config(env) assert str(config) == '' @@ -19,18 +19,18 @@ def test_config_repr(env): def test_invalid_config(): """There are only 'production' and 'testing' configurations.""" with pytest.raises(ValueError, match="'production' or 'testing'"): - config_mod.get_config('invalid') + configuration.make_config('invalid') @pytest.mark.parametrize('env', envs) def test_database_uri_set(env, monkeypatch): """Package does NOT emit warning if DATABASE_URI is set.""" uri = 'postgresql://user:password@localhost/db' - monkeypatch.setattr(config_mod.ProductionConfig, 'DATABASE_URI', uri) - monkeypatch.setattr(config_mod.TestingConfig, 'DATABASE_URI', uri) + monkeypatch.setattr(configuration.ProductionConfig, 'DATABASE_URI', uri) + monkeypatch.setattr(configuration.TestingConfig, 'DATABASE_URI', uri) with pytest.warns(None) as record: - config_mod.get_config(env) + configuration.make_config(env) assert len(record) == 0 # noqa:WPS441,WPS507 @@ -38,16 +38,17 @@ def test_database_uri_set(env, monkeypatch): @pytest.mark.parametrize('env', envs) def test_no_database_uri_set(env, monkeypatch): """Package does not work without DATABASE_URI set in the environment.""" - monkeypatch.setattr(config_mod.ProductionConfig, 'DATABASE_URI', None) - monkeypatch.setattr(config_mod.TestingConfig, 'DATABASE_URI', None) + monkeypatch.setattr(configuration.ProductionConfig, 'DATABASE_URI', None) + monkeypatch.setattr(configuration.TestingConfig, 'DATABASE_URI', None) with pytest.warns(UserWarning, match='no DATABASE_URI'): - config_mod.get_config(env) + configuration.make_config(env) def test_random_testing_schema(): - """CLEAN_SCHEMA is randomized if not seti explicitly.""" - result = config_mod.random_schema_name() + """CLEAN_SCHEMA is randomized if not set explicitly.""" + result = configuration.random_schema_name() assert isinstance(result, str) - assert len(result) <= 10 + assert result.startswith('temp_') + assert len(result) == 15