Files
beer_data/src/flask/templates/run_detail.html
T
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

168 lines
7.1 KiB
HTML

{% extends 'base.html' %}
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">
{{ summary.canonical_name }}
{% if summary.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</h1>
<p class="page__sub">
Run <span class="mono">{{ summary.beer_run_id }}</span>
{% if summary.first_date %} &middot; started {{ summary.first_date }}{% endif %}
{% if summary.final_date %} &middot; finished {{ summary.final_date }}{% endif %}
</p>
</div>
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a>
</div>
<div class="grid grid--sidebar mb-lg">
<!-- ------------------------------------------------- run metadata -->
<section class="card">
<div class="card__head">
<h2 class="card__title">Run details</h2>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label class="field__label" for="name">Beer / cider name</label>
<input type="text" id="name" name="name" maxlength="120"
value="{{ meta_form.name.data or '' }}">
<div class="field__hint">Overrides the drifting name on each reading.</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="tap_number">Tap number</label>
<input type="number" id="tap_number" name="tap_number"
min="1" max="99" step="1"
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
</div>
<div class="field">
<label class="field__label" for="og_points">Original gravity</label>
<input type="number" id="og_points" name="og_points"
min="900" max="1300" step="1"
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
<div class="field__hint">Points, e.g. 1045.</div>
</div>
</div>
<div class="field">
<label class="field__label" for="notes">Brew notes</label>
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
</div>
<button class="btn btn--block" type="submit">Save run details</button>
</form>
</div>
</section>
<div class="grid grid--4">
<div class="stat">
<div class="stat__label">Original gravity</div>
<div class="stat__value">
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.og_points is not none %}
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
</div>
<div class="stat__value">
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.fg_points is not none %}
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">Alcohol</div>
<div class="stat__value">
{% if summary.abv is not none %}
{{ '%.2f' % summary.abv }}%
{% elif summary.projected_abv is not none %}
{{ '%.2f' % summary.projected_abv }}%
{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if summary.abv is not none %}
measured
{% elif summary.projected_abv is not none %}
projected
{% else %}
needs 2nd reading
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Days brewing</div>
<div class="stat__value">
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
</div>
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
</div>
</div>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Gravity over time</h2>
<span class="card__hint">Standard ABV: (OG &minus; FG) &times; 131.25</span>
</div>
<div class="card__body">
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Readings</h2>
<span class="card__hint">{{ summary.reading_count }} total</span>
</div>
<div class="card__body">
<ul class="reading-list">
{% for reading in summary.readings %}
<li class="reading-list__item">
<span class="reading-list__date">{{ reading.date or '—' }}</span>
<span class="reading-list__sg">
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
</span>
<div class="reading-list__body">
{% if reading.comment %}
<div class="reading-list__comment">{{ reading.comment }}</div>
{% else %}
<div class="faint tiny">No comment</div>
{% endif %}
</div>
{% if reading.final_reading %}
<span class="chip chip--done">Final</span>
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
</form>
{% else %}
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}