From 2dbb0a84164796ba9282c2da2d62f1e9c62299bb Mon Sep 17 00:00:00 2001 From: Brian Rosner Date: Thu, 23 May 2024 22:51:14 -0600 Subject: [PATCH] stub out v1 api --- .gitignore | 2 + alembic/versions/52bdefdbf755_initial_db.py | 138 +++++++++++++++ alembic/versions/d487ed348c4c_add_user.py | 37 ---- poetry.lock | 179 +++++++++++++++++++- pyproject.toml | 2 + tests/conftest.py | 24 ++- tests/test_flight.py | 173 +++++++++++++++++++ teufa/app.py | 21 ++- teufa/dao.py | 41 +++++ teufa/db.py | 109 +++++++++++- teufa/v1_api/__init__.py | 10 ++ teufa/v1_api/flights.py | 102 +++++++++++ 12 files changed, 797 insertions(+), 41 deletions(-) create mode 100644 alembic/versions/52bdefdbf755_initial_db.py delete mode 100644 alembic/versions/d487ed348c4c_add_user.py create mode 100644 tests/test_flight.py create mode 100644 teufa/dao.py create mode 100644 teufa/v1_api/__init__.py create mode 100644 teufa/v1_api/flights.py diff --git a/.gitignore b/.gitignore index 12dc156..b149727 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .mypy_cache/ .pytest_cache/ .ruff_cache/ +notes.txt + diff --git a/alembic/versions/52bdefdbf755_initial_db.py b/alembic/versions/52bdefdbf755_initial_db.py new file mode 100644 index 0000000..67e0326 --- /dev/null +++ b/alembic/versions/52bdefdbf755_initial_db.py @@ -0,0 +1,138 @@ +"""initial db + +Revision ID: 52bdefdbf755 +Revises: +Create Date: 2024-05-27 13:59:24.644977 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "52bdefdbf755" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tenants", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("hostname", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "aircraft", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("icao", sa.String(), nullable=False), + sa.Column("tail_number", sa.String(), nullable=True), + sa.Column("range_nm", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("icao", "tail_number"), + ) + op.create_table( + "airlines", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("iata", sa.String(), nullable=False), + sa.Column("icao", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "airports", + sa.Column("icao", sa.String(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("iata", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("city", sa.String(), nullable=False), + sa.Column("country", sa.String(), nullable=False), + sa.Column("latitude", sa.Float(), nullable=False), + sa.Column("longitude", sa.Float(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("icao"), + ) + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "flights", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("departure_icao", sa.String(), nullable=False), + sa.Column("arrival_icao", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("aircraft_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["aircraft_id"], + ["aircraft.id"], + ), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "livery", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("aircraft_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["aircraft_id"], + ["aircraft.id"], + ), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["tenants.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("livery") + op.drop_table("flights") + op.drop_table("users") + op.drop_table("airports") + op.drop_table("airlines") + op.drop_table("aircraft") + op.drop_table("tenants") + # ### end Alembic commands ### diff --git a/alembic/versions/d487ed348c4c_add_user.py b/alembic/versions/d487ed348c4c_add_user.py deleted file mode 100644 index b151a75..0000000 --- a/alembic/versions/d487ed348c4c_add_user.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add user - -Revision ID: d487ed348c4c -Revises: -Create Date: 2024-05-23 22:06:52.412454 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "d487ed348c4c" -down_revision: Union[str, None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("users") - # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 5bec090..4fa4408 100644 --- a/poetry.lock +++ b/poetry.lock @@ -19,6 +19,31 @@ typing-extensions = ">=4" [package.extras] tz = ["backports.zoneinfo"] +[[package]] +name = "aniso8601" +version = "9.0.1" +description = "A library for parsing ISO 8601 strings." +optional = false +python-versions = "*" +files = [ + {file = "aniso8601-9.0.1-py2.py3-none-any.whl", hash = "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f"}, + {file = "aniso8601-9.0.1.tar.gz", hash = "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973"}, +] + +[package.extras] +dev = ["black", "coverage", "isort", "pre-commit", "pyenchant", "pylint"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "blinker" version = "1.8.2" @@ -141,6 +166,26 @@ Werkzeug = ">=3.0.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-restful" +version = "0.3.10" +description = "Simple framework for creating REST APIs" +optional = false +python-versions = "*" +files = [ + {file = "Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37"}, + {file = "Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b"}, +] + +[package.dependencies] +aniso8601 = ">=0.82" +Flask = ">=0.8" +pytz = "*" +six = ">=1.3.0" + +[package.extras] +docs = ["sphinx"] + [[package]] name = "flask-sqlalchemy" version = "3.1.1" @@ -424,6 +469,116 @@ docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)" pool = ["psycopg-pool"] test = ["anyio (>=3.6.2,<4.0)", "mypy (>=1.4.1)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +[[package]] +name = "pydantic" +version = "2.7.1" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pydeps" version = "1.12.20" @@ -476,6 +631,28 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "sqlalchemy" version = "2.0.30" @@ -623,4 +800,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "21c13832e26aeda993299db3cf7414cac6f000f61f905516d8399026943bfadb" +content-hash = "e63de32ed89a7884b852f1a883d4e7cfe58ec7d010d95a230d436ec82dacf4b4" diff --git a/pyproject.toml b/pyproject.toml index ede5283..f525a17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ flask = "^3.0.3" flask-sqlalchemy = "^3.1.1" psycopg = "^3.1.19" alembic = "^1.13.1" +pydantic = "^2.7.1" +flask-restful = "^0.3.10" [tool.poetry.scripts] teufa = "teufa.cli:cli" diff --git a/tests/conftest.py b/tests/conftest.py index 0161ddc..604bef2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,31 @@ import pytest +from flask import Flask, g +from flask.testing import FlaskClient +from teufa import db as dbm from teufa.app import create_app +from teufa.ext import db @pytest.fixture def app(): app = create_app() - yield app + with app.app_context(): + dbm.Base.metadata.create_all(db.engine) + + tenant = dbm.Tenant( + name="Default", + hostname="localhost", + ) + db.session.add(tenant) + db.session.commit() + g.tenant = tenant + + yield app + + dbm.Base.metadata.drop_all(db.engine) + + +@pytest.fixture +def client(app: Flask) -> FlaskClient: + return app.test_client() diff --git a/tests/test_flight.py b/tests/test_flight.py new file mode 100644 index 0000000..f3bfa45 --- /dev/null +++ b/tests/test_flight.py @@ -0,0 +1,173 @@ +import json + +from flask import g +from flask.testing import FlaskClient +from sqlalchemy.sql import func, select + +from teufa import db as dbm +from teufa.ext import db + + +def test_create_flight(client: FlaskClient): + aircraft = dbm.Aircraft( + tenant_id=g.tenant.id, + icao="B737", + tail_number="N12345", + range_nm=3000, + ) + db.session.add(aircraft) + db.session.commit() + + response = client.post( + "/api/v1/flights", + json={ + "flight": { + "departure_icao": "KDEN", + "arrival_icao": "KLGA", + "aircraft_id": aircraft.id, + } + }, + ) + + assert response.status_code == 201 + assert json.loads(response.json) == { + "flight": { + "id": 1, + "departure_icao": "KDEN", + "arrival_icao": "KLGA", + "aircraft_id": 1, + } + } + + +def test_get_flight(client: FlaskClient): + aircraft = dbm.Aircraft( + tenant_id=g.tenant.id, + icao="B737", + tail_number="N12345", + range_nm=3000, + ) + db.session.add(aircraft) + db.session.commit() + db.session.add( + dbm.Flight( + id=1, + tenant_id=g.tenant.id, + departure_icao="KDEN", + arrival_icao="KLGA", + aircraft_id=aircraft.id, + ) + ) + db.session.commit() + + response = client.get("/api/v1/flights/1") + + assert response.status_code == 200 + assert json.loads(response.json) == { + "flight": { + "id": 1, + "departure_icao": "KDEN", + "arrival_icao": "KLGA", + "aircraft_id": 1, + } + } + + +def test_get_flight_not_found(client: FlaskClient): + response = client.get("/api/v1/flights/1") + + assert response.status_code == 404 + assert json.loads(response.json) == {"message": "Flight not found"} + + +def test_update_flight(client: FlaskClient): + aircraft = dbm.Aircraft( + tenant_id=g.tenant.id, + icao="B737", + tail_number="N12345", + range_nm=3000, + ) + db.session.add(aircraft) + db.session.commit() + db.session.add( + dbm.Flight( + id=1, + tenant_id=g.tenant.id, + departure_icao="KDEN", + arrival_icao="KLGA", + aircraft_id=aircraft.id, + ) + ) + db.session.commit() + + response = client.put( + "/api/v1/flights/1", + json={ + "flight": { + "departure_icao": "KJFK", + "arrival_icao": "KLAX", + "aircraft_id": 1, + } + }, + ) + + assert response.status_code == 200 + assert json.loads(response.json) == { + "flight": { + "id": 1, + "departure_icao": "KJFK", + "arrival_icao": "KLAX", + "aircraft_id": 1, + } + } + + +def test_update_flight_not_found(client: FlaskClient): + response = client.put( + "/api/v1/flights/1", + json={ + "flight": { + "departure_icao": "KJFK", + "arrival_icao": "KLAX", + } + }, + ) + + assert response.status_code == 404 + assert json.loads(response.json) == {"message": "Flight not found"} + + +def test_delete_flight(client: FlaskClient): + aircraft = dbm.Aircraft( + tenant_id=g.tenant.id, + icao="B737", + tail_number="N12345", + range_nm=3000, + ) + db.session.add(aircraft) + db.session.commit() + db.session.add( + dbm.Flight( + id=1, + tenant_id=g.tenant.id, + departure_icao="KDEN", + arrival_icao="KLGA", + aircraft_id=aircraft.id, + ) + ) + db.session.commit() + + response = client.delete("/api/v1/flights/1") + + assert response.status_code == 204 + assert response.data == b"" + + with db.session.begin(): + assert db.session.scalar(select(func.count(dbm.Flight.id))) == 0 + + +def test_delete_flight_not_found(client: FlaskClient): + response = client.delete("/api/v1/flights/1") + + assert response.status_code == 404 + assert json.loads(response.json) == {"message": "Flight not found"} diff --git a/teufa/app.py b/teufa/app.py index 9130376..69b5877 100644 --- a/teufa/app.py +++ b/teufa/app.py @@ -1,7 +1,10 @@ -from flask import Flask +from flask import Flask, g, request +from sqlalchemy import select +from . import db as dbm from .config import Config from .ext import db +from .v1_api import bp as v1_bp def create_app(): @@ -10,4 +13,20 @@ def create_app(): db.init_app(app) + @app.before_request + def before_request(): + if not hasattr(g, "tenant"): + hostname = request.host.split(":")[0] + g.tenant = db.session.scalars( + select(dbm.Tenant).filter_by(hostname=hostname).limit(1) + ).first() + + @app.teardown_request + def teardown_request(exc): + if exc: + db.session.rollback() + db.session.remove() + + app.register_blueprint(v1_bp) + return app diff --git a/teufa/dao.py b/teufa/dao.py new file mode 100644 index 0000000..fc0d150 --- /dev/null +++ b/teufa/dao.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel + +empty = object() + + +class Error(BaseModel): + message: str + + +class Flight(BaseModel): + id: int | None = None + departure_icao: str + arrival_icao: str + aircraft_id: int + + +class PartialFlight(BaseModel): + id: int | object = empty + departure_icao: str | object = empty + arrival_icao: str | object = empty + aircraft_id: int | object = empty + + +class CreateFlightRequest(BaseModel): + flight: Flight + + +class CreateFlightResponse(BaseModel): + flight: Flight + + +class UpdateFlightRequest(BaseModel): + flight: PartialFlight + + +class UpdateFlightResponse(BaseModel): + flight: Flight + + +class GetFlightResponse(BaseModel): + flight: Flight diff --git a/teufa/db.py b/teufa/db.py index f813c09..f369c96 100644 --- a/teufa/db.py +++ b/teufa/db.py @@ -1,13 +1,120 @@ -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from datetime import datetime + +from sqlalchemy import ForeignKey, UniqueConstraint, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass +class Tenant(Base): + __tablename__ = "tenants" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + hostname: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + users: Mapped[list["User"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + airlines: Mapped[list["Airline"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + airports: Mapped[list["Airport"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + fleet: Mapped[list["Aircraft"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + liveries: Mapped[list["Livery"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + flights: Mapped[list["Flight"]] = relationship( + back_populates="tenant", cascade="all, delete-orphan" + ) + + class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) name: Mapped[str] email: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + tenant: Mapped["Tenant"] = relationship(back_populates="users") + + +class Airline(Base): + __tablename__ = "airlines" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) + name: Mapped[str] + iata: Mapped[str] + icao: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + tenant: Mapped["Tenant"] = relationship(back_populates="airlines") + + +class Airport(Base): + __tablename__ = "airports" + + icao: Mapped[str] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) + iata: Mapped[str] + name: Mapped[str] + city: Mapped[str] + country: Mapped[str] + latitude: Mapped[float] + longitude: Mapped[float] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + tenant: Mapped["Tenant"] = relationship(back_populates="airports") + + +class Aircraft(Base): + __tablename__ = "aircraft" + __table_args__ = (UniqueConstraint("icao", "tail_number"),) + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) + icao: Mapped[str] + tail_number: Mapped[str | None] + range_nm: Mapped[int] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + tenant: Mapped["Tenant"] = relationship(back_populates="fleet") + liveries: Mapped[list["Livery"]] = relationship(back_populates="aircraft") + flights: Mapped[list["Flight"]] = relationship(back_populates="aircraft") + + +class Livery(Base): + __tablename__ = "livery" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) + name: Mapped[str] + aircraft_id: Mapped[int] = mapped_column(ForeignKey("aircraft.id")) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + + tenant: Mapped["Tenant"] = relationship(back_populates="liveries") + aircraft: Mapped["Aircraft"] = relationship(back_populates="liveries") + + +class Flight(Base): + __tablename__ = "flights" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id")) + departure_icao: Mapped[str] + arrival_icao: Mapped[str] + created_at: Mapped[datetime] = mapped_column(default=func.now()) + aircraft_id: Mapped["Aircraft"] = mapped_column(ForeignKey("aircraft.id")) + + tenant: Mapped["Tenant"] = relationship(back_populates="flights") + aircraft: Mapped["Aircraft"] = relationship(back_populates="flights") diff --git a/teufa/v1_api/__init__.py b/teufa/v1_api/__init__.py new file mode 100644 index 0000000..da7ec35 --- /dev/null +++ b/teufa/v1_api/__init__.py @@ -0,0 +1,10 @@ +from flask import Blueprint +from flask_restful import Api + +from .flights import FlightCollectionResource, FlightResource + +bp = Blueprint("api", __name__, url_prefix="/api") +api = Api(bp) + +api.add_resource(FlightCollectionResource, "/v1/flights") +api.add_resource(FlightResource, "/v1/flights/") diff --git a/teufa/v1_api/flights.py b/teufa/v1_api/flights.py new file mode 100644 index 0000000..5b5a470 --- /dev/null +++ b/teufa/v1_api/flights.py @@ -0,0 +1,102 @@ +from flask import g, request +from flask_restful import Resource + +from .. import dao +from .. import db as dbm +from ..ext import db + + +class FlightCollectionResource(Resource): + def post(self): + data = request.get_json() + + req = dao.CreateFlightRequest(**data) + + flight = dbm.Flight( + tenant_id=g.tenant.id, + departure_icao=req.flight.departure_icao, + arrival_icao=req.flight.arrival_icao, + aircraft_id=req.flight.aircraft_id, + ) + + db.session.add(flight) + db.session.commit() + + res = dao.CreateFlightResponse( + **{ + "flight": { + "id": flight.id, + "departure_icao": flight.departure_icao, + "arrival_icao": flight.arrival_icao, + "aircraft_id": flight.aircraft_id, + } + } + ) + + return res.model_dump_json(), 201 + + +class FlightResource(Resource): + def get(self, flight_id): + flight = db.session.get(dbm.Flight, flight_id) + + if not flight: + return dao.Error(message="Flight not found").model_dump_json(), 404 + + res = dao.GetFlightResponse( + **{ + "flight": { + "id": flight.id, + "departure_icao": flight.departure_icao, + "arrival_icao": flight.arrival_icao, + "aircraft_id": flight.aircraft_id, + } + } + ) + + return res.model_dump_json() + + def put(self, flight_id): + flight = db.session.get(dbm.Flight, flight_id) + + if not flight: + return dao.Error(message="Flight not found").model_dump_json(), 404 + + data = request.get_json() + + req = dao.UpdateFlightRequest(**data) + + if req.flight.id is not dao.empty: + flight.id = req.flight.id + if req.flight.departure_icao is not dao.empty: + flight.departure_icao = req.flight.departure_icao + if req.flight.arrival_icao is not dao.empty: + flight.arrival_icao = req.flight.arrival_icao + if req.flight.aircraft_id is not dao.empty: + flight.aircraft_id = req.flight.aircraft_id + + db.session.commit() + + res = dao.UpdateFlightResponse( + **{ + "flight": { + "id": flight.id, + "departure_icao": flight.departure_icao, + "arrival_icao": flight.arrival_icao, + "aircraft_id": flight.aircraft_id, + } + } + ) + + return res.model_dump_json() + + def delete(self, flight_id): + flight = db.session.get(dbm.Flight, flight_id) + + if not flight: + return dao.Error(message="Flight not found").model_dump_json(), 404 + + db.session.delete(flight) + db.session.commit() + + return "", 204