diff --git a/pyproject.toml b/pyproject.toml index 0331338..9d81367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ dependencies = [ "psycopg<4.0.0,>=3.1.19", "alembic<2.0.0,>=1.13.1", "pydantic<3.0.0,>=2.7.1", - "flask-restful<1.0.0,>=0.3.10", ] [project.scripts] diff --git a/tests/test_tenant.py b/tests/test_tenant.py new file mode 100644 index 0000000..75c46b0 --- /dev/null +++ b/tests/test_tenant.py @@ -0,0 +1,120 @@ +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_tenant(client: FlaskClient): + response = client.post( + "/api/v1/admin/tenants", + json={ + "tenant": { + "name": "Test Tenant", + "hostname": "test.tenant.com", + } + }, + ) + + assert response.status_code == 201 + assert response.json == { + "tenant": { + "id": 1, + "name": "Test Tenant", + "hostname": "test.tenant.com", + } + } + + +def test_get_tenant(client: FlaskClient): + tenant = dbm.Tenant( + name="Test Tenant", + hostname="test.tenant.com", + ) + db.session.add(tenant) + db.session.commit() + + response = client.get(f"/api/v1/admin/tenants/{tenant.id}") + + assert response.status_code == 200 + assert response.json == { + "tenant": { + "id": tenant.id, + "name": "Test Tenant", + "hostname": "test.tenant.com", + } + } + + +def test_get_tenant_not_found(client: FlaskClient): + response = client.get("/api/v1/admin/tenants/1") + + assert response.status_code == 404 + assert response.json == {"message": "Tenant not found"} + + +def test_update_tenant(client: FlaskClient): + tenant = dbm.Tenant( + name="Test Tenant", + hostname="test.tenant.com", + ) + db.session.add(tenant) + db.session.commit() + + response = client.put( + f"/api/v1/admin/tenants/{tenant.id}", + json={ + "tenant": { + "name": "Updated Tenant", + "hostname": "updated.tenant.com", + } + }, + ) + + assert response.status_code == 200 + assert response.json == { + "tenant": { + "id": tenant.id, + "name": "Updated Tenant", + "hostname": "updated.tenant.com", + } + } + + +def test_update_tenant_not_found(client: FlaskClient): + response = client.put( + "/api/v1/admin/tenants/1", + json={ + "tenant": { + "name": "Updated Tenant", + "hostname": "updated.tenant.com", + } + }, + ) + + assert response.status_code == 404 + assert response.json == {"message": "Tenant not found"} + + +def test_delete_tenant(client: FlaskClient): + tenant = dbm.Tenant( + name="Test Tenant", + hostname="test.tenant.com", + ) + db.session.add(tenant) + db.session.commit() + + response = client.delete(f"/api/v1/admin/tenants/{tenant.id}") + + assert response.status_code == 204 + assert response.data == b"" + + with db.session.begin(): + assert db.session.scalar(select(func.count(dbm.Tenant.id))) == 0 + + +def test_delete_tenant_not_found(client: FlaskClient): + response = client.delete("/api/v1/admin/tenants/1") + + assert response.status_code == 404 + assert response.json == {"message": "Tenant not found"} diff --git a/teufa/app.py b/teufa/app.py index a71344e..3043b0f 100644 --- a/teufa/app.py +++ b/teufa/app.py @@ -2,7 +2,7 @@ from flask import Flask from .config import Config from .ext import db -from .v1_api import bp as v1_bp +from .v1_api import v1_bp def create_app(): diff --git a/teufa/dao.py b/teufa/dao.py index fc0d150..ceac8db 100644 --- a/teufa/dao.py +++ b/teufa/dao.py @@ -7,6 +7,38 @@ class Error(BaseModel): message: str +class Tenant(BaseModel): + id: int | None = None + name: str + hostname: str + + +class PartialTenant(BaseModel): + id: int | object = empty + name: str | object = empty + hostname: str | object = empty + + +class CreateTenantRequest(BaseModel): + tenant: Tenant + + +class CreateTenantResponse(BaseModel): + tenant: Tenant + + +class UpdateTenantRequest(BaseModel): + tenant: PartialTenant + + +class UpdateTenantResponse(BaseModel): + tenant: Tenant + + +class GetTenantResponse(BaseModel): + tenant: Tenant + + class Flight(BaseModel): id: int | None = None departure_icao: str diff --git a/teufa/v1_api/__init__.py b/teufa/v1_api/__init__.py index 479bfbe..e6f6e44 100644 --- a/teufa/v1_api/__init__.py +++ b/teufa/v1_api/__init__.py @@ -1,15 +1,22 @@ -from flask import Blueprint, Flask, g, request -from flask_restful import Api +from flask import Blueprint, g, request from sqlalchemy import select from .. import db as dbm from ..ext import db -from .flights import FlightCollectionResource, FlightResource +from .flights import flights_bp +from .tenants import tenants_bp -bp = Blueprint("api", __name__, url_prefix="/api") +v1_bp = Blueprint("v1", __name__, url_prefix="/api/v1") + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + +admin_bp.register_blueprint(tenants_bp) +v1_bp.register_blueprint(admin_bp) + +api_bp = Blueprint("api", __name__, url_prefix="/") -@bp.before_request +@api_bp.before_request def before_request(): if not hasattr(g, "tenant"): hostname = request.host.split(":")[0] @@ -18,7 +25,5 @@ def before_request(): ).first() -api = Api(bp) - -api.add_resource(FlightCollectionResource, "/v1/flights") -api.add_resource(FlightResource, "/v1/flights/") +api_bp.register_blueprint(flights_bp) +v1_bp.register_blueprint(api_bp) diff --git a/teufa/v1_api/flights.py b/teufa/v1_api/flights.py index 9f3d279..6de0c33 100644 --- a/teufa/v1_api/flights.py +++ b/teufa/v1_api/flights.py @@ -1,102 +1,103 @@ -from flask import g, request -from flask_restful import Resource +from flask import Blueprint, g, jsonify, request from .. import dao from .. import db as dbm from ..ext import db +flights_bp = Blueprint("flights", __name__) -class FlightCollectionResource(Resource): - def post(self): - data = request.get_json() - req = dao.CreateFlightRequest(**data) +@flights_bp.route("/flights", methods=["POST"]) +def create_flight(): + 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, - ) + 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() + 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, - } + 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(), 201 + return jsonify(res.model_dump()), 201 -class FlightResource(Resource): - def get(self, flight_id): - flight = db.session.get(dbm.Flight, flight_id) +@flights_bp.route("/flights/", methods=["GET"]) +def get_flight(flight_id): + flight = db.session.get(dbm.Flight, flight_id) - if not flight: - return dao.Error(message="Flight not found").model_dump(), 404 + if not flight: + return jsonify(dao.Error(message="Flight not found").model_dump()), 404 - res = dao.GetFlightResponse( - **{ - "flight": { - "id": flight.id, - "departure_icao": flight.departure_icao, - "arrival_icao": flight.arrival_icao, - "aircraft_id": flight.aircraft_id, - } + 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() + return jsonify(res.model_dump()) - 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(), 404 +@flights_bp.route("/flights/", methods=["PUT"]) +def update_flight(flight_id): + flight = db.session.get(dbm.Flight, flight_id) - data = request.get_json() + if not flight: + return jsonify(dao.Error(message="Flight not found").model_dump()), 404 - req = dao.UpdateFlightRequest(**data) + 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 + 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() + 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, - } + 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() + return jsonify(res.model_dump()) - 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(), 404 +@flights_bp.route("/flights/", methods=["DELETE"]) +def delete_flight(flight_id): + flight = db.session.get(dbm.Flight, flight_id) - db.session.delete(flight) - db.session.commit() + if not flight: + return jsonify(dao.Error(message="Flight not found").model_dump()), 404 - return "", 204 + db.session.delete(flight) + db.session.commit() + + return "", 204 diff --git a/teufa/v1_api/tenants.py b/teufa/v1_api/tenants.py new file mode 100644 index 0000000..4035558 --- /dev/null +++ b/teufa/v1_api/tenants.py @@ -0,0 +1,99 @@ +from flask import Blueprint, g, jsonify, request + +from .. import dao +from .. import db as dbm +from ..ext import db + +tenants_bp = Blueprint("tenants", __name__) + + +@tenants_bp.route("/tenants", methods=["POST"]) +def create_tenant(): + data = request.get_json() + req = dao.CreateTenantRequest(**data) + + tenant = dbm.Tenant( + name=req.tenant.name, + hostname=req.tenant.hostname, + ) + + db.session.add(tenant) + db.session.commit() + + res = dao.CreateTenantResponse( + **{ + "tenant": { + "id": tenant.id, + "name": tenant.name, + "hostname": tenant.hostname, + "created_at": tenant.created_at, + } + } + ) + + return jsonify(res.model_dump()), 201 + + +@tenants_bp.route("/tenants/", methods=["GET"]) +def get_tenant(tenant_id): + tenant = db.session.get(dbm.Tenant, tenant_id) + + if not tenant: + return jsonify(dao.Error(message="Tenant not found").model_dump()), 404 + + res = dao.GetTenantResponse( + **{ + "tenant": { + "id": tenant.id, + "name": tenant.name, + "hostname": tenant.hostname, + "created_at": tenant.created_at, + } + } + ) + + return jsonify(res.model_dump()) + + +@tenants_bp.route("/tenants/", methods=["PUT"]) +def update_tenant(tenant_id): + tenant = db.session.get(dbm.Tenant, tenant_id) + + if not tenant: + return jsonify(dao.Error(message="Tenant not found").model_dump()), 404 + + data = request.get_json() + req = dao.UpdateTenantRequest(**data) + + if req.tenant.name is not dao.empty: + tenant.name = req.tenant.name + if req.tenant.hostname is not dao.empty: + tenant.hostname = req.tenant.hostname + + db.session.commit() + + res = dao.UpdateTenantResponse( + **{ + "tenant": { + "id": tenant.id, + "name": tenant.name, + "hostname": tenant.hostname, + "created_at": tenant.created_at, + } + } + ) + + return jsonify(res.model_dump()) + + +@tenants_bp.route("/tenants/", methods=["DELETE"]) +def delete_tenant(tenant_id): + tenant = db.session.get(dbm.Tenant, tenant_id) + + if not tenant: + return jsonify(dao.Error(message="Tenant not found").model_dump()), 404 + + db.session.delete(tenant) + db.session.commit() + + return "", 204 diff --git a/uv.lock b/uv.lock index 55d8fd1..272fb0d 100644 --- a/uv.lock +++ b/uv.lock @@ -15,15 +15,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/06/8b505aea3d77021b18dcbd8133aa1418f1a1e37e432a465b14c46b2c0eaa/alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25", size = 233482 }, ] -[[package]] -name = "aniso8601" -version = "10.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/3f/dc8a28fa6dc72c13d8c158b01f8975f240e9e72c336cc1ae00f424e2d7ce/aniso8601-10.0.0.tar.gz", hash = "sha256:ff1d0fc2346688c62c0151547136ac30e322896ed8af316ef7602c47da9426cf", size = 47008 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/bf/d5cde2cb7cdc2cb1770d974418d169a79c3187bd962cb752b9fd617848ca/aniso8601-10.0.0-py2.py3-none-any.whl", hash = "sha256:3c943422efaa0229ebd2b0d7d223effb5e7c89e24d2267ebe76c61a2d8e290cb", size = 52767 }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -107,21 +98,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 }, ] -[[package]] -name = "flask-restful" -version = "0.3.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aniso8601" }, - { name = "flask" }, - { name = "pytz" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/ce/a0a133db616ea47f78a41e15c4c68b9f08cab3df31eb960f61899200a119/Flask-RESTful-0.3.10.tar.gz", hash = "sha256:fe4af2ef0027df8f9b4f797aba20c5566801b6ade995ac63b588abf1a59cec37", size = 110453 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/7b/f0b45f0df7d2978e5ae51804bb5939b7897b2ace24306009da0cc34d8d1f/Flask_RESTful-0.3.10-py2.py3-none-any.whl", hash = "sha256:1cf93c535172f112e080b0d4503a8d15f93a48c88bdd36dd87269bdaf405051b", size = 26217 }, -] - [[package]] name = "flask-sqlalchemy" version = "3.1.1" @@ -350,15 +326,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, ] -[[package]] -name = "pytz" -version = "2024.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, -] - [[package]] name = "ruff" version = "0.9.2" @@ -384,15 +351,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/4e/33df635528292bd2d18404e4daabcd74ca8a9853b2e1df85ed3d32d24362/ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6", size = 10001738 }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, -] - [[package]] name = "sqlalchemy" version = "2.0.37" @@ -431,7 +389,6 @@ dependencies = [ { name = "alembic" }, { name = "click" }, { name = "flask" }, - { name = "flask-restful" }, { name = "flask-sqlalchemy" }, { name = "gunicorn" }, { name = "psycopg" }, @@ -453,7 +410,6 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13.1,<2.0.0" }, { name = "click", specifier = ">=8.1.7,<9.0.0" }, { name = "flask", specifier = ">=3.0.3,<4.0.0" }, - { name = "flask-restful", specifier = ">=0.3.10,<1.0.0" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1,<4.0.0" }, { name = "gunicorn", specifier = ">=22.0.0,<23.0.0" }, { name = "psycopg", specifier = ">=3.1.19,<4.0.0" },