feat: Quart app factory with health endpoint
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from pathlib import Path
|
||||||
|
from quart import Quart
|
||||||
|
from .config import load_config
|
||||||
|
from .database import init_db
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(
|
||||||
|
config_path: Path | str | None = None,
|
||||||
|
testing: bool = False,
|
||||||
|
) -> Quart:
|
||||||
|
app = Quart(__name__, template_folder="templates")
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
cfg = load_config(config_path)
|
||||||
|
app.config.update(
|
||||||
|
SECRET_KEY=cfg["secret_key"],
|
||||||
|
DATABASE_URL=cfg["database"]["url"],
|
||||||
|
SESSION_LIFETIME_HOURS=cfg.get("session", {}).get("lifetime_hours", 8),
|
||||||
|
DATA_RETENTION_DAYS=cfg.get("data", {}).get("retention_days", 90),
|
||||||
|
PLUGIN_DIR=cfg.get("plugin_dir", "plugins"),
|
||||||
|
PLUGINS=cfg.get("plugins", {}),
|
||||||
|
SMTP=cfg.get("smtp", {}),
|
||||||
|
WEBHOOK=cfg.get("webhook", {}),
|
||||||
|
TESTING=testing,
|
||||||
|
_RAW_CFG=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database
|
||||||
|
if not testing:
|
||||||
|
init_db(app)
|
||||||
|
else:
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
app.db_sessionmaker = MagicMock()
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from .auth.routes import auth_bp
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
|
||||||
|
from .dashboard.routes import dashboard_bp
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import functools
|
||||||
|
from quart import session, redirect, url_for, abort
|
||||||
|
from fablednetmon.models.users import UserRole
|
||||||
|
|
||||||
|
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(minimum_role: UserRole):
|
||||||
|
"""Decorator: requires authenticated user with at least minimum_role."""
|
||||||
|
def decorator(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
user_role = UserRole(session.get("user_role", "viewer"))
|
||||||
|
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
|
||||||
|
abort(403)
|
||||||
|
return await f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(user) -> None:
|
||||||
|
"""Write user identity into the session."""
|
||||||
|
session["user_id"] = user.id
|
||||||
|
session["user_role"] = user.role.value
|
||||||
|
session["username"] = user.username
|
||||||
|
|
||||||
|
|
||||||
|
def logout_user() -> None:
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def current_user_id() -> str | None:
|
||||||
|
return session.get("user_id")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from quart import Blueprint
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import click
|
||||||
|
from .app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--config", default="config.yaml", help="Path to config.yaml")
|
||||||
|
@click.option("--host", default="0.0.0.0")
|
||||||
|
@click.option("--port", default=5000, type=int)
|
||||||
|
@click.option("--debug", is_flag=True, default=False)
|
||||||
|
def main(config: str, host: str, port: int, debug: bool) -> None:
|
||||||
|
"""Start the FabledNetMon server."""
|
||||||
|
app = create_app(config_path=Path(config))
|
||||||
|
app.run(host=host, port=port, debug=debug)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from quart import Blueprint, render_template
|
||||||
|
from fablednetmon.auth.middleware import require_role
|
||||||
|
from fablednetmon.models.users import UserRole
|
||||||
|
|
||||||
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_bp.get("/")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def index():
|
||||||
|
return await render_template("dashboard/index.html")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
import textwrap
|
||||||
|
from fablednetmon.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_file(tmp_path):
|
||||||
|
f = tmp_path / "config.yaml"
|
||||||
|
f.write_text(textwrap.dedent("""\
|
||||||
|
secret_key: test-secret-key-32-chars-minimum!!
|
||||||
|
database:
|
||||||
|
url: postgresql+asyncpg://test:test@localhost/test
|
||||||
|
"""))
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def app(config_file):
|
||||||
|
application = create_app(config_path=config_file, testing=True)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(app):
|
||||||
|
return app.test_client()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import pytest
|
||||||
|
from fablednetmon.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_app_returns_quart_app(config_file):
|
||||||
|
from quart import Quart
|
||||||
|
app = create_app(config_path=config_file, testing=True)
|
||||||
|
assert isinstance(app, Quart)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_app_has_db_sessionmaker(config_file):
|
||||||
|
app = create_app(config_path=config_file, testing=True)
|
||||||
|
assert hasattr(app, "db_sessionmaker")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_endpoint(client):
|
||||||
|
response = await client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
Reference in New Issue
Block a user