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).
208 lines
8.1 KiB
HTML
208 lines
8.1 KiB
HTML
{% extends 'base.html' %}
|
||
|
||
{% block title %}History — Brew Log{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="page__head">
|
||
<div>
|
||
<h1 class="page__title">History</h1>
|
||
<p class="page__sub">How every brew has gone, and what it turned out like.</p>
|
||
</div>
|
||
<div class="btn-row">
|
||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
|
||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Brew process</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid--4 mb-lg">
|
||
<div class="stat">
|
||
<div class="stat__label">Total brews</div>
|
||
<div class="stat__value">{{ stats.total_runs }}</div>
|
||
<div class="stat__meta">{{ stats.completed }} finished · {{ stats.active }} active</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat__label">Average ABV</div>
|
||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||
<div class="stat__meta">across finished brews</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat__label">Strongest</div>
|
||
<div class="stat__value">
|
||
{% if stats.best %}{{ '%.2f' % stats.best.abv }}%{% else %}—{% endif %}
|
||
</div>
|
||
<div class="stat__meta">
|
||
{% if stats.best %}{{ stats.best.name }}{% else %}—{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="stat__label">Lightest</div>
|
||
<div class="stat__value">
|
||
{% if stats.worst %}{{ '%.2f' % stats.worst.abv }}%{% else %}—{% endif %}
|
||
</div>
|
||
<div class="stat__meta">
|
||
{% if stats.worst %}{{ stats.worst.name }}{% else %}—{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid--2 mb-lg">
|
||
<section class="card">
|
||
<div class="card__head">
|
||
<h2 class="card__title">Alcohol by brew</h2>
|
||
<span class="card__hint">Standard formula (OG − FG) × 131.25</span>
|
||
</div>
|
||
<div class="card__body">
|
||
<canvas id="abv-chart" height="120"></canvas>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card__head">
|
||
<h2 class="card__title">Brews over time</h2>
|
||
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
|
||
</div>
|
||
<div class="card__body">
|
||
<canvas id="months-chart" height="120"></canvas>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{% if outliers %}
|
||
<section class="card mb-lg">
|
||
<div class="card__head">
|
||
<h2 class="card__title">Readings worth a second look</h2>
|
||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||
</div>
|
||
<div class="card__body card__body--flush">
|
||
<div class="table-wrap">
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th>Run</th>
|
||
<th>Name</th>
|
||
<th>Date</th>
|
||
<th class="num">Reading</th>
|
||
<th>Comment</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for row in outliers %}
|
||
<tr>
|
||
<td class="mono">{{ row.beer_run_id }}</td>
|
||
<td>{{ row.name }}</td>
|
||
<td class="mono nowrap">{{ row.date or '—' }}</td>
|
||
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
||
<td class="comment">{{ row.comment or '—' }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
|
||
<section class="card mb-lg">
|
||
<div class="card__head">
|
||
<h2 class="card__title">Every brew</h2>
|
||
<span class="card__hint">{{ summaries | length }} runs</span>
|
||
</div>
|
||
<div class="card__body card__body--flush">
|
||
<div class="table-wrap">
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th>Run</th>
|
||
<th>Name</th>
|
||
<th>Status</th>
|
||
<th class="num">OG</th>
|
||
<th class="num">FG</th>
|
||
<th class="num">Drop</th>
|
||
<th class="num">ABV</th>
|
||
<th class="num">Legacy</th>
|
||
<th>Finished</th>
|
||
<th>Comment</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for run in summaries %}
|
||
<tr>
|
||
<td class="mono">{{ run.beer_run_id }}</td>
|
||
<td>{{ run.canonical_name }}</td>
|
||
<td>
|
||
{% if run.active %}
|
||
<span class="chip chip--active">Active</span>
|
||
{% else %}
|
||
<span class="chip chip--done">Finished</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="num">{{ run.og_points or '—' }}</td>
|
||
<td class="num">{{ run.fg_points or '—' }}</td>
|
||
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
||
<td class="num">
|
||
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
||
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
||
{% else %}—{% endif %}
|
||
</td>
|
||
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
|
||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
||
<td class="comment">
|
||
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
|
||
</td>
|
||
<td class="right">
|
||
<a class="btn btn--ghost btn--sm"
|
||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||
</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card__head">
|
||
<h2 class="card__title">Standard vs legacy alcohol figures</h2>
|
||
<span class="card__hint">The old app used (max − min) ÷ 7.36 + 0.5, which is not a recognised formula</span>
|
||
</div>
|
||
<div class="card__body card__body--flush">
|
||
<div class="table-wrap">
|
||
<table class="data">
|
||
<thead>
|
||
<tr>
|
||
<th>Run</th>
|
||
<th>Name</th>
|
||
<th class="num">Standard ABV</th>
|
||
<th class="num">Legacy ABV</th>
|
||
<th class="num">Overstated by</th>
|
||
<th>Finished</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for row in abv_rows %}
|
||
<tr>
|
||
<td class="mono">{{ row.beer_run_id }}</td>
|
||
<td>{{ row.name }}</td>
|
||
<td class="num">{{ '%.2f' % row.abv }}%</td>
|
||
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
||
<td class="num">
|
||
{% if row.delta > 0 %}
|
||
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
||
{% else %}
|
||
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
||
{% endif %}
|
||
</td>
|
||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{% endblock %}
|
||
|
||
|