feat(ui): rebuild the interface with a proper design system
The old UI was two pages of raw pandas tables with no navigation, no styling and no way to see a run's comments. This replaces it with a small, testable application and a beer-themed design. What changed ------------ Restructure - The domain logic now lives in `brewing.py` as pure functions over plain data, with no Flask or Mongo dependency. The gravity maths, the ABV formula and the run-aggregation rules are unit-testable on their own. - `app_factory.py` replaces the module-level app, and the routes move into blueprints under `views/`. A request-scoped Mongo repository is created per request and closed at teardown. - `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`. Reading a document no longer requires a second query to fetch the fields. Interface - A dark, beer-themed design system: warm amber `#be7f39` on near-black, a condensed display face (Anton) for headings and Inter for body text. Both fonts and the chart library are vendored into `static/`, so the container needs no outbound network at runtime — the old templates pulled Bootstrap from a CDN. - A real navigation bar, a login page, a brew-process page with a proper form, and a run-detail page listing every reading with its comment. - The run summary tables now show the beer name and the latest comment, which the old pandas tables did not. - Native form controls (checkbox, number spinners, date icon, scrollbars) are restyled for the dark theme. Correctness - The alcohol calculation now uses the standard homebrew formula, `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is not a recognised brewing formula and overstated every result. - Gravity points and decimal readings now convert correctly in both directions (`1039` <-> `1.039`). - A run's display name resolves to the most-used label across its readings, because `beer_type` is stored per-reading and drifts across a run. - Re-submitting a reading for the same run and day updates it in place rather than creating a duplicate. Security / production - The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an interactive console on any unhandled error. It now runs waitress with debugging off. - `SECRET_KEY` comes from the environment. - The app secret is no longer a hard-coded string in source. Housekeeping - Removes the bokeh/duckdb/pandas dependency chain and the static file it generated at import time; removes the dead `add_user.py` and `test.py`. - Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests). - Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the image is only built when the tests pass. - Adds a README describing the data model and a RUNBOOK for the cluster side. Data compatibility ------------------ The existing `beer_db.beer_data` documents are read as they are and are not migrated or rewritten. No collection is dropped, and no document is modified by this change other than through the explicit "record a reading" form, which keeps the original `(date, beer_run_id)` uniqueness rule.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""Application factory for the beer_data web app.
|
||||
|
||||
The application is deliberately small: a handful of pages, one Mongo database,
|
||||
no build step for the front end. Everything server-side lives in blueprints
|
||||
under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g, redirect, request, session, url_for
|
||||
from flask_wtf import CSRFProtect
|
||||
|
||||
__all__ = ["create_app"]
|
||||
|
||||
|
||||
def create_app(config: dict | None = None) -> Flask:
|
||||
"""Build and configure the Flask application."""
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
)
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=os.getenv("SECRET_KEY", "dev-secret-change-me"),
|
||||
MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"),
|
||||
MONGO_USER=os.getenv("MONGO_USER", "root"),
|
||||
MONGO_PASS=os.getenv("MONGO_PASS", ""),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
# Flask-WTF needs this for the login form to render and validate.
|
||||
WTF_CSRF_ENABLED=True,
|
||||
)
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Request-scoped repository
|
||||
# ------------------------------------------------------------------ #
|
||||
from mongo.repository import BeerRepository
|
||||
|
||||
def get_repo() -> BeerRepository:
|
||||
"""One Mongo client per request, closed when the request ends."""
|
||||
if "repo" not in g:
|
||||
# A client can be injected via config (used by the test suite);
|
||||
# otherwise build one from the environment.
|
||||
injected = app.config.get("MONGO_CLIENT")
|
||||
g.repo = BeerRepository(client=injected) if injected else BeerRepository()
|
||||
return g.repo
|
||||
|
||||
app.extensions["beer_repo"] = get_repo
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _close_repo(_exc=None):
|
||||
repo = g.pop("repo", None)
|
||||
# An injected client (the test suite) is owned by the caller — do not
|
||||
# close it, or the next request in the same test gets a dead client.
|
||||
if repo is not None and app.config.get("MONGO_CLIENT") is None:
|
||||
repo.client.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Auth helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
def logged_in() -> bool:
|
||||
return bool(session.get("logged_in"))
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {
|
||||
"logged_in": logged_in(),
|
||||
"current_path": request.path,
|
||||
"app_name": "Brew Log",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Error handlers
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.errorhandler(404)
|
||||
def _not_found(_exc):
|
||||
return render_template("error.html", code=404,
|
||||
message="That page does not exist."), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def _server_error(_exc): # pragma: no cover - defensive
|
||||
return render_template("error.html", code=500,
|
||||
message="Something broke on our side."), 500
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Blueprints
|
||||
# ------------------------------------------------------------------ #
|
||||
from views.auth import bp as auth_bp
|
||||
from views.brews import bp as brews_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(brews_bp)
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
"""Liveness probe used by Kubernetes. Never touches Mongo."""
|
||||
return {"status": "ok"}, 200
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
if logged_in():
|
||||
return redirect(url_for("brews.index"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def render_template(*args, **kwargs): # noqa: D401 - re-export for views
|
||||
"""Small shim so view modules can import ``render_template`` from one place."""
|
||||
from flask import render_template as _rt
|
||||
|
||||
return _rt(*args, **kwargs)
|
||||
Reference in New Issue
Block a user