add tenant api

This commit is contained in:
Brian Rosner 2025-01-19 09:52:37 -07:00
parent 86a1e38dfc
commit 7cf05261c6
8 changed files with 338 additions and 126 deletions

View File

@ -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]

120
tests/test_tenant.py Normal file
View File

@ -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"}

View File

@ -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():

View File

@ -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

View File

@ -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/<int:flight_id>")
api_bp.register_blueprint(flights_bp)
v1_bp.register_blueprint(api_bp)

View File

@ -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/<int:flight_id>", 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/<int:flight_id>", 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/<int:flight_id>", 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

99
teufa/v1_api/tenants.py Normal file
View File

@ -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/<int:tenant_id>", 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/<int:tenant_id>", 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/<int:tenant_id>", 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

44
uv.lock generated
View File

@ -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" },