Files
beer_data/src/flask/app_factory.py
T
hermes 1fa9f1dcbe feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment on a reading.

A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number
- record the OG or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.

Tests
-----
91 (up from 74). New coverage: the active page contents, the tap number, the
projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next free
tap number, the partial-update semantics, the history page, the outlier
list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts into
`beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:35:50 +10:00

167 lines
6.0 KiB
Python

"""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"]
class ImproperlyConfigured(RuntimeError):
"""Raised when the app is missing configuration it cannot safely default.
Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never
exported one, so the app defines its own. It is a ``RuntimeError`` so it
is not mistaken for an HTTP error the app should try to render a page for.
"""
def _require_secret_key() -> str:
"""Return the Flask secret key, refusing to start without a real one.
A known default secret key means anyone can forge a session cookie and
log in as the operator, so a missing or placeholder value must stop the
app rather than silently fall back. The one exception is the test
suite, which supplies its own key through the config.
"""
secret = os.getenv("SECRET_KEY", "").strip()
if not secret:
raise ImproperlyConfigured(
"SECRET_KEY is not set. Export a random value, e.g. "
"`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`."
)
if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}:
raise ImproperlyConfigured(
"SECRET_KEY is still the placeholder value. Set a real secret key."
)
return secret
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=_require_secret_key(),
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.analysis import bp as analysis_bp
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.register_blueprint(analysis_bp)
@app.route("/healthz")
def healthz():
"""Liveness probe. Never touches Mongo — it answers "is the process
up", not "is the app working". A database outage must not cause the
kubelet to restart a perfectly healthy process."""
return {"status": "ok"}, 200
@app.route("/readyz")
def readyz():
"""Readiness probe. Checks that Mongo is actually reachable, so a
pod with no database is taken out of the Service's endpoints instead
of serving errors."""
try:
repo = get_repo()
repo.client.admin.command("ping")
except Exception as exc: # noqa: BLE001 - any failure means not ready
return {"status": "not ready", "error": str(exc)}, 503
return {"status": "ready"}, 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)