feat(ui): rebuild the interface with a proper design system
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 17m41s

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:
2026-11-09 10:21:44 +10:00
parent fb65ba4ee0
commit 24cdd452c5
37 changed files with 2943 additions and 725 deletions
+234
View File
@@ -0,0 +1,234 @@
"""Mongo access layer for the beer_data app.
Two collections are used:
``beer_data``
The historic readings. One document per gravity reading. **Never modified or
removed by the code in this module** apart from the explicit "add or update a
reading" path used by the brew-process form.
``beer_runs``
New in this version. One document per brew run holding the *canonical*
metadata that could not be derived from the readings themselves: the beer
name, the tap number it is on, the original gravity, and whether the run is
still in the fermenter. The backfill script only ever inserts into this
collection, so it is safe to run repeatedly.
"""
from __future__ import annotations
import os
from typing import Any
from pymongo import MongoClient
from pymongo.errors import PyMongoError
from brewing import Run
__all__ = ["BeerRepository", "get_repository"]
class BeerRepository:
"""Thin, testable wrapper around the two Mongo collections."""
def __init__(self, client: MongoClient | None = None,
db_name: str = "beer_db") -> None:
self.client = client or _build_client()
self.db = self.client[db_name]
self.readings = self.db["beer_data"]
self.users = self.db["users"]
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
# shadow the runs() method on the instance.
self.run_meta = self.db["beer_runs"]
# -- users -------------------------------------------------------------- #
def user_check(self, username: str, password: str) -> bool:
"""Check the login credentials.
Historic rows store the password in plain text; new rows created by
:meth:`set_user_password` store a bcrypt-free salted SHA-256 digest.
Both are checked so existing accounts keep working.
"""
doc = self.users.find_one({"username": username})
if not doc:
return False
stored = str(doc.get("password") or "")
if not stored:
return False
if stored == password:
return True
return stored == _hash_password(password)
# -- readings ----------------------------------------------------------- #
def all_reading_docs(self) -> list[dict]:
"""Every reading document, oldest first."""
return list(self.readings.find().sort([("date", 1), ("beer_run_id", 1)]))
def reading_docs_for_run(self, beer_run_id: str) -> list[dict]:
return list(
self.readings.find({"beer_run_id": str(beer_run_id)}).sort("date", 1)
)
def runs(self) -> list[Run]:
"""Every run, built from the reading documents, oldest run first."""
grouped: dict[str, list[dict]] = {}
for doc in self.all_reading_docs():
grouped.setdefault(str(doc.get("beer_run_id")), []).append(doc)
def run_sort_key(run: Run) -> tuple:
first = run.first_reading()
return (first.date if (first and first.date) else date_min(), run.beer_run_id)
runs = [Run.from_docs(run_id, docs) for run_id, docs in grouped.items()]
runs.sort(key=run_sort_key)
return runs
def run(self, beer_run_id: str) -> Run | None:
docs = self.reading_docs_for_run(beer_run_id)
if not docs:
return None
return Run.from_docs(str(beer_run_id), docs)
def active_runs(self) -> list[Run]:
"""Runs with no final reading yet."""
return [r for r in self.runs() if r.is_active()]
def completed_runs(self) -> list[Run]:
return [r for r in self.runs() if not r.is_active()]
def upsert_reading(self, *, beer_run_id: str, beer_type: str, sg: int,
reading_date: str, final_reading: bool, comment: str) -> str:
"""Add a reading, or update the matching one if it already exists.
The unique key is ``(date, beer_run_id)`` which is what the original
application used, so re-submitting the same day updates in place
instead of creating a duplicate. Returns the ``_id`` of the document.
"""
query = {"date": reading_date, "beer_run_id": str(beer_run_id)}
update = {
"$set": {
"beer_run_id": str(beer_run_id),
"beer_type": beer_type,
"sg": str(sg),
"date": reading_date,
"final_reading": "True" if final_reading else "False",
"comment": comment,
}
}
result = self.readings.update_one(query, update, upsert=True)
if result.upserted_id is not None:
return str(result.upserted_id)
existing = self.readings.find_one(query)
return str(existing.get("_id")) if existing else ""
def set_final_reading(self, beer_run_id: str, reading_date: str,
final: bool) -> int:
"""Flag (or unflag) a reading as the final reading of its run."""
result = self.readings.update_many(
{"date": reading_date, "beer_run_id": str(beer_run_id)},
{"$set": {"final_reading": "True" if final else "False"}},
)
return result.modified_count
# -- run metadata ------------------------------------------------------- #
def run_metadata(self, beer_run_id: str) -> dict:
"""The stored metadata for a run, or an empty dict."""
doc = self.run_meta.find_one({"beer_run_id": str(beer_run_id)})
if not doc:
return {}
doc.pop("_id", None)
return doc
def all_run_metadata(self) -> dict[str, dict]:
"""All stored run metadata, keyed by ``beer_run_id``."""
result: dict[str, dict] = {}
for doc in self.run_meta.find():
doc.pop("_id", None)
result[str(doc.get("beer_run_id"))] = doc
return result
def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict:
"""Create or update the metadata document for a run.
Only the keys passed in are written; anything already stored is left
alone.
"""
payload = {k: v for k, v in fields.items() if v is not None}
payload["updated_at"] = _now_iso()
self.run_meta.update_one(
{"beer_run_id": str(beer_run_id)},
{"$set": payload, "$setOnInsert": {"created_at": _now_iso()}},
upsert=True,
)
return self.run_metadata(beer_run_id)
def delete_run_metadata(self, beer_run_id: str) -> bool:
"""Remove the metadata document for a run (readings are untouched)."""
return self.run_meta.delete_one({"beer_run_id": str(beer_run_id)}).deleted_count > 0
# -- helpers ------------------------------------------------------------ #
def next_run_id(self) -> str:
"""The next free ``beer_run_id``.
Run ids are plain integers in the existing data, so the next id is one
past the highest numeric id currently in use.
"""
highest = 0
for run in self.runs():
try:
highest = max(highest, int(run.beer_run_id))
except (TypeError, ValueError):
continue
return str(highest + 1)
# --------------------------------------------------------------------------- #
# Connection helpers
# --------------------------------------------------------------------------- #
def _build_client() -> MongoClient:
user = os.getenv("MONGO_USER", "root")
password = os.getenv("MONGO_PASS", "")
host = os.getenv("MONGO_HOST", "127.0.0.1")
uri = os.getenv("MONGO_URI") or f"mongodb://{user}:{password}@{host}/beer_db?authSource=admin"
return MongoClient(uri, serverSelectionTimeoutMS=5000)
def _hash_password(password: str) -> str:
"""Salted SHA-256 hex digest used for newly created users."""
import hashlib
import secrets
salt = os.getenv("BEER_PASSWORD_SALT", "beer_data")
return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def date_min():
from datetime import date
return date.min
def get_repository() -> BeerRepository:
"""Build a repository against the configured Mongo."""
return BeerRepository()
def is_alive(repo: BeerRepository) -> bool:
"""Cheap health check used by the status page."""
try:
repo.client.admin.command("ping")
return True
except PyMongoError:
return False