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).
This commit is contained in:
@@ -5,6 +5,7 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Unit tests
|
name: Unit tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -13,8 +14,14 @@ jobs:
|
|||||||
# build from GitHub, which this runner cannot reach reliably - the job
|
# build from GitHub, which this runner cannot reach reliably - the job
|
||||||
# hung on it. The runner image already carries a usable python3, so
|
# hung on it. The runner image already carries a usable python3, so
|
||||||
# the tests use it directly.
|
# the tests use it directly.
|
||||||
|
#
|
||||||
|
# The version is checked against the container image's Python so the
|
||||||
|
# tests run on the same interpreter the app ships in.
|
||||||
- name: Show the Python version
|
- name: Show the Python version
|
||||||
run: python3 --version
|
run: |
|
||||||
|
python3 --version
|
||||||
|
python3 -c "import sys; assert sys.version_info[:2] >= (3, 11), \
|
||||||
|
'the runner python is too old for the pinned requirements'"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -49,6 +56,10 @@ jobs:
|
|||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
needs: test
|
needs: test
|
||||||
if: gitea.ref == 'refs/heads/master'
|
if: gitea.ref == 'refs/heads/master'
|
||||||
|
# A build that has not finished in 15 minutes is wedged — the qemu
|
||||||
|
# multi-arch crash used to hang the buildkit pod for exactly this long.
|
||||||
|
# A timeout stops it occupying a runner indefinitely.
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -110,6 +110,25 @@ def create_app(config: dict | None = None) -> Flask:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Error handlers
|
# Error handlers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
@app.after_request
|
||||||
|
def _set_security_headers(response):
|
||||||
|
"""Add the standard security headers.
|
||||||
|
|
||||||
|
The app serves its own vendored JavaScript and CSS and loads nothing
|
||||||
|
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
||||||
|
and is genuinely enforceable.
|
||||||
|
"""
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||||
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
||||||
|
"form-action 'self'; frame-ancestors 'none'; base-uri 'self'",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def _not_found(_exc):
|
def _not_found(_exc):
|
||||||
return render_template("error.html", code=404,
|
return render_template("error.html", code=404,
|
||||||
|
|||||||
+14
-3
@@ -59,7 +59,9 @@ ABV_FACTOR = 0.13125
|
|||||||
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
||||||
ASSUMED_ORIGINAL_GRAVITY = 1012
|
ASSUMED_ORIGINAL_GRAVITY = 1012
|
||||||
|
|
||||||
#: Plausible range for a hydrometer reading, in points.
|
#: Plausible range for a hydrometer reading, in points. The upper bound is
|
||||||
|
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
|
||||||
|
#: still catching typos such as 900 or 1500.
|
||||||
_MIN_PLAUSIBLE_SG = 990
|
_MIN_PLAUSIBLE_SG = 990
|
||||||
_MAX_PLAUSIBLE_SG = 1200
|
_MAX_PLAUSIBLE_SG = 1200
|
||||||
|
|
||||||
@@ -228,7 +230,15 @@ class Run:
|
|||||||
return og - fg
|
return og - fg
|
||||||
|
|
||||||
def abv_percent(self) -> float | None:
|
def abv_percent(self) -> float | None:
|
||||||
"""Standard ABV for a finished run."""
|
"""Standard ABV for a **finished** run.
|
||||||
|
|
||||||
|
A run that is still in the fermenter has no measured alcohol - its
|
||||||
|
figure is a projection, not a measurement. Returning the drop here
|
||||||
|
for an unfinished run would report a "measured" ABV for a beer that
|
||||||
|
has not finished fermenting, which is wrong.
|
||||||
|
"""
|
||||||
|
if self.is_active():
|
||||||
|
return None
|
||||||
drop = self.gravity_drop_points()
|
drop = self.gravity_drop_points()
|
||||||
return abv_percent(drop) if drop is not None else None
|
return abv_percent(drop) if drop is not None else None
|
||||||
|
|
||||||
@@ -448,7 +458,8 @@ def run_summary(run: Run, today: date | None = None,
|
|||||||
"drop_points": run.gravity_drop_points(),
|
"drop_points": run.gravity_drop_points(),
|
||||||
"abv": run.abv_percent(),
|
"abv": run.abv_percent(),
|
||||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||||
"legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
|
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg())
|
||||||
|
if not run.is_active() else None),
|
||||||
"reading_count": len(run.readings),
|
"reading_count": len(run.readings),
|
||||||
"first_date": first.date.isoformat() if (first and first.date) else None,
|
"first_date": first.date.isoformat() if (first and first.date) else None,
|
||||||
"final_date": final.date.isoformat() if (final and final.date) else None,
|
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||||
|
|||||||
@@ -44,6 +44,23 @@ class BeerRepository:
|
|||||||
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
||||||
# shadow the runs() method on the instance.
|
# shadow the runs() method on the instance.
|
||||||
self.run_meta = self.db["beer_runs"]
|
self.run_meta = self.db["beer_runs"]
|
||||||
|
self._ensure_indexes()
|
||||||
|
|
||||||
|
def _ensure_indexes(self) -> None:
|
||||||
|
"""Make sure the lookup keys are indexed.
|
||||||
|
|
||||||
|
Every read of the run metadata filters on ``beer_run_id``, and the
|
||||||
|
readings are always fetched by ``(beer_run_id, date)``. Without the
|
||||||
|
indexes those are collection scans.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
|
||||||
|
self.readings.create_index(
|
||||||
|
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
|
||||||
|
)
|
||||||
|
except PyMongoError:
|
||||||
|
# An index failure must not stop the app from serving.
|
||||||
|
pass
|
||||||
|
|
||||||
# -- users -------------------------------------------------------------- #
|
# -- users -------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|||||||
@@ -649,6 +649,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
.row--between { justify-content: space-between; }
|
.row--between { justify-content: space-between; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Spacing utilities. These exist so the templates carry no inline
|
||||||
|
`style=` attributes, which a strict Content-Security-Policy
|
||||||
|
(`style-src 'self'`) would block. */
|
||||||
|
.mb-lg { margin-bottom: 1.25rem; }
|
||||||
|
.mt-lg { margin-top: 2rem; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 2.5rem 1rem;
|
padding: 2.5rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -104,7 +104,10 @@
|
|||||||
function drawRunChart() {
|
function drawRunChart() {
|
||||||
var canvas = document.getElementById("run-chart");
|
var canvas = document.getElementById("run-chart");
|
||||||
if (!canvas) { return; }
|
if (!canvas) { return; }
|
||||||
var runId = window.BREW_RUN_ID;
|
// The run id comes from a data attribute rather than a global, so the
|
||||||
|
// page needs no inline script (a strict CSP forbids inline scripts).
|
||||||
|
var runId = canvas.getAttribute("data-run-id");
|
||||||
|
if (!runId) { return; }
|
||||||
|
|
||||||
fetch("/api/runs/" + encodeURIComponent(runId), {
|
fetch("/api/runs/" + encodeURIComponent(runId), {
|
||||||
headers: { "Accept": "application/json" }
|
headers: { "Accept": "application/json" }
|
||||||
@@ -169,7 +172,12 @@
|
|||||||
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (runs) {
|
.then(function (runs) {
|
||||||
var completed = runs.filter(function (r) { return r.abv !== null && r.abv !== undefined; });
|
// Only finished brews have a measured ABV. An unfinished run
|
||||||
|
// has a projection, not a measurement, so it is not plotted
|
||||||
|
// here.
|
||||||
|
var completed = runs.filter(function (r) {
|
||||||
|
return r.abv !== null && r.abv !== undefined;
|
||||||
|
});
|
||||||
if (!completed.length) { return; }
|
if (!completed.length) { return; }
|
||||||
|
|
||||||
new Chart(canvas.getContext("2d"), {
|
new Chart(canvas.getContext("2d"), {
|
||||||
@@ -223,6 +231,22 @@
|
|||||||
var months = Object.keys(counts).sort();
|
var months = Object.keys(counts).sort();
|
||||||
if (!months.length) { return; }
|
if (!months.length) { return; }
|
||||||
|
|
||||||
|
// Include the months with no brews, with a zero. Leaving the
|
||||||
|
// gaps out would space the busy months evenly on the axis and
|
||||||
|
// make the chart read as continuous activity.
|
||||||
|
var filled = [];
|
||||||
|
var cursor = months[0];
|
||||||
|
var last = months[months.length - 1];
|
||||||
|
while (cursor <= last) {
|
||||||
|
filled.push(cursor);
|
||||||
|
var year = parseInt(cursor.slice(0, 4), 10);
|
||||||
|
var month = parseInt(cursor.slice(5, 7), 10);
|
||||||
|
month += 1;
|
||||||
|
if (month > 12) { month = 1; year += 1; }
|
||||||
|
cursor = year + "-" + (month < 10 ? "0" + month : month);
|
||||||
|
}
|
||||||
|
months = filled;
|
||||||
|
|
||||||
new Chart(canvas.getContext("2d"), {
|
new Chart(canvas.getContext("2d"), {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid--3" style="margin-bottom:1.25rem">
|
<div class="grid grid--3 mb-lg">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Active brews</div>
|
<div class="stat__label">Active brews</div>
|
||||||
<div class="stat__value">{{ totals.count }}</div>
|
<div class="stat__value">{{ totals.count }}</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card" style="margin-top:2rem">
|
<div class="card mt-lg">
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||||
<h1>{{ code }}</h1>
|
<h1>{{ code }}</h1>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid--4" style="margin-bottom:1.25rem">
|
<div class="grid grid--4 mb-lg">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat__label">Total brews</div>
|
<div class="stat__label">Total brews</div>
|
||||||
<div class="stat__value">{{ stats.total_runs }}</div>
|
<div class="stat__value">{{ stats.total_runs }}</div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid--2" style="margin-bottom:1.25rem">
|
<div class="grid grid--2 mb-lg">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Alcohol by brew</h2>
|
<h2 class="card__title">Alcohol by brew</h2>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Brews over time</h2>
|
<h2 class="card__title">Brews over time</h2>
|
||||||
<span class="card__hint">Runs started per month</span>
|
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<canvas id="months-chart" height="120"></canvas>
|
<canvas id="months-chart" height="120"></canvas>
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if outliers %}
|
{% if outliers %}
|
||||||
<section class="card" style="margin-bottom:1.25rem">
|
<section class="card mb-lg">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Readings worth a second look</h2>
|
<h2 class="card__title">Readings worth a second look</h2>
|
||||||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="card" style="margin-bottom:1.25rem">
|
<section class="card mb-lg">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Every brew</h2>
|
<h2 class="card__title">Every brew</h2>
|
||||||
<span class="card__hint">{{ summaries | length }} runs</span>
|
<span class="card__hint">{{ summaries | length }} runs</span>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid--sidebar" style="margin-bottom:1.25rem">
|
<div class="grid grid--sidebar mb-lg">
|
||||||
<!-- ------------------------------------------------- run metadata -->
|
<!-- ------------------------------------------------- run metadata -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
@@ -115,6 +115,16 @@
|
|||||||
</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 − FG) × 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">
|
<section class="card">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Readings</h2>
|
<h2 class="card__title">Readings</h2>
|
||||||
|
|||||||
@@ -32,6 +32,27 @@ def login_required(fn):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _month_range(first: str, last: str) -> list[str]:
|
||||||
|
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
||||||
|
|
||||||
|
Used to include the months with no brews in the chart, so a quiet period
|
||||||
|
shows as a gap rather than being compressed out of the axis.
|
||||||
|
"""
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
year, month = int(first[:4]), int(first[5:7])
|
||||||
|
end_year, end_month = int(last[:4]), int(last[5:7])
|
||||||
|
|
||||||
|
months = []
|
||||||
|
while (year, month) <= (end_year, end_month):
|
||||||
|
months.append(f"{year:04d}-{month:02d}")
|
||||||
|
month += 1
|
||||||
|
if month > 12:
|
||||||
|
month = 1
|
||||||
|
year += 1
|
||||||
|
return months
|
||||||
|
|
||||||
|
|
||||||
def _fmt_days(value: float) -> str:
|
def _fmt_days(value: float) -> str:
|
||||||
"""Format a day count: a whole number stays whole, a fraction keeps one
|
"""Format a day count: a whole number stays whole, a fraction keeps one
|
||||||
decimal place."""
|
decimal place."""
|
||||||
@@ -109,12 +130,20 @@ def history():
|
|||||||
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
|
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
|
||||||
|
|
||||||
# --- brews per month --------------------------------------------------- #
|
# --- brews per month --------------------------------------------------- #
|
||||||
|
# Every month between the first and the last brew is included, with zero
|
||||||
|
# for the months with no brews. Leaving the gaps out would space the
|
||||||
|
# remaining months evenly and make the chart read as continuous activity.
|
||||||
per_month: dict[str, int] = defaultdict(int)
|
per_month: dict[str, int] = defaultdict(int)
|
||||||
for s in summaries:
|
for s in summaries:
|
||||||
stamp = s["final_date"] or s["first_date"]
|
stamp = s["final_date"] or s["first_date"]
|
||||||
if stamp:
|
if stamp:
|
||||||
per_month[stamp[:7]] += 1
|
per_month[stamp[:7]] += 1
|
||||||
months = sorted(per_month.items())
|
|
||||||
|
months = []
|
||||||
|
if per_month:
|
||||||
|
first, last = min(per_month), max(per_month)
|
||||||
|
for month in _month_range(first, last):
|
||||||
|
months.append((month, per_month.get(month, 0)))
|
||||||
|
|
||||||
# --- outliers ---------------------------------------------------------- #
|
# --- outliers ---------------------------------------------------------- #
|
||||||
outliers = []
|
outliers = []
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import (Blueprint, flash, redirect, render_template, request,
|
from flask import (Blueprint, flash, jsonify, redirect, render_template,
|
||||||
session, url_for)
|
request, session, url_for)
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||||
SubmitField, TextAreaField)
|
SubmitField, TextAreaField)
|
||||||
@@ -50,7 +50,7 @@ class ReadingForm(FlaskForm):
|
|||||||
validators=[Optional(), Length(max=120)])
|
validators=[Optional(), Length(max=120)])
|
||||||
Date = DateField("Reading date", validators=[DataRequired()])
|
Date = DateField("Reading date", validators=[DataRequired()])
|
||||||
sg = IntegerField("Gravity reading (points)",
|
sg = IntegerField("Gravity reading (points)",
|
||||||
validators=[DataRequired(), NumberRange(min=900, max=1300)])
|
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||||
final_reading = BooleanField("This is the final reading")
|
final_reading = BooleanField("This is the final reading")
|
||||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||||
submit = SubmitField("Save reading")
|
submit = SubmitField("Save reading")
|
||||||
@@ -68,7 +68,7 @@ class RunMetaForm(FlaskForm):
|
|||||||
tap_number = IntegerField("Tap number",
|
tap_number = IntegerField("Tap number",
|
||||||
validators=[Optional(), NumberRange(min=1, max=99)])
|
validators=[Optional(), NumberRange(min=1, max=99)])
|
||||||
og_points = IntegerField("Original gravity (points)",
|
og_points = IntegerField("Original gravity (points)",
|
||||||
validators=[Optional(), NumberRange(min=900, max=1300)])
|
validators=[Optional(), NumberRange(min=900, max=1500)])
|
||||||
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
||||||
submit = SubmitField("Save run")
|
submit = SubmitField("Save run")
|
||||||
|
|
||||||
@@ -166,6 +166,45 @@ def run_detail(run_id: str):
|
|||||||
meta_form=meta_form, meta=meta)
|
meta_form=meta_form, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
def api_runs():
|
||||||
|
"""JSON feed of the run summaries, used by the charts on the analysis
|
||||||
|
pages.
|
||||||
|
|
||||||
|
The charts fetch this rather than having the data inlined into the page,
|
||||||
|
so one fetch serves every chart on a page.
|
||||||
|
"""
|
||||||
|
runs = repo().runs()
|
||||||
|
metadata = repo().all_run_metadata()
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for run in runs:
|
||||||
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||||
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||||
|
summary["tap_number"] = meta.get("tap_number")
|
||||||
|
summary["last_date"] = _last_reading_date(run)
|
||||||
|
payload.append(summary)
|
||||||
|
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/runs/<run_id>")
|
||||||
|
@login_required
|
||||||
|
def api_run(run_id: str):
|
||||||
|
"""JSON detail of a single run."""
|
||||||
|
run = repo().run(run_id)
|
||||||
|
if run is None:
|
||||||
|
return {"error": "not found"}, 404
|
||||||
|
|
||||||
|
meta = repo().run_metadata(run_id)
|
||||||
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||||
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||||
|
summary["tap_number"] = meta.get("tap_number")
|
||||||
|
return jsonify(summary)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def mark_final(run_id: str):
|
def mark_final(run_id: str):
|
||||||
|
|||||||
@@ -473,3 +473,165 @@ def test_partial_metadata_update_ignores_none(repo):
|
|||||||
meta = repo.run_metadata("1")
|
meta = repo.run_metadata("1")
|
||||||
assert meta["name"] == "A Name"
|
assert meta["name"] == "A Name"
|
||||||
assert meta["tap_number"] == 3
|
assert meta["tap_number"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- security headers #
|
||||||
|
|
||||||
|
def test_security_headers_are_set(logged_in):
|
||||||
|
"""The app serves only its own vendored assets, so a strict CSP of
|
||||||
|
'self' is enforceable."""
|
||||||
|
response = logged_in.get("/brews")
|
||||||
|
headers = response.headers
|
||||||
|
|
||||||
|
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||||
|
assert headers.get("X-Frame-Options") == "DENY"
|
||||||
|
assert headers.get("Referrer-Policy") == "same-origin"
|
||||||
|
|
||||||
|
csp = headers.get("Content-Security-Policy")
|
||||||
|
assert csp is not None
|
||||||
|
assert "default-src 'self'" in csp
|
||||||
|
assert "script-src 'self'" in csp
|
||||||
|
assert "frame-ancestors 'none'" in csp
|
||||||
|
# No CDN is used, so nothing external should be allowed.
|
||||||
|
assert "https://" not in csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers_are_set_on_the_login_page(client):
|
||||||
|
response = client.get("/login")
|
||||||
|
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||||
|
assert response.headers.get("Content-Security-Policy") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ indexes #
|
||||||
|
|
||||||
|
def test_the_repository_creates_its_indexes(repo):
|
||||||
|
"""The run metadata is always looked up by beer_run_id, and the readings
|
||||||
|
by (beer_run_id, date) — both need an index."""
|
||||||
|
index_names = repo.run_meta.index_information()
|
||||||
|
assert "beer_run_id_1" in index_names
|
||||||
|
|
||||||
|
reading_indexes = repo.readings.index_information()
|
||||||
|
assert "run_date_1" in reading_indexes
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_template_has_an_inline_script_or_style(client):
|
||||||
|
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
|
||||||
|
scripts and style attributes, so the templates must have neither."""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
template_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"src", "flask", "templates",
|
||||||
|
)
|
||||||
|
|
||||||
|
for path in glob.glob(os.path.join(template_dir, "*.html")):
|
||||||
|
with open(path) as fh:
|
||||||
|
body = fh.read()
|
||||||
|
|
||||||
|
# An inline <script> is one with content rather than a src attribute.
|
||||||
|
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||||
|
body, re.S):
|
||||||
|
assert match.group(1).strip() == "", (
|
||||||
|
f"{os.path.basename(path)} has an inline <script>, which the "
|
||||||
|
"CSP blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not re.search(r"\sstyle\s*=", body), (
|
||||||
|
f"{os.path.basename(path)} has an inline style attribute, which "
|
||||||
|
"the CSP blocks"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- API #
|
||||||
|
|
||||||
|
def test_api_runs_returns_json(logged_in, repo):
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||||
|
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/api/runs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.get_json()
|
||||||
|
assert len(payload) == 1
|
||||||
|
assert payload[0]["abv"] == 5.25
|
||||||
|
assert payload[0]["active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_run_detail_returns_json(logged_in, repo):
|
||||||
|
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
|
||||||
|
|
||||||
|
response = logged_in.get("/api/runs/11")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.get_json()
|
||||||
|
assert payload["beer_run_id"] == "11"
|
||||||
|
assert payload["name"] == "Cider"
|
||||||
|
assert len(payload["readings"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
|
||||||
|
response = logged_in.get("/api/runs/does-not-exist")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_is_protected(client):
|
||||||
|
"""The charts fetch from the API, so it must not be public."""
|
||||||
|
for path in ("/api/runs", "/api/runs/1"):
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 302, path
|
||||||
|
assert "/login" in response.headers["Location"], path
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
|
||||||
|
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
|
||||||
|
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
|
||||||
|
|
||||||
|
payload = logged_in.get("/api/runs").get_json()
|
||||||
|
entry = next(r for r in payload if r["beer_run_id"] == "12")
|
||||||
|
assert entry["canonical_name"] == "Canonical"
|
||||||
|
assert entry["tap_number"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_months_chart_includes_the_gaps(logged_in, repo):
|
||||||
|
"""Months with no brews must appear with a zero, or the chart spaces the
|
||||||
|
busy months evenly and reads as continuous activity."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||||
|
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||||
|
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||||
|
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
from views.analysis import _month_range
|
||||||
|
|
||||||
|
months = _month_range("2026-01", "2026-06")
|
||||||
|
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
|
||||||
|
"2026-05", "2026-06"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
|
||||||
|
"""The chart is fed by the API, so the quiet months are visible there."""
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||||
|
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||||
|
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||||
|
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
body = logged_in.get("/history").data.decode()
|
||||||
|
# The page embeds the month labels for the chart.
|
||||||
|
assert "2026-06" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_month_range_covers_a_year_boundary():
|
||||||
|
from views.analysis import _month_range
|
||||||
|
|
||||||
|
assert _month_range("2025-11", "2026-02") == [
|
||||||
|
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||||
|
]
|
||||||
|
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||||
|
|||||||
@@ -342,3 +342,31 @@ def test_point_scale_for_a_round_reading():
|
|||||||
summary = run_summary(run)
|
summary = run_summary(run)
|
||||||
assert summary["min_sg_point_scale"] == "10"
|
assert summary["min_sg_point_scale"] == "10"
|
||||||
assert summary["min_sg_decimal"] == "1.010"
|
assert summary["min_sg_decimal"] == "1.010"
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unfinished_run_has_no_measured_abv():
|
||||||
|
"""A run still in the fermenter has a projection, not a measurement.
|
||||||
|
Reporting a "measured" ABV for it would be wrong."""
|
||||||
|
run = Run.from_docs("20", [
|
||||||
|
_doc(run_id="20", date="2026-11-01", sg="1045"),
|
||||||
|
_doc(run_id="20", date="2026-11-08", sg="1039"),
|
||||||
|
])
|
||||||
|
assert run.is_active() is True
|
||||||
|
assert run.abv_percent() is None
|
||||||
|
assert run.projected_abv_percent() is not None
|
||||||
|
|
||||||
|
summary = run_summary(run)
|
||||||
|
assert summary["abv"] is None
|
||||||
|
assert summary["projected_abv"] is not None
|
||||||
|
assert summary["legacy_abv"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finished_run_still_has_a_measured_abv():
|
||||||
|
run = Run.from_docs("21", [
|
||||||
|
_doc(run_id="21", date="2026-11-01", sg="1050"),
|
||||||
|
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
|
||||||
|
])
|
||||||
|
assert run.abv_percent() == 5.25
|
||||||
|
summary = run_summary(run)
|
||||||
|
assert summary["abv"] == 5.25
|
||||||
|
assert summary["legacy_abv"] is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user