hermes bf0f88dd70 fix: build the image on Python 3.14, and fix the review findings
The image build failed on master:

    ERROR: Ignored the following versions that require a different python
    version: 4.19.0 Requires-Python >=3.11
    ERROR: Could not find a version that satisfies the requirement
    pymongo==4.19.0

The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.

All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.

The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.

Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
  the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
  a strict `default-src 'self'` policy is enforceable. Adding it required
  removing the inline `style=` attributes from the templates (they became
  `.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
  id now travels as a `data-run-id` attribute). A test asserts no template
  has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
  a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
  (a 1.150 wort is real) can be recorded.

Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter — its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
  code behind. It is restored.

Tests: 105 (up from 91).
2026-11-09 12:36:17 +10:00
2024-09-07 23:24:32 +10:00
2024-09-07 23:24:32 +10:00
2024-09-07 23:24:32 +10:00
2024-09-07 23:24:32 +10:00
2024-09-07 23:24:32 +10:00

Brew Log

Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into useful figures — alcohol by volume, days in the fermenter, gravity trends.

Flask app, Mongo backend, no build step for the front end.

Running it locally

python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt

export MONGO_HOST=192.168.178.229
export MONGO_USER=root
export MONGO_PASS=...
export SECRET_KEY=anything-for-local-use

cd src/flask
python -m beer_data            # dev server on :5000
# or, production-style:
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app

Then open http://localhost:5000/ and sign in.

Tests

. .venv/bin/activate
python -m pytest tests/ -q

The tests use mongomock, so they need no database and no network.

The pages

Page What it is for
/login Sign in
/brews The brew process: record a reading, review every run
/active What is in the fermenter right now — tap, gravity, projected strength
/history How every brew has gone, with charts and the standard-vs-legacy ABV comparison

/active — the active beers page

A card per brew that is still in the fermenter (a run with no reading marked final), showing:

  • the tap number it is on,
  • the current gravity, as a decimal reading with the point scale beneath,
  • the projected alcohol, worked out from the run's original gravity,
  • how many days it has been brewing,
  • a sparkline of the gravity so far,
  • 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 on the run (or take a second reading) and it appears.

Run metadata

Some details cannot be derived from the readings, so they are stored per run in the beer_runs collection and edited on the run detail page:

  • the canonical name the brew is known by (overriding the drifting per-reading name),
  • the tap number,
  • the original gravity,
  • free-text brew notes.

Populating the run metadata from the existing readings

# dry run - prints what it would do, writes nothing
python scripts/backfill_run_metadata.py

# write it
python scripts/backfill_run_metadata.py --apply

The script is idempotent and non-destructive: it only inserts into beer_runs, never touches beer_data, and never overwrites a name you have already set by hand (pass --force if you want it to).

Layout

src/flask/
  beer_data.py       entry point; exposes `app` and `create_app`
  app_factory.py     application factory, request-scoped Mongo repository
  brewing.py         pure domain logic — gravity parsing, ABV, run aggregation
  views/             blueprints: auth, brews, analysis
  mongo/repository.py Mongo access for the beer_data and beer_runs collections
  templates/         Jinja templates
  static/            CSS, JS, fonts, logo (all served locally — no CDN)
  static/            CSS, JS, fonts, logo (all served locally - no CDN)
tests/               pytest suite
scripts/             operational scripts (the run-metadata backfill)

brewing.py holds every piece of arithmetic and has no Flask or Mongo dependency, so the maths is unit-testable on its own.

Data model

beer_data — one document per gravity reading

Field Type Notes
_id string 24 random characters
beer_run_id string The run this reading belongs to, e.g. "20"
beer_type string Beer/cider name. Free text, per reading — it drifts across a run
sg string Specific gravity in points: 1039 means 1.039
date string YYYY-MM-DD
final_reading string "True" / "False" — marks the last reading of a run
comment string Free text

The unique key is (date, beer_run_id): re-submitting a reading for the same run and day updates it in place rather than creating a duplicate.

A run is active while it has no reading with final_reading == "True".

beer_runs — one document per run (metadata)

Added alongside the readings to hold what cannot be derived from them: the canonical beer name, the tap number, the recorded original gravity, and brew notes. This collection is only ever inserted into, never written over by the backfill.

Alcohol by volume

Standard homebrew formula:

ABV% = (OG - FG) × 131.25

With gravity stored in points, the drop is already in points, so ABV% = points_drop × 0.13125.

The previous version of this app used (max - min) / 7.36 + 0.5, which is not a recognised brewing formula and overstated every result. The old figure is still computed by brewing.legacy_abv_percent() so the history page can show the difference between the two.

For a run still in the fermenter the figure is an estimate: it uses the run's first reading as the original gravity. A run with a single reading and no recorded OG has no reliable estimate, and the UI shows a dash rather than a misleading number.

Deployment

The image is built and pushed by .gitea/workflows/build_push.yml on push to master. See RUNBOOK.md for the cluster side.

S
Description
No description provided
Readme
1.2 MiB
Languages
Python 64.3%
HTML 14.5%
JavaScript 11.4%
CSS 9%
Dockerfile 0.7%