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,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
|
||||
|
||||
Reference in New Issue
Block a user