Files
beer_data/src/flask/views/brews.py
T
hermes 2a9e0ad15e
Build and Push Image / Unit tests (push) Successful in 1m6s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
fix: log index failures, and tidy the CSP and the API error response
Round-2 review findings.

- `_ensure_indexes` swallowed a `PyMongoError` with a bare `pass`. An index
  failure does not stop the app serving, but it means the lookup is a
  collection scan, which gets slow as the data grows — so it is now logged
  as a warning with the traceback.

- The Content-Security-Policy string is now a module-level `CSP` constant
  rather than inline in the response handler, and it gained
  `object-src 'none'` (the app uses no plugins). The test now also asserts
  that neither `unsafe-inline` nor `unsafe-eval` appears — either would
  defeat the point of the policy.

- `api_run` returned a bare dict for its 404 while the success path used
  `jsonify`. It is now `jsonify({"error": "not found"})` for consistency.
2026-11-09 12:36:17 +10:00

237 lines
8.4 KiB
Python

"""The brew-process pages: record readings and review the runs."""
from __future__ import annotations
from flask import (Blueprint, flash, jsonify, redirect, render_template,
request, session, url_for)
from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from brewing import is_plausible_sg, run_summary
bp = Blueprint("brews", __name__)
def repo():
"""The request-scoped repository (one Mongo client per request)."""
from flask import current_app
return current_app.extensions["beer_repo"]()
def login_required(fn):
"""Redirect to the login page when there is no session."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("auth.login", next=request.path))
return fn(*args, **kwargs)
return wrapper
def _last_reading_date(run) -> str | None:
"""The ISO date of the most recent reading in a run."""
last = run.last_reading()
if last is None or last.date is None:
return None
return last.date.isoformat()
class ReadingForm(FlaskForm):
"""Add or update a gravity reading."""
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
beer_run_type = StringField("Beer / cider name",
validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()])
sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1500)])
final_reading = BooleanField("This is the final reading")
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
submit = SubmitField("Save reading")
class RunMetaForm(FlaskForm):
"""Edit the canonical metadata of a run.
These are the details that cannot be derived from the readings: the name
the brew is actually known by, which tap it is on, and its original
gravity.
"""
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
tap_number = IntegerField("Tap number",
validators=[Optional(), NumberRange(min=1, max=99)])
og_points = IntegerField("Original gravity (points)",
validators=[Optional(), NumberRange(min=900, max=1500)])
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
submit = SubmitField("Save run")
@bp.route("/brews", methods=["GET", "POST"])
@login_required
def index():
"""The brew-process page: record a reading, then review the runs."""
form = ReadingForm()
if form.validate_on_submit():
sg_points = int(form.sg.data)
if not is_plausible_sg(sg_points):
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
repo().upsert_reading(
beer_run_id=str(form.beer_run_id.data),
beer_type=(form.beer_run_type.data or "").strip(),
sg=sg_points,
reading_date=form.Date.data.isoformat(),
final_reading=bool(form.final_reading.data),
comment=(form.comment.data or "").strip(),
)
flash("Reading saved.", "success")
return redirect(url_for("brews.index"))
runs = repo().runs()
metadata = repo().all_run_metadata()
summaries = []
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["notes"] = meta.get("notes", "")
summary["last_date"] = _last_reading_date(run)
summaries.append(summary)
# Active runs: most recent activity first — that is what is fermenting now.
active = sorted([s for s in summaries if s["active"]],
key=lambda s: s["last_date"] or "", reverse=True)
# Finished runs: most recently finished first.
completed = sorted([s for s in summaries if not s["active"]],
key=lambda s: s["final_date"] or "", reverse=True)
return render_template(
"brews.html",
form=form,
summaries=summaries,
active_runs=active,
completed_runs=completed,
next_tap=repo().next_free_tap_number(),
)
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
@login_required
def run_detail(run_id: str):
"""One run in detail, with its readings and its editable metadata."""
run = repo().run(run_id)
if run is None:
flash(f"No run numbered {run_id}.", "error")
return redirect(url_for("brews.index"))
meta = repo().run_metadata(run_id)
meta_form = RunMetaForm()
if meta_form.validate_on_submit():
repo().upsert_run_metadata(
run_id,
name=(meta_form.name.data or "").strip() or None,
tap_number=meta_form.tap_number.data,
og_points=meta_form.og_points.data,
notes=(meta_form.notes.data or "").strip() or None,
)
flash("Run details saved.", "success")
return redirect(url_for("brews.run_detail", run_id=run_id))
if not meta_form.is_submitted():
meta_form.name.data = meta.get("name") or run.display_name()
meta_form.tap_number.data = meta.get("tap_number")
meta_form.og_points.data = meta.get("og_points")
meta_form.notes.data = meta.get("notes", "")
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["notes"] = meta.get("notes", "")
return render_template("run_detail.html", run=run, summary=summary,
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 jsonify({"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"])
@login_required
def mark_final(run_id: str):
"""Flag a reading as the final one for its run."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
changed = repo().set_final_reading(run_id, reading_date, True)
if changed:
flash("Final reading recorded.", "success")
else:
flash("That reading could not be updated.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
@login_required
def unfinal(run_id: str):
"""Un-flag a final reading, putting the run back into the active list."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
repo().set_final_reading(run_id, reading_date, False)
flash("Reading is no longer marked final.", "info")
return redirect(url_for("brews.run_detail", run_id=run_id))