Files
beer_data/src/flask/views/brews.py
T
hermes 24cdd452c5
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 17m41s
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.
2026-11-09 10:21:44 +10:00

153 lines
5.2 KiB
Python

"""The brew-process pages: record readings and review the runs."""
from __future__ import annotations
from flask import (Blueprint, flash, redirect, render_template, request,
session, url_for)
from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from brewing import is_plausible_sg, run_summary
bp = Blueprint("brews", __name__)
def repo():
"""The request-scoped repository (one Mongo client per request)."""
from flask import current_app
return current_app.extensions["beer_repo"]()
def login_required(fn):
"""Redirect to the login page when there is no session."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("auth.login", next=request.path))
return fn(*args, **kwargs)
return wrapper
def _last_reading_date(run) -> str | None:
"""The ISO date of the most recent reading in a run."""
last = run.last_reading()
if last is None or last.date is None:
return None
return last.date.isoformat()
class ReadingForm(FlaskForm):
"""Add or update a gravity reading."""
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
beer_run_type = StringField("Beer / cider name",
validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()])
sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1300)])
final_reading = BooleanField("This is the final reading")
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
submit = SubmitField("Save reading")
@bp.route("/brews", methods=["GET", "POST"])
@login_required
def index():
"""The brew-process page: record a reading, then review the runs."""
form = ReadingForm()
if form.validate_on_submit():
sg_points = int(form.sg.data)
if not is_plausible_sg(sg_points):
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
repo().upsert_reading(
beer_run_id=str(form.beer_run_id.data),
beer_type=(form.beer_run_type.data or "").strip(),
sg=sg_points,
reading_date=form.Date.data.isoformat(),
final_reading=bool(form.final_reading.data),
comment=(form.comment.data or "").strip(),
)
flash("Reading saved.", "success")
return redirect(url_for("brews.index"))
runs = repo().runs()
summaries = []
for run in runs:
summary = run_summary(run)
summary["canonical_name"] = run.display_name()
summary["last_date"] = _last_reading_date(run)
summaries.append(summary)
# Active runs: most recent activity first — that is what is fermenting now.
active = sorted([s for s in summaries if s["active"]],
key=lambda s: s["last_date"] or "", reverse=True)
# Finished runs: most recently finished first.
completed = sorted([s for s in summaries if not s["active"]],
key=lambda s: s["final_date"] or "", reverse=True)
return render_template(
"brews.html",
form=form,
summaries=summaries,
active_runs=active,
completed_runs=completed,
)
@bp.route("/runs/<run_id>")
@login_required
def run_detail(run_id: str):
"""One run in detail, with all of its readings."""
run = repo().run(run_id)
if run is None:
flash(f"No run numbered {run_id}.", "error")
return redirect(url_for("brews.index"))
summary = run_summary(run)
summary["canonical_name"] = run.display_name()
return render_template("run_detail.html", run=run, summary=summary)
@bp.route("/runs/<run_id>/final", methods=["POST"])
@login_required
def mark_final(run_id: str):
"""Flag a reading as the final one for its run."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
changed = repo().set_final_reading(run_id, reading_date, True)
if changed:
flash("Final reading recorded.", "success")
else:
flash("That reading could not be updated.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
@login_required
def unfinal(run_id: str):
"""Un-flag a final reading, putting the run back into the active list."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
repo().set_final_reading(run_id, reading_date, False)
flash("Reading is no longer marked final.", "info")
return redirect(url_for("brews.run_detail", run_id=run_id))