From 5c259539292fb13876774a551a584bf38d6a0fce Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 17 Mar 2026 18:55:15 -0400 Subject: [PATCH] feat: Quart app factory with health endpoint --- fablednetmon/app.py | 48 ++++++++++++++++++++++++++++++ fablednetmon/auth/__init__.py | 0 fablednetmon/auth/middleware.py | 37 +++++++++++++++++++++++ fablednetmon/auth/routes.py | 3 ++ fablednetmon/cli.py | 14 +++++++++ fablednetmon/dashboard/__init__.py | 0 fablednetmon/dashboard/routes.py | 11 +++++++ tests/conftest.py | 27 +++++++++++++++++ tests/test_app.py | 21 +++++++++++++ 9 files changed, 161 insertions(+) create mode 100644 fablednetmon/app.py create mode 100644 fablednetmon/auth/__init__.py create mode 100644 fablednetmon/auth/middleware.py create mode 100644 fablednetmon/auth/routes.py create mode 100644 fablednetmon/cli.py create mode 100644 fablednetmon/dashboard/__init__.py create mode 100644 fablednetmon/dashboard/routes.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py diff --git a/fablednetmon/app.py b/fablednetmon/app.py new file mode 100644 index 0000000..dc1acc4 --- /dev/null +++ b/fablednetmon/app.py @@ -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 diff --git a/fablednetmon/auth/__init__.py b/fablednetmon/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/auth/middleware.py b/fablednetmon/auth/middleware.py new file mode 100644 index 0000000..bc3ac30 --- /dev/null +++ b/fablednetmon/auth/middleware.py @@ -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") diff --git a/fablednetmon/auth/routes.py b/fablednetmon/auth/routes.py new file mode 100644 index 0000000..e00ba49 --- /dev/null +++ b/fablednetmon/auth/routes.py @@ -0,0 +1,3 @@ +from quart import Blueprint + +auth_bp = Blueprint("auth", __name__) diff --git a/fablednetmon/cli.py b/fablednetmon/cli.py new file mode 100644 index 0000000..b9fe960 --- /dev/null +++ b/fablednetmon/cli.py @@ -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) diff --git a/fablednetmon/dashboard/__init__.py b/fablednetmon/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/dashboard/routes.py b/fablednetmon/dashboard/routes.py new file mode 100644 index 0000000..7085aef --- /dev/null +++ b/fablednetmon/dashboard/routes.py @@ -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") diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..072f81a --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b413433 --- /dev/null +++ b/tests/test_app.py @@ -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