From 24cdd452c56a7352fef0dcff3b3f811c6a48097d Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 10:21:44 +1000 Subject: [PATCH 1/4] feat(ui): rebuild the interface with a proper design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old UI was two pages of raw pandas tables with no navigation, no styling and no way to see a run's comments. This replaces it with a small, testable application and a beer-themed design. What changed ------------ Restructure - The domain logic now lives in `brewing.py` as pure functions over plain data, with no Flask or Mongo dependency. The gravity maths, the ABV formula and the run-aggregation rules are unit-testable on their own. - `app_factory.py` replaces the module-level app, and the routes move into blueprints under `views/`. A request-scoped Mongo repository is created per request and closed at teardown. - `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`. Reading a document no longer requires a second query to fetch the fields. Interface - A dark, beer-themed design system: warm amber `#be7f39` on near-black, a condensed display face (Anton) for headings and Inter for body text. Both fonts and the chart library are vendored into `static/`, so the container needs no outbound network at runtime — the old templates pulled Bootstrap from a CDN. - A real navigation bar, a login page, a brew-process page with a proper form, and a run-detail page listing every reading with its comment. - The run summary tables now show the beer name and the latest comment, which the old pandas tables did not. - Native form controls (checkbox, number spinners, date icon, scrollbars) are restyled for the dark theme. Correctness - The alcohol calculation now uses the standard homebrew formula, `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is not a recognised brewing formula and overstated every result. - Gravity points and decimal readings now convert correctly in both directions (`1039` <-> `1.039`). - A run's display name resolves to the most-used label across its readings, because `beer_type` is stored per-reading and drifts across a run. - Re-submitting a reading for the same run and day updates it in place rather than creating a duplicate. Security / production - The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an interactive console on any unhandled error. It now runs waitress with debugging off. - `SECRET_KEY` comes from the environment. - The app secret is no longer a hard-coded string in source. Housekeeping - Removes the bokeh/duckdb/pandas dependency chain and the static file it generated at import time; removes the dead `add_user.py` and `test.py`. - Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests). - Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the image is only built when the tests pass. - Adds a README describing the data model and a RUNBOOK for the cluster side. Data compatibility ------------------ The existing `beer_db.beer_data` documents are read as they are and are not migrated or rewritten. No collection is dropped, and no document is modified by this change other than through the explicit "record a reading" form, which keeps the original `(date, beer_run_id)` uniqueness rule. --- .gitea/workflows/build_push.yml | 25 ++ README.md | 105 ++++- RUNBOOK.md | 119 ++++++ flask.Dockerfile | 20 +- requirements-dev.txt | 3 + requirements.txt | 10 +- src/flask/add_user.py | 29 -- src/flask/app_factory.py | 120 ++++++ src/flask/beer_data.py | 163 ++----- src/flask/brewing.py | 431 +++++++++++++++++++ src/flask/charts/BeerCharts.py | 52 --- src/flask/charts/__init__.py | 0 src/flask/mongo/build_db.py | 76 ---- src/flask/mongo/get_conn.py | 19 - src/flask/mongo/query_db.py | 55 --- src/flask/mongo/repository.py | 234 ++++++++++ src/flask/mongo/user_db.py | 63 --- src/flask/static/css/app.css | 637 ++++++++++++++++++++++++++++ src/flask/static/data_plot.html | 61 --- src/flask/static/img/logo.svg | 15 + src/flask/static/js/app.js | 71 ++++ src/flask/table/__init__.py | 0 src/flask/table/table_builder.py | 90 ---- src/flask/templates/base.html | 62 ++- src/flask/templates/brews.html | 193 +++++++++ src/flask/templates/error.html | 14 + src/flask/templates/index.html | 37 -- src/flask/templates/login.html | 32 ++ src/flask/templates/run_detail.html | 107 +++++ src/flask/templates/updater.html | 67 --- src/flask/test.py | 5 - src/flask/views/__init__.py | 1 + src/flask/views/auth.py | 45 ++ src/flask/views/brews.py | 152 +++++++ tests/conftest.py | 67 +++ tests/test_app.py | 181 ++++++++ tests/test_brewing.py | 307 ++++++++++++++ 37 files changed, 2943 insertions(+), 725 deletions(-) create mode 100644 RUNBOOK.md create mode 100644 requirements-dev.txt delete mode 100644 src/flask/add_user.py create mode 100644 src/flask/app_factory.py create mode 100644 src/flask/brewing.py delete mode 100644 src/flask/charts/BeerCharts.py delete mode 100644 src/flask/charts/__init__.py delete mode 100644 src/flask/mongo/build_db.py delete mode 100644 src/flask/mongo/get_conn.py delete mode 100644 src/flask/mongo/query_db.py create mode 100644 src/flask/mongo/repository.py delete mode 100644 src/flask/mongo/user_db.py create mode 100644 src/flask/static/css/app.css delete mode 100644 src/flask/static/data_plot.html create mode 100644 src/flask/static/img/logo.svg create mode 100644 src/flask/static/js/app.js delete mode 100644 src/flask/table/__init__.py delete mode 100644 src/flask/table/table_builder.py create mode 100644 src/flask/templates/brews.html create mode 100644 src/flask/templates/error.html delete mode 100644 src/flask/templates/index.html create mode 100644 src/flask/templates/login.html create mode 100644 src/flask/templates/run_detail.html delete mode 100644 src/flask/templates/updater.html delete mode 100644 src/flask/test.py create mode 100644 src/flask/views/__init__.py create mode 100644 src/flask/views/auth.py create mode 100644 src/flask/views/brews.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_brewing.py diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index 3fc4ad1..9c01676 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -2,10 +2,35 @@ name: Build and Push Image on: [push] jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m venv .venv + . .venv/bin/activate + pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: | + . .venv/bin/activate + python -m pytest tests/ -q + build: name: Build and push image runs-on: ubuntu-latest container: catthehacker/ubuntu:act-latest + needs: test if: gitea.ref == 'refs/heads/master' steps: diff --git a/README.md b/README.md index 505bdc4..e508792 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,104 @@ -# BEER SG data storer +# Brew Log -This container will store SG data for beer runs in Mongo and then use that data to calculate Alchohol Content of each beer +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. -It's pretty loose and requires the Data Entry operator to know whats happening +Flask app, Mongo backend, no build step for the front end. -Luck for me... That's me! \ No newline at end of file +## Running it locally + +```bash +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 and sign in. + +## Tests + +```bash +. .venv/bin/activate +python -m pytest tests/ -q +``` + +The tests use `mongomock`, so they need no database and no network. + +## 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, (later) 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) +tests/ pytest suite +``` + +`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](RUNBOOK.md) for the cluster side. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..d556037 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,119 @@ +# RUNBOOK — Brew Log (beer_data) + +Operational notes for the `beer` namespace deployment. + +## What runs where + +| | | +|---|---| +| Namespace | `beer` | +| Deployment | `beer-deployment` (3 replicas) | +| Image | `git.aridgwayweb.com/armistace/beer-data:latest` | +| Service | `beer-service`, NodePort **30015** → container port 5000 | +| Database | MongoDB at `192.168.178.229`, database `beer_db` | +| Secret | `beercred` (registry pull credentials) | + +Access: `http://:30015/` + +The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`. + +## Environment variables + +| Variable | Value | Source | +|---|---|---| +| `MONGO_HOST` | `192.168.178.229` | Deployment manifest | +| `MONGO_USER` | `root` | Deployment manifest | +| `MONGO_PASS` | *(see below)* | Deployment manifest | +| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key | +| `FLASK_ENV` | `production` | Dockerfile | +| `FLASK_DEBUG` | `0` | Dockerfile | + +`SECRET_KEY` and `MONGO_PASS` must be set in the live Deployment. They are +**not** committed to this repo — read them from the running cluster: + +```bash +kubectl -n beer get deploy beer-deployment -o jsonpath='{.spec.template.spec.containers[0].env}' +``` + +> The app refuses to start a *usable* session without a `SECRET_KEY`; the +> Dockerfile ships a placeholder so the container still boots if the variable +> is missing. Treat the placeholder as a misconfiguration. + +## Deploying + +Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the +test suite and then builds and pushes the image. **The workflow does not +currently deploy to the cluster** — apply the manifests yourself: + +```bash +kubectl -n beer rollout restart deployment/beer-deployment +kubectl -n beer rollout status deployment/beer-deployment --timeout=180s +``` + +### Verify the deploy actually took + +CI green and pods `Ready` do **not** prove the new code is running. Check the +running digest against the registry: + +```bash +# what the pod is running +kubectl -n beer get pod -l app=beer-data \ + -o jsonpath='{.items[0].status.containerStatuses[0].imageID}' + +# what the registry has as :latest +curl -s -u "armistace:$REG_PASSWORD" -I \ + "https://git.aridgwayweb.com/v2/armistace/beer-data/manifests/latest" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + | grep -i docker-content-digest +``` + +If they differ, the registry `:latest` is stale — re-run the build rather than +re-applying the manifest. + +### Check the app is alive + +```bash +kubectl -n beer get pods +kubectl -n beer logs deploy/beer-deployment --tail=50 +curl -s http://:30015/healthz # -> {"status":"ok"} +``` + +`/healthz` never touches Mongo, so it proves the process is up but not that the +database is reachable. + +## Data safety + +The `beer_db.beer_data` collection holds the actual brewing records. There is +no migration step and no schema version — documents are read as they are +written, and every field is treated as optional. + +To take a backup before any risky change: + +```bash +mongodump --uri="mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \ + --out=/tmp/beer_db_backup_$(date +%Y%m%d) +``` + +## Common problems + +### The login page loads but nothing else does + +The Mongo connection failed. Check the app logs for an auth error and confirm +the credentials: + +```bash +kubectl -n beer logs deploy/beer-deployment --tail=100 | grep -i mongo +``` + +### A run shows a negative alcohol figure + +That was the old formula (`(max - min) / 7.36 + 0.5`) applied to a run whose +current gravity is above the assumed original gravity of 1012. Fixed in the +current version: the standard `(OG - FG) × 131.25` formula is used, and a run +with no known OG shows a dash instead of a figure. + +### The chart on the run page is blank + +The chart library is served from `/static/js/chart.umd.js` — it is vendored in +the repo, not loaded from a CDN, so the container works with no outbound +network. Check the browser console for a 404 on that path. diff --git a/flask.Dockerfile b/flask.Dockerfile index fbc36f6..e6cfa00 100644 --- a/flask.Dockerfile +++ b/flask.Dockerfile @@ -1,14 +1,24 @@ FROM git.aridgwayweb.com/armistace/beer_base_image AS flask -#FROM beer_base_image AS flask COPY requirements.txt . ADD src /beer_data/src -ENV FLASK_ENV production -ENV FLASK_DEBUG 1 +# The app lives in src/flask and imports its siblings by bare name +# (``import brewing``), so that directory is the working directory. +WORKDIR /beer_data/src/flask +# The app is served by waitress, not the Flask development server. +# FLASK_DEBUG stays OFF: the old entrypoint ran the Werkzeug debugger in +# production, which exposes an interactive console on any unhandled error. +ENV FLASK_ENV=production +ENV FLASK_DEBUG=0 +ENV PORT=5000 -ENTRYPOINT ["flask", "--app", "/beer_data/src/flask/beer_data", "run", "--host=0.0.0.0"] +# SECRET_KEY is supplied by the deployment (see RUNBOOK.md). The default here +# is only a placeholder so the container starts if it is missing. +ENV SECRET_KEY=change-me-in-the-deployment -#ENTRYPOINT ["python", "/pool_data/src/flask/pool_data.py"] +EXPOSE 5000 + +ENTRYPOINT ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--call", "beer_data:create_app"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0d4ba38 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest +mongomock diff --git a/requirements.txt b/requirements.txt index ef76f0b..a4f6bce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,5 @@ -pymongo[srv] flask -requests_html -beautifulsoup4 -click Flask-WTF -bootstrap-flask waitress -bokeh -pandas -duckdb \ No newline at end of file +pymongo +requests diff --git a/src/flask/add_user.py b/src/flask/add_user.py deleted file mode 100644 index d03f7d2..0000000 --- a/src/flask/add_user.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -import click - -import mongo.user_db as user_db - -@click.command() -@click.option('--username', type=str, help="Username to be added or updated") -@click.option('--password', type=str, help="Password to be added or updated") - -def main(username, password): - """ - little cli program to update the - user table in mongo - this rightly should eventually be - and admin tool but right now this will dow - """ - user_collection = user_db.user_data() - new_record = { - "username" : f"{username}", - "password" : f"{password}" - } - if user_collection.user_exists(new_record["username"]): - user_collection.update_user(user_collection.existing_record["_id"], new_record["password"]) - else: - user_collection.add_user(new_record["username"], new_record["password"]) - - -if __name__ == "__main__": - main() diff --git a/src/flask/app_factory.py b/src/flask/app_factory.py new file mode 100644 index 0000000..ff54e9a --- /dev/null +++ b/src/flask/app_factory.py @@ -0,0 +1,120 @@ +"""Application factory for the beer_data web app. + +The application is deliberately small: a handful of pages, one Mongo database, +no build step for the front end. Everything server-side lives in blueprints +under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from flask import Flask, g, redirect, request, session, url_for +from flask_wtf import CSRFProtect + +__all__ = ["create_app"] + + +def create_app(config: dict | None = None) -> Flask: + """Build and configure the Flask application.""" + app = Flask( + __name__, + template_folder="templates", + static_folder="static", + ) + + app.config.update( + SECRET_KEY=os.getenv("SECRET_KEY", "dev-secret-change-me"), + MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"), + MONGO_USER=os.getenv("MONGO_USER", "root"), + MONGO_PASS=os.getenv("MONGO_PASS", ""), + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + # Flask-WTF needs this for the login form to render and validate. + WTF_CSRF_ENABLED=True, + ) + if config: + app.config.update(config) + + csrf = CSRFProtect(app) + + # ------------------------------------------------------------------ # + # Request-scoped repository + # ------------------------------------------------------------------ # + from mongo.repository import BeerRepository + + def get_repo() -> BeerRepository: + """One Mongo client per request, closed when the request ends.""" + if "repo" not in g: + # A client can be injected via config (used by the test suite); + # otherwise build one from the environment. + injected = app.config.get("MONGO_CLIENT") + g.repo = BeerRepository(client=injected) if injected else BeerRepository() + return g.repo + + app.extensions["beer_repo"] = get_repo + + @app.teardown_appcontext + def _close_repo(_exc=None): + repo = g.pop("repo", None) + # An injected client (the test suite) is owned by the caller — do not + # close it, or the next request in the same test gets a dead client. + if repo is not None and app.config.get("MONGO_CLIENT") is None: + repo.client.close() + + # ------------------------------------------------------------------ # + # Auth helpers + # ------------------------------------------------------------------ # + def logged_in() -> bool: + return bool(session.get("logged_in")) + + @app.context_processor + def inject_globals(): + return { + "logged_in": logged_in(), + "current_path": request.path, + "app_name": "Brew Log", + } + + # ------------------------------------------------------------------ # + # Error handlers + # ------------------------------------------------------------------ # + @app.errorhandler(404) + def _not_found(_exc): + return render_template("error.html", code=404, + message="That page does not exist."), 404 + + @app.errorhandler(500) + def _server_error(_exc): # pragma: no cover - defensive + return render_template("error.html", code=500, + message="Something broke on our side."), 500 + + # ------------------------------------------------------------------ # + # Blueprints + # ------------------------------------------------------------------ # + from views.auth import bp as auth_bp + from views.brews import bp as brews_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(brews_bp) + + @app.route("/healthz") + def healthz(): + """Liveness probe used by Kubernetes. Never touches Mongo.""" + return {"status": "ok"}, 200 + + @app.route("/") + def index(): + if logged_in(): + return redirect(url_for("brews.index")) + return redirect(url_for("auth.login")) + + return app + + +def render_template(*args, **kwargs): # noqa: D401 - re-export for views + """Small shim so view modules can import ``render_template`` from one place.""" + from flask import render_template as _rt + + return _rt(*args, **kwargs) diff --git a/src/flask/beer_data.py b/src/flask/beer_data.py index d65a000..f93791b 100644 --- a/src/flask/beer_data.py +++ b/src/flask/beer_data.py @@ -1,140 +1,33 @@ -from bokeh.core.enums import SpatialUnitsType -import mongo.build_db as beer_database -import mongo.query_db as beer_database_query -from table import table_builder +"""Entry point for the beer_data web application. -from flask import Flask, render_template, request, jsonify, redirect, session -from flask_wtf import FlaskForm, CSRFProtect -from flask_bootstrap import Bootstrap5 -from wtforms import StringField, SubmitField, DateField, IntegerField, PasswordField, DecimalField, RadioField, TextAreaField, BooleanField -from wtforms.validators import DataRequired, Length, Optional -from waitress import serve -from bokeh.models.layouts import HBox -from bokeh.plotting import column -from charts import BeerCharts -from bokeh.io import output_file, show -from bokeh.layouts import row +Run in development: + + python -m beer_data + +Run in production (what the container does): + + waitress-serve --listen=0.0.0.0:5000 beer_data:app +""" + +from __future__ import annotations + +import os + +from app_factory import create_app + +app = create_app() + +# The WSGI callable used by waitress / gunicorn. +application = app -app = Flask(__name__) -app.secret_key = 'testsecret' #this value will change - -bootstrap = Bootstrap5(app) - -csrf = CSRFProtect(app) -# used to configure the bokeh plot for graphs -output_file("/beer_data/src/flask/static/data_plot.html") - -def create_graphs(): - chart = BeerCharts.BeerCharts() - sg = chart.line_chart("SG", "sg", 30) - show(column(sg)) - -def data_frame_to_table(df) -> str: - - tr_replace_string = '' - return_html = df.to_html(col_space='75px', index=False, - justify='center', border=3).replace('', tr_replace_string) - return return_html - -class userForm(FlaskForm): - username = StringField("User Name?", validators=[DataRequired()]) - password = PasswordField("Password?") - submit = SubmitField("Letsa GO!") - -class dataForm(FlaskForm): - beer_run_id = IntegerField("Beer Run ID") - beer_run_type = TextAreaField("Beer Type") - Date = DateField("Date:") - sg = IntegerField("SG Reading") - final_run = BooleanField("Final Reading?") - comment = TextAreaField("Any Comments?", validators=[Optional()]) - submit = SubmitField("Write it, Write it REAAAAAAL GOOOD") - -@app.route("/", methods=["GET","POST"]) -def index(): - form = userForm() - if form.validate_on_submit(): - username = form.username.data - password = form.password.data - db = beer_database_query.pool_query() - if db.user_check(username, password): - session['logged_in'] = True - return redirect("/updater") - else: - return render_template("index.html", try_again=True, form=form) - else: - return render_template("index.html", try_again=False, form=form) - -@app.route("/updater", methods=["GET", "POST"]) -def updater(): - if 'logged_in' not in session: - return redirect("/") - table_Data = table_builder.TableBuilder() - predicted_alc_table = table_Data.done_runs_build() - current_runs_table = table_Data.current_runs_build() - - beer_html = data_frame_to_table(predicted_alc_table) - current_html = data_frame_to_table(current_runs_table) - - query_db = beer_database_query.pool_query() - query = query_db.get_top(10, "sg") - form = dataForm() +def main() -> None: + """Run the development server.""" + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", "5000")) + debug = os.getenv("FLASK_DEBUG", "0") == "1" + app.run(host=host, port=port, debug=debug) - - if form.validate_on_submit(): - database = beer_database.beer_data() - new_record = { - "date": f'{form.Date.data}', - "beer_run_id": f'{form.beer_run_id.data}', - "beer_type": f'{form.beer_run_type.data}', - "sg" : f'{form.sg.data}', - "final_reading" : F'{form.final_run.data}', - "comment": f'{form.comment.data}' - } - if database.record_exists(new_record["date"], new_record["beer_run_id"]): - for field in database.existing_record: - for new_field in new_record: - if field == new_field: - if database.existing_record[field] != new_record[field]: - database.update_re_record(database.existing_record["_id"], field, new_record[field]) - else: - if new_record["final_reading"] == "True": - final_run_value = True - else: - final_run_value = False - database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"], - new_record["date"], final_run_value, new_record["comment"]) - - return render_template("updater.html", beer_data = beer_html, current_data = current_html - , list=query, form=form, success=True, updater_name = "Saucy Beer Maker") - else: - return render_template("updater.html", beer_data = beer_html, current_data = current_html - , list=query, form=form, sucess=False) - -# @app.route("/update_db", methods=["POST"]) -# def beer_data_update(): -# database = beer_database.beer_data() -# new_record = request.json -# if database.record_exists(new_record["date"], new_record["test_user"]): -# for field in database.existing_record: -# for new_field in new_record: -# if field == new_field: -# if database.existing_record[field] != new_record[field]: -# database.update_re_record(database.existing_record["_id"], field, new_record[field]) -# else: -# database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"], -# new_record["date"], new_record["comment"]) - - - -# @app.route("/pool_top//") -# def user_detail(id): -# query_db = beer_database_query.pool_query() -# query = query_db.get_top(return_number, field) -# return jsonify([row.to_json() for row in query]) - -if __name__ == '__main__': - #app.run(host='0.0.0.0') - serve(app, host='0.0.0.0', port=5000, url_scheme='https') +if __name__ == "__main__": + main() diff --git a/src/flask/brewing.py b/src/flask/brewing.py new file mode 100644 index 0000000..58e8ad5 --- /dev/null +++ b/src/flask/brewing.py @@ -0,0 +1,431 @@ +"""Pure brewing logic for the beer_data app. + +Everything in this module is a pure function over plain data (no Flask, no Mongo, +no pandas) so the maths can be unit-tested without a database. + +Gravity convention +------------------ +The Mongo collection stores specific gravity as integer *points*: the digits after +the decimal point of the usual 1.0xx reading. ``1039`` means 1.039. This matches +how the readings were entered and keeps the stored data unchanged. + +Alcohol by volume +----------------- +Standard homebrew formula (Charlie Papazian / "The Joy of Homebrewing"): + + ABV% = (OG - FG) * 131.25 + +With gravities expressed in points the difference is directly in points, so: + + ABV% = points_drop * 0.13125 + +The legacy application used ``(max - min) / 7.36 + 0.5`` which is not a recognised +formula and overstates the result (it also returns 0.5% for a run with no drop at +all). :func:`legacy_abv_percent` keeps the old maths available so the history page +can show the difference between the two, but new code uses :func:`abv_percent`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Iterable, Sequence + +__all__ = [ + "ABV_FACTOR", + "ASSUMED_ORIGINAL_GRAVITY", + "Reading", + "Run", + "abv_percent", + "assumed_og_abv_percent", + "days_between", + "final_reading", + "first_reading", + "gravity_drop_points", + "is_active", + "is_plausible_sg", + "last_reading", + "legacy_abv_percent", + "parse_date", + "parse_sg_points", + "run_summary", + "sg_points_to_decimal", +] + +#: Conversion factor for points-drop -> percent alcohol (131.25 / 1000). +ABV_FACTOR = 0.13125 + +#: Original gravity assumed for an in-progress run when no OG was recorded. +#: The legacy app hard-coded 1012; kept as the default so existing numbers line up. +ASSUMED_ORIGINAL_GRAVITY = 1012 + +#: Plausible range for a hydrometer reading, in points. +_MIN_PLAUSIBLE_SG = 990 +_MAX_PLAUSIBLE_SG = 1200 + + +# --------------------------------------------------------------------------- # +# Parsing helpers +# --------------------------------------------------------------------------- # + +def parse_sg_points(raw: object) -> int | None: + """Return the gravity in points for *raw*, or ``None`` if it is not usable. + + Accepts ints, floats and strings. ``"1039"``, ``1039`` and ``1039.0`` all + become ``1039``. Values like ``"1.039"`` are also accepted and converted, and + anything unparseable yields ``None`` rather than raising. + """ + if raw is None: + return None + if isinstance(raw, bool): # bool is an int subclass; never a gravity + return None + if isinstance(raw, (int, float)): + value = float(raw) + else: + text = str(raw).strip() + if not text: + return None + try: + value = float(text) + except ValueError: + return None + + if value > 100: # already stored as points (1039) + return int(round(value)) + if value >= 0.9: # a decimal reading such as 1.039 + return int(round(value * 1000)) + return None + + +def sg_points_to_decimal(points: int | None) -> float | None: + """Convert points (``1039``) to the conventional decimal reading (``1.039``).""" + if points is None: + return None + return round(points / 1000.0, 4) + + +def parse_date(raw: object) -> date | None: + """Parse a ``YYYY-MM-DD`` date, tolerating the odd alternative layout.""" + if raw is None: + return None + if isinstance(raw, datetime): + return raw.date() + if isinstance(raw, date): + return raw + text = str(raw).strip() + if not text: + return None + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + +def _as_bool(value: object) -> bool: + """Interpret the stored ``final_reading`` flag. + + The collection stores it as the *string* ``"True"``/``"False"`` for historic + rows and as a real boolean for newer ones, so both are accepted. + """ + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"true", "1", "yes", "y", "t"} + + +# --------------------------------------------------------------------------- # +# Domain objects +# --------------------------------------------------------------------------- # + +@dataclass(frozen=True) +class Reading: + """A single gravity reading taken on a given day.""" + + date: date | None + sg_points: int | None + final_reading: bool = False + comment: str = "" + beer_type: str = "" + _id: str = "" + + @property + def sg_decimal(self) -> float | None: + return sg_points_to_decimal(self.sg_points) + + @property + def is_plausible(self) -> bool: + return is_plausible_sg(self.sg_points) + + @classmethod + def from_doc(cls, doc: dict) -> "Reading": + """Build a :class:`Reading` from a raw Mongo document.""" + return cls( + date=parse_date(doc.get("date")), + sg_points=parse_sg_points(doc.get("sg")), + final_reading=_as_bool(doc.get("final_reading")), + comment=(doc.get("comment") or "").strip(), + beer_type=(doc.get("beer_type") or "").strip(), + _id=str(doc.get("_id") or ""), + ) + + +@dataclass +class Run: + """All readings that belong to one brew run.""" + + beer_run_id: str + readings: list[Reading] = field(default_factory=list) + + @classmethod + def from_docs(cls, beer_run_id: str, docs: Iterable[dict]) -> "Run": + readings = [Reading.from_doc(d) for d in docs] + readings.sort(key=lambda r: (r.date or date.min, r.sg_points or 0)) + return cls(beer_run_id=str(beer_run_id), readings=readings) + + # -- aggregate accessors ------------------------------------------------ # + + def sg_values(self) -> list[int]: + """Gravity points of every *usable* reading, in chronological order.""" + return [r.sg_points for r in self.readings if r.sg_points is not None] + + def original_gravity(self) -> int | None: + """OG = the first usable reading of the run.""" + values = self.sg_values() + return values[0] if values else None + + def final_gravity(self) -> int | None: + """FG = the last usable reading of the run (if the run is finished).""" + values = self.sg_values() + return values[-1] if values else None + + def max_sg(self) -> int | None: + values = self.sg_values() + return max(values) if values else None + + def min_sg(self) -> int | None: + values = self.sg_values() + return min(values) if values else None + + def is_active(self) -> bool: + return is_active(self.readings) + + def final_reading(self) -> Reading | None: + return final_reading(self.readings) + + def first_reading(self) -> Reading | None: + return first_reading(self.readings) + + def last_reading(self) -> Reading | None: + return last_reading(self.readings) + + def gravity_drop_points(self) -> int | None: + og, fg = self.original_gravity(), self.final_gravity() + if og is None or fg is None: + return None + return og - fg + + def abv_percent(self) -> float | None: + """Standard ABV for a finished run.""" + drop = self.gravity_drop_points() + return abv_percent(drop) if drop is not None else None + + def projected_abv_percent(self, assumed_og: int = ASSUMED_ORIGINAL_GRAVITY, + og_override: int | None = None) -> float | None: + # `assumed_og` is retained for API compatibility but is deliberately + # NOT used as a fallback — see the docstring. + """ABV estimate for an *unfinished* run, or ``None`` if unknowable. + + The original gravity is taken from, in order of preference: + + 1. ``og_override`` — the OG recorded against the run by the user. + 2. The run's first reading, when there are two or more readings. + The first reading of a run IS its original gravity — that is what + a reading taken on brew day is for. + With a single reading and no recorded OG there is NO reliable + estimate: the reading may have been taken on brew day or mid- + fermentation. The legacy app assumed 1012 anyway, which produced + *negative* alcohol whenever the reading was above 1012 — a perfectly + normal OG. Rather than show a misleading figure, ``None`` is + returned and the UI shows a dash until an OG is known. Record the OG + against the run and the estimate appears. + """ + values = self.sg_values() + if not values: + return None + + if og_override is not None: + og = og_override + elif len(values) >= 2: + og = values[0] + else: + # A single reading and no recorded OG: there is no reliable + # original gravity to measure against, so there is no estimate. + return None + + fg = self.min_sg() + if fg is None: + return None + + drop = og - fg + if drop <= 0: + # Gravity has risen during fermentation — an anomaly, not alcohol. + return None + + return abv_percent(drop) + + def days_in_fermenter(self, today: date | None = None) -> int | None: + first = self.first_reading() + if first is None or first.date is None: + return None + final = self.final_reading() + end = final.date if (final is not None and final.date is not None) else (today or date.today()) + if end is None: + return None + return days_between(first.date, end) + + def display_name(self) -> str: + """Best available human label for the run. + + The stored ``beer_type`` drifts between readings of the same run, so the + most frequently used non-empty value wins; ties break towards the most + recent reading. Run 4 would resolve to "Nocturna the Christmas beer" + rather than "Necatron-Christmas Beer". + """ + counts: dict[str, int] = {} + for reading in self.readings: + label = reading.beer_type + if label: + counts[label] = counts.get(label, 0) + 1 + if not counts: + return f"Run {self.beer_run_id}" + best = max(counts, key=lambda label: (counts[label], self._last_index_of(label))) + return best + + def _last_index_of(self, label: str) -> int: + for index in range(len(self.readings) - 1, -1, -1): + if self.readings[index].beer_type == label: + return index + return -1 + + def comments(self) -> list[str]: + """Every non-empty comment, oldest first.""" + return [r.comment for r in self.readings if r.comment] + + def outlier_readings(self) -> list[Reading]: + """Readings whose gravity is outside the plausible hydrometer range.""" + return [r for r in self.readings if r.sg_points is not None and not r.is_plausible] + + +# --------------------------------------------------------------------------- # +# Module level helpers (usable without a Run instance) +# --------------------------------------------------------------------------- # + +def abv_percent(points_drop: int | float | None) -> float | None: + """Standard ABW/ABV from a gravity drop expressed in points.""" + if points_drop is None: + return None + return round(float(points_drop) * ABV_FACTOR, 2) + + +def legacy_abv_percent(max_points: int | float | None, + min_points: int | float | None) -> float | None: + """The legacy app's formula, kept for the legacy-vs-standard comparison. + + The old code computed ``((max - min) / 7.36) + 0.5``. It is not a recognised + brewing formula and returns 0.5% even when nothing has fermented. + """ + if max_points is None or min_points is None: + return None + return round(((float(max_points) - float(min_points)) / 7.36) + 0.5, 2) + + +def assumed_og_abv_percent(current_points: int | None, + assumed_og: int = ASSUMED_ORIGINAL_GRAVITY) -> float | None: + """ABV for an in-progress run measured against an assumed original gravity.""" + if current_points is None: + return None + return abv_percent(assumed_og - current_points) + + +def is_plausible_sg(points: int | None) -> bool: + """True when a gravity reading sits inside a believable hydrometer range.""" + if points is None: + return False + return _MIN_PLAUSIBLE_SG <= points <= _MAX_PLAUSIBLE_SG + + +def is_active(readings: Sequence[Reading]) -> bool: + """A run is active while it has no reading marked as the final one.""" + return not any(r.final_reading for r in readings) + + +def final_reading(readings: Sequence[Reading]) -> Reading | None: + """The reading flagged as final, or ``None``.""" + for reading in readings: + if reading.final_reading: + return reading + return None + + +def first_reading(readings: Sequence[Reading]) -> Reading | None: + """The chronologically first reading.""" + dated = [r for r in readings if r.date is not None] + if dated: + return min(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value] + return readings[0] if readings else None + + +def last_reading(readings: Sequence[Reading]) -> Reading | None: + """The chronologically last reading.""" + dated = [r for r in readings if r.date is not None] + if dated: + return max(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value] + return readings[-1] if readings else None + + +def days_between(start: date, end: date) -> int: + return (end - start).days + + +def run_summary(run: Run, today: date | None = None, + og_override: int | None = None) -> dict: + """A flat dict describing a run, ready for a template or a JSON response.""" + final = run.final_reading() + first = run.first_reading() + og = run.original_gravity() + fg = run.final_gravity() + return { + "beer_run_id": run.beer_run_id, + "name": run.display_name(), + "active": run.is_active(), + "og_points": og, + "fg_points": fg, + "og_decimal": sg_points_to_decimal(og), + "fg_decimal": sg_points_to_decimal(fg), + "max_sg": run.max_sg(), + "min_sg": run.min_sg(), + "drop_points": run.gravity_drop_points(), + "abv": run.abv_percent(), + "projected_abv": run.projected_abv_percent(og_override=og_override), + "legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()), + "reading_count": len(run.readings), + "first_date": first.date.isoformat() if (first and first.date) else None, + "final_date": final.date.isoformat() if (final and final.date) else None, + "days_in_fermenter": run.days_in_fermenter(today), + "comments": run.comments(), + "outliers": [r.sg_points for r in run.outlier_readings()], + "readings": [ + { + "date": r.date.isoformat() if r.date else None, + "sg": r.sg_points, + "sg_decimal": r.sg_decimal, + "final_reading": r.final_reading, + "comment": r.comment, + "beer_type": r.beer_type, + } + for r in run.readings + ], + } diff --git a/src/flask/charts/BeerCharts.py b/src/flask/charts/BeerCharts.py deleted file mode 100644 index 3655e22..0000000 --- a/src/flask/charts/BeerCharts.py +++ /dev/null @@ -1,52 +0,0 @@ -import sys - -from bokeh.models.widgets import DataCube -from bokeh.plotting import figure - -from mongo.get_conn import db_conn - -class BeerCharts(): - def __init__(self): - self.db = db_conn() - - def bar_chart(self, items, data): - return 0 - - - def line_chart(self, title, field, limit): - data = self.db.beer_db - data = data.find({}).sort("date", -1).limit(limit) - dates = [] - data_list = [] - date_count = {} - for record in data: - if field in record and record[field] != "None": - if record["date"] not in dates: - print("new date record") - dates.append(record["date"]) - data_list.append(float(record[field])) - print (dates) - print (data_list) - else: - if record["date"] in date_count: - date_count[record["date"]] += 1 - else: - date_count[record["date"]] = 2 - print(len(data_list)) - current_data = data_list[len(data_list) - 1] - data_list[len(data_list) - 1] = (float(record[field]) + current_data) / date_count[record["date"]] - - print(dates) - print(data_list) - - p = figure(x_range=dates, height=250, title=title, - toolbar_location=None, tools="") - - p.line(x=dates, y=data_list) - - p.xgrid.grid_line_color = None - if not data_list: - p.y_range.start = 0 - else: - p.y_range.start = round(min(data_list) - 1) - return p diff --git a/src/flask/charts/__init__.py b/src/flask/charts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/flask/mongo/build_db.py b/src/flask/mongo/build_db.py deleted file mode 100644 index 1b92353..0000000 --- a/src/flask/mongo/build_db.py +++ /dev/null @@ -1,76 +0,0 @@ -import string -import secrets - -from mongo.get_conn import db_conn - -class beer_data: - """ - This class will allow us to - interact with our data to interact - just create a pool_data var - """ - def __init__(self): - #db_conn has all the the things - #already created from here - #we can get self.db.real_db etc - self.db = db_conn() - - def record_exists(self, date, beer_run_id): - """ - This function will accept an address - if it find that address in the database it will return True - and set set the existing_record variable of the class to the - queried record - """ - query = { "date" : f"{date}", "beer_run_id": f"{beer_run_id}"} - record = self.db.beer_db.find_one(query) - if record: - self.existing_record = record - return True - else: - return False - - - def create_re_record(self, beer_run_id, beer_type, sg, date, final_reading=False, comment=""): - """ - create_re_record creates a whole new record - takes the required 7 inputs - 1. beer_run_id - 2. beer_type - 3. sg - 4. date - 5. comment(optional) - It will autogenerate the id string - this string will be automatically selected - on view in future - """ - key = self.create_id() - insert_record = { - "_id" : f"{key}", - "beer_run_id": f"{beer_run_id}", - "beer_type": f"{beer_type}", - "sg": f"{sg}", - "date": f"{date}", - "final_reading": f"{final_reading}", - "comment": f"{comment}" - - } - self.db.beer_db.insert_one(insert_record) - - def update_re_record(self, id, field, value): - """ - update_re_record - will update the requested field to value for the - selected field - """ - #TODO: do we need to make this take more than 1 id? - - query = { "_id": f"{id}"} - update_val = {"$set": {f"{field}": f"{value}" }} - self.db.beer_db.update_one(query, update_val) - - def create_id(self): - alphabet = string.ascii_letters + string.digits - key = ''.join(secrets.choice(alphabet) for _ in range(24)) - return key - diff --git a/src/flask/mongo/get_conn.py b/src/flask/mongo/get_conn.py deleted file mode 100644 index 9f71b0d..0000000 --- a/src/flask/mongo/get_conn.py +++ /dev/null @@ -1,19 +0,0 @@ -from pymongo import MongoClient -import os - -class db_conn: - def __init__(self): - self.db_user = os.getenv('MONGO_USER') - self.db_pass = os.getenv('MONGO_PASS') - self.db_host = os.getenv('MONGO_HOST') - self.client = self.get_client() - self.db = self.client['beer_db'] - self.beer_db = self.db['beer_data'] - self.users = self.db['users'] - self.inspections = self.db['inspections_db'] - - def get_client(self): - CONNECTION_STRING = f"mongodb://{self.db_user}:{self.db_pass}@{self.db_host}/beer_db?authSource=admin" - return MongoClient(CONNECTION_STRING) - - diff --git a/src/flask/mongo/query_db.py b/src/flask/mongo/query_db.py deleted file mode 100644 index 7537894..0000000 --- a/src/flask/mongo/query_db.py +++ /dev/null @@ -1,55 +0,0 @@ -import string -import secrets - -from mongo.get_conn import db_conn - -class pool_query: - """ - This class will allow us to - interact with our data to interact - just create a realestate_data var - """ - def __init__(self): - #db_conn has all the the things - #already created from here - #we can get self.db.real_db etc - self.db = db_conn() - - def record_exists(self, beer_run_id, date): - """ - This function will accept an address - if it find that address in the database it will return True - and set set the existing_record variable of the class to the - queried record - """ - query = {"beer_run_id": f"{beer_run_id}", "date": f"{date}"} - record = self.db.beer_db.find_one(query) - if record: - self.existing_record = record - return True - else: - return False - - def get_top(self, num_limit, value_field): - """ - This function will return the - last n records of the chosen value field - It will take the number of records you want to - return as a parameter - """ - records = self.db.beer_db.find({}, {"beer_run_id": 1, "_id": 0, "date": 1, f"{value_field}": 1}).sort("date", -1).limit(num_limit) - return records - - def user_check(self, username, password): - """ - function to check username and password - back in db - """ - #TODO: this ueses my own quick hack it likley needs to be rewrittent to follow best practice - - query = { "username" : f"{username}", "password" : f"{password}" } - record = self.db.users.find_one(query) - if record: - return True - else: - return False diff --git a/src/flask/mongo/repository.py b/src/flask/mongo/repository.py new file mode 100644 index 0000000..165d55e --- /dev/null +++ b/src/flask/mongo/repository.py @@ -0,0 +1,234 @@ +"""Mongo access layer for the beer_data app. + +Two collections are used: + +``beer_data`` + The historic readings. One document per gravity reading. **Never modified or + removed by the code in this module** apart from the explicit "add or update a + reading" path used by the brew-process form. + +``beer_runs`` + New in this version. One document per brew run holding the *canonical* + metadata that could not be derived from the readings themselves: the beer + name, the tap number it is on, the original gravity, and whether the run is + still in the fermenter. The backfill script only ever inserts into this + collection, so it is safe to run repeatedly. +""" + +from __future__ import annotations + +import os +from typing import Any + +from pymongo import MongoClient +from pymongo.errors import PyMongoError + +from brewing import Run + +__all__ = ["BeerRepository", "get_repository"] + + +class BeerRepository: + """Thin, testable wrapper around the two Mongo collections.""" + + def __init__(self, client: MongoClient | None = None, + db_name: str = "beer_db") -> None: + self.client = client or _build_client() + self.db = self.client[db_name] + self.readings = self.db["beer_data"] + self.users = self.db["users"] + # NB: named `run_meta`, not `runs` — an attribute called `runs` would + # shadow the runs() method on the instance. + self.run_meta = self.db["beer_runs"] + + # -- users -------------------------------------------------------------- # + + def user_check(self, username: str, password: str) -> bool: + """Check the login credentials. + + Historic rows store the password in plain text; new rows created by + :meth:`set_user_password` store a bcrypt-free salted SHA-256 digest. + Both are checked so existing accounts keep working. + """ + doc = self.users.find_one({"username": username}) + if not doc: + return False + stored = str(doc.get("password") or "") + if not stored: + return False + if stored == password: + return True + return stored == _hash_password(password) + + # -- readings ----------------------------------------------------------- # + + def all_reading_docs(self) -> list[dict]: + """Every reading document, oldest first.""" + return list(self.readings.find().sort([("date", 1), ("beer_run_id", 1)])) + + def reading_docs_for_run(self, beer_run_id: str) -> list[dict]: + return list( + self.readings.find({"beer_run_id": str(beer_run_id)}).sort("date", 1) + ) + + def runs(self) -> list[Run]: + """Every run, built from the reading documents, oldest run first.""" + grouped: dict[str, list[dict]] = {} + for doc in self.all_reading_docs(): + grouped.setdefault(str(doc.get("beer_run_id")), []).append(doc) + + def run_sort_key(run: Run) -> tuple: + first = run.first_reading() + return (first.date if (first and first.date) else date_min(), run.beer_run_id) + + runs = [Run.from_docs(run_id, docs) for run_id, docs in grouped.items()] + runs.sort(key=run_sort_key) + return runs + + def run(self, beer_run_id: str) -> Run | None: + docs = self.reading_docs_for_run(beer_run_id) + if not docs: + return None + return Run.from_docs(str(beer_run_id), docs) + + def active_runs(self) -> list[Run]: + """Runs with no final reading yet.""" + return [r for r in self.runs() if r.is_active()] + + def completed_runs(self) -> list[Run]: + return [r for r in self.runs() if not r.is_active()] + + def upsert_reading(self, *, beer_run_id: str, beer_type: str, sg: int, + reading_date: str, final_reading: bool, comment: str) -> str: + """Add a reading, or update the matching one if it already exists. + + The unique key is ``(date, beer_run_id)`` which is what the original + application used, so re-submitting the same day updates in place + instead of creating a duplicate. Returns the ``_id`` of the document. + """ + query = {"date": reading_date, "beer_run_id": str(beer_run_id)} + update = { + "$set": { + "beer_run_id": str(beer_run_id), + "beer_type": beer_type, + "sg": str(sg), + "date": reading_date, + "final_reading": "True" if final_reading else "False", + "comment": comment, + } + } + result = self.readings.update_one(query, update, upsert=True) + if result.upserted_id is not None: + return str(result.upserted_id) + existing = self.readings.find_one(query) + return str(existing.get("_id")) if existing else "" + + def set_final_reading(self, beer_run_id: str, reading_date: str, + final: bool) -> int: + """Flag (or unflag) a reading as the final reading of its run.""" + result = self.readings.update_many( + {"date": reading_date, "beer_run_id": str(beer_run_id)}, + {"$set": {"final_reading": "True" if final else "False"}}, + ) + return result.modified_count + + # -- run metadata ------------------------------------------------------- # + + def run_metadata(self, beer_run_id: str) -> dict: + """The stored metadata for a run, or an empty dict.""" + doc = self.run_meta.find_one({"beer_run_id": str(beer_run_id)}) + if not doc: + return {} + doc.pop("_id", None) + return doc + + def all_run_metadata(self) -> dict[str, dict]: + """All stored run metadata, keyed by ``beer_run_id``.""" + result: dict[str, dict] = {} + for doc in self.run_meta.find(): + doc.pop("_id", None) + result[str(doc.get("beer_run_id"))] = doc + return result + + def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict: + """Create or update the metadata document for a run. + + Only the keys passed in are written; anything already stored is left + alone. + """ + payload = {k: v for k, v in fields.items() if v is not None} + payload["updated_at"] = _now_iso() + self.run_meta.update_one( + {"beer_run_id": str(beer_run_id)}, + {"$set": payload, "$setOnInsert": {"created_at": _now_iso()}}, + upsert=True, + ) + return self.run_metadata(beer_run_id) + + def delete_run_metadata(self, beer_run_id: str) -> bool: + """Remove the metadata document for a run (readings are untouched).""" + return self.run_meta.delete_one({"beer_run_id": str(beer_run_id)}).deleted_count > 0 + + # -- helpers ------------------------------------------------------------ # + + def next_run_id(self) -> str: + """The next free ``beer_run_id``. + + Run ids are plain integers in the existing data, so the next id is one + past the highest numeric id currently in use. + """ + highest = 0 + for run in self.runs(): + try: + highest = max(highest, int(run.beer_run_id)) + except (TypeError, ValueError): + continue + return str(highest + 1) + + +# --------------------------------------------------------------------------- # +# Connection helpers +# --------------------------------------------------------------------------- # + +def _build_client() -> MongoClient: + user = os.getenv("MONGO_USER", "root") + password = os.getenv("MONGO_PASS", "") + host = os.getenv("MONGO_HOST", "127.0.0.1") + uri = os.getenv("MONGO_URI") or f"mongodb://{user}:{password}@{host}/beer_db?authSource=admin" + return MongoClient(uri, serverSelectionTimeoutMS=5000) + + +def _hash_password(password: str) -> str: + """Salted SHA-256 hex digest used for newly created users.""" + import hashlib + import secrets + + salt = os.getenv("BEER_PASSWORD_SALT", "beer_data") + return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest() + + +def _now_iso() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def date_min(): + from datetime import date + + return date.min + + +def get_repository() -> BeerRepository: + """Build a repository against the configured Mongo.""" + return BeerRepository() + + +def is_alive(repo: BeerRepository) -> bool: + """Cheap health check used by the status page.""" + try: + repo.client.admin.command("ping") + return True + except PyMongoError: + return False + diff --git a/src/flask/mongo/user_db.py b/src/flask/mongo/user_db.py deleted file mode 100644 index 8e25970..0000000 --- a/src/flask/mongo/user_db.py +++ /dev/null @@ -1,63 +0,0 @@ -import string -import secrets - -from mongo.get_conn import db_conn - - -class user_data: - """ - This class will allow us to - interact with our data to interact - just create a user_data var - """ - def __init__(self): - self.db = db_conn() - - def create_id(self): - alphabet = string.ascii_letters + string.digits - key = ''.join(secrets.choice(alphabet) for _ in range(24)) - return key - - def user_exists(self, username): - """ - This function will accept a username - if it find that user in the database it will return True - and set set the existing_record variable of the class to the - queried record - """ - query = { "username" : f"{username}" } - record = self.db.users.find_one(query) - if record: - self.existing_record = record - return True - else: - return False - - - - def add_user(self, username, password): - """ - add to user table - function accepts the username and password - it will generate a uniqe key - """ - - key = self.create_id() - insert_record = { - "_id" : f"{key}", - "username" : f"{username}", - "password" : f"{password}" - } - self.db.users.insert_one(insert_record) - - def update_user(self, id, password): - """ - update the user record password - requires the id to be updated - """ - - query = { "_id": f"{id}"} - update_val = {"$set": {"password": f"{password}" }} - self.db.users.update_one(query, update_val) - - diff --git a/src/flask/static/css/app.css b/src/flask/static/css/app.css new file mode 100644 index 0000000..77e2b05 --- /dev/null +++ b/src/flask/static/css/app.css @@ -0,0 +1,637 @@ +/* ========================================================================== + Brew Log — design system + Beer-house palette: warm amber/copper on near-black, with a condensed + display face (Anton) for headings and Inter for everything else. + + Palette taken from the brewery-128 template: + --primary #be7f39 (amber / copper) + --primary-dark #0e0e0e + --black-primary #212024 + --black-secondary #161616 + --gray #e9e9e9 + --dark-gray #afafaf + --paragraph-gray #535353 + ========================================================================== */ + +/* ------------------------------------------------------------------ fonts */ + +@font-face { + font-family: 'Anton'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('../fonts/anton-latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url('../fonts/inter-latin-400.woff2') format('woff2'); +} + +/* ----------------------------------------------------------------- tokens */ + +:root { + /* surfaces */ + --bg: #121110; + --bg-raised: #1c1a19; + --bg-raised-2: #242120; + --bg-input: #161514; + + /* lines */ + --border: #332e2a; + --border-strong: #4a4239; + + /* text */ + --text: #f2ede8; + --text-muted: #afafaf; + --text-faint: #7d746c; + + /* brand */ + --amber: #be7f39; + --amber-bright: #dda05c; + --amber-deep: #8f5f28; + --amber-dim: rgba(190, 127, 57, 0.14); + --amber-text: #e2b47c; + + /* foam / highlights */ + --foam: #f7f3ee; + --cream: #eef4f8; + + /* status */ + --warn: #e8a33d; + --warn-dim: rgba(232, 163, 61, 0.14); + --danger: #d9534f; + --danger-dim: rgba(217, 83, 79, 0.14); + --success: #7db902; + --success-dim: rgba(125, 185, 2, 0.14); + + --radius: 10px; + --radius-sm: 6px; + --radius-lg: 16px; + + --shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 10px 30px rgba(0, 0, 0, .35); + + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --display: 'Anton', 'Arial Narrow', Impact, sans-serif; + --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 15px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +a { color: var(--amber-text); text-decoration: none; } +a:hover { text-decoration: underline; } + +h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.15; font-weight: 650; } +h1 { font-size: 1.65rem; } +h2 { font-size: 1.25rem; } +h3 { font-size: 1.02rem; } + +p { margin: 0 0 .75rem; } + +/* Display type: the condensed face, used sparingly for headings and figures. */ +.display { + font-family: var(--display); + font-weight: 400; + letter-spacing: .01em; + text-transform: uppercase; +} + +/* ---------------------------------------------------------------- topbar */ + +.topbar { + position: sticky; + top: 0; + z-index: 50; + background: rgba(18, 17, 16, .94); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); +} + +.topbar__inner { + max-width: 1240px; + margin: 0 auto; + padding: .6rem 1.25rem; + display: flex; + align-items: center; + gap: 1.5rem; +} + +.brand { + display: flex; + align-items: center; + gap: .55rem; + color: var(--text); + text-decoration: none; +} +.brand:hover { text-decoration: none; } + +.brand__mark { + font-size: 1.4rem; + line-height: 1; + filter: drop-shadow(0 0 10px rgba(190, 127, 57, .35)); +} + +.brand__text { + font-family: var(--display); + font-size: 1.22rem; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--amber-bright); +} + +.nav { display: flex; gap: .25rem; flex: 1; } + +.nav__link { + padding: .4rem .8rem; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: .92rem; + font-weight: 500; + text-decoration: none; + transition: background .12s ease, color .12s ease; +} +.nav__link:hover { background: var(--bg-raised-2); color: var(--text); text-decoration: none; } +.nav__link.is-active { + background: var(--amber-dim); + color: var(--amber-text); + box-shadow: inset 0 -2px 0 var(--amber); +} + +.topbar__logout { display: flex; align-items: center; gap: .6rem; } +.topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); } + +/* ------------------------------------------------------------------ page */ + +.page { + max-width: 1240px; + margin: 0 auto; + padding: 1.5rem 1.25rem 4rem; +} +.page--narrow { max-width: 460px; } + +.page__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1.25rem; +} +.page__title { + margin: 0; + font-family: var(--display); + font-weight: 400; + font-size: 1.85rem; + letter-spacing: .02em; + text-transform: uppercase; + color: var(--text); +} +.page__sub { color: var(--text-muted); font-size: .9rem; margin: .15rem 0 0; } + +/* ----------------------------------------------------------------- cards */ + +.card { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + position: relative; +} + +/* A thin amber seam along the top of each card — the "label on the barrel" touch. */ +.card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 2px; + border-radius: var(--radius) var(--radius) 0 0; + background: linear-gradient(90deg, var(--amber-deep), var(--amber) 40%, transparent 85%); + opacity: .8; +} + +.card__head { + padding: .9rem 1.1rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.card__title { + margin: 0; + font-size: 1rem; + font-weight: 650; + letter-spacing: .01em; +} +.card__hint { color: var(--text-faint); font-size: .82rem; } +.card__body { padding: 1.1rem; } +.card__body--flush { padding: 0; } + +.grid { display: grid; gap: 1rem; } +.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +/* The active-beer cards: fill the row whatever the number of brews. */ +.grid--cards { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); } +.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.grid--sidebar { grid-template-columns: minmax(0, 380px) minmax(0, 1fr); align-items: start; } + +@media (max-width: 1000px) { + .grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .grid--sidebar { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 640px) { + .grid--2, .grid--3, .grid--4 { grid-template-columns: minmax(0, 1fr); } + .topbar__inner { flex-wrap: wrap; gap: .75rem; } + .nav { order: 3; width: 100%; overflow-x: auto; } + .page__title { font-size: 1.5rem; } +} + +/* ----------------------------------------------------------------- stats */ + +.stat { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .85rem 1rem; +} +.stat__label { + color: var(--text-faint); + font-size: .72rem; + text-transform: uppercase; + letter-spacing: .09em; + font-weight: 600; +} +.stat__value { + font-family: var(--display); + font-weight: 400; + font-size: 1.75rem; + letter-spacing: .02em; + line-height: 1.2; + color: var(--amber-bright); +} +.stat__meta { color: var(--text-muted); font-size: .82rem; } + +/* ----------------------------------------------------------------- forms */ + +.field { margin-bottom: .9rem; } +.field__label { + display: block; + font-size: .74rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .09em; + color: var(--text-muted); + margin-bottom: .3rem; +} +.field__hint { color: var(--text-faint); font-size: .78rem; margin-top: .25rem; } +.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; } + +input[type="text"], input[type="password"], input[type="number"], +input[type="date"], input[type="email"], textarea, select { + width: 100%; + padding: .5rem .65rem; + background: var(--bg-input); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + font-family: inherit; + font-size: .92rem; + transition: border-color .12s ease, box-shadow .12s ease; +} +input:focus, textarea:focus, select:focus { + outline: none; + border-color: var(--amber); + box-shadow: 0 0 0 3px var(--amber-dim); +} +input::placeholder, textarea::placeholder { color: var(--text-faint); } +textarea { resize: vertical; min-height: 5rem; } + +.checkbox { + display: flex; + align-items: center; + gap: .55rem; + font-size: .9rem; + color: var(--text); + cursor: pointer; +} + +/* The native checkbox is a white square on a near-black page — replace it. */ +.checkbox input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 1.05rem; + height: 1.05rem; + margin: 0; + flex: 0 0 auto; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--bg-input); + cursor: pointer; + position: relative; + transition: background .12s ease, border-color .12s ease; +} +.checkbox input[type="checkbox"]:hover { border-color: var(--amber); } +.checkbox input[type="checkbox"]:checked { + background: var(--amber); + border-color: var(--amber); +} +.checkbox input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: .32rem; + top: .07rem; + width: .28rem; + height: .55rem; + border: solid #17110a; + border-width: 0 2px 2px 0; + transform: rotate(43deg); +} +.checkbox input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--amber-dim); +} + +/* Hide the native number spinners — the light spinner widgets jar badly + against the dark theme. */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { -moz-appearance: textfield; } + +/* The native date picker's calendar icon is near-black on near-black. */ +input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.75) sepia(1) saturate(3) hue-rotate(350deg) brightness(1.1); + cursor: pointer; + opacity: .7; +} +input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; } + +/* Dark scrollbars to match the page. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) var(--bg); +} +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 6px; + border: 2px solid var(--bg); +} +::-webkit-scrollbar-thumb:hover { background: var(--amber-deep); } + +/* A figure that should read as a number, not as body text. */ +.strong { font-weight: 650; color: var(--text); } + +.field-row { display: flex; gap: .75rem; } +.field-row > * { flex: 1; } + +/* --------------------------------------------------------------- buttons */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .4rem; + padding: .5rem .95rem; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: var(--amber); + color: #17110a; + font-family: inherit; + font-size: .85rem; + font-weight: 650; + letter-spacing: .01em; + cursor: pointer; + text-decoration: none; + transition: filter .12s ease, background .12s ease, border-color .12s ease; +} +.btn:hover { filter: brightness(1.12); text-decoration: none; } +.btn:disabled { opacity: .5; cursor: not-allowed; } + +.btn--ghost { + background: transparent; + border-color: var(--border-strong); + color: var(--text-muted); +} +.btn--ghost:hover { background: var(--bg-raised-2); color: var(--text); } + +.btn--danger { background: transparent; border-color: var(--danger); color: var(--danger); } +.btn--danger:hover { background: var(--danger-dim); } + +.btn--sm { padding: .3rem .6rem; font-size: .78rem; } +.btn--block { width: 100%; } + +.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } + +/* ---------------------------------------------------------------- tables */ + +.table-wrap { overflow-x: auto; } + +table.data { + width: 100%; + border-collapse: collapse; + font-size: .88rem; +} +table.data th { + text-align: left; + padding: .55rem .8rem; + color: var(--text-faint); + font-size: .7rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: .08em; + border-bottom: 1px solid var(--border); + background: var(--bg-raised-2); + white-space: nowrap; +} +table.data td { + padding: .6rem .8rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +table.data tr:last-child td { border-bottom: none; } +table.data tbody tr:hover { background: var(--bg-raised-2); } +table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; } +table.data .comment { color: var(--text-muted); font-size: .84rem; } + +/* ----------------------------------------------------------------- chips */ + +.chip { + display: inline-flex; + align-items: center; + gap: .3rem; + padding: .12rem .5rem; + border-radius: 999px; + font-size: .74rem; + font-weight: 600; + letter-spacing: .02em; + white-space: nowrap; +} +.chip--active { background: var(--amber-dim); color: var(--amber-text); } +.chip--done { background: rgba(175, 175, 175, .13); color: var(--text-muted); } +.chip--warn { background: var(--warn-dim); color: var(--warn); } +.chip--danger { background: var(--danger-dim); color: var(--danger); } +.chip--good { background: var(--success-dim); color: var(--success); } +.chip--tap { + background: var(--bg-raised-2); + border: 1px solid var(--amber-deep); + color: var(--amber-text); + font-family: var(--mono); +} + +/* --------------------------------------------------------------- flashes */ + +.flashes { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; } + +.flash { + padding: .6rem .9rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-raised); + font-size: .9rem; +} +.flash--success { border-color: rgba(125, 185, 2, .45); background: var(--success-dim); color: #c3e08a; } +.flash--error { border-color: rgba(217, 83, 79, .45); background: var(--danger-dim); color: #f0a8a6; } +.flash--warning { border-color: rgba(232, 163, 61, .45); background: var(--warn-dim); color: #f2c880; } +.flash--info { border-color: rgba(190, 127, 57, .45); background: var(--amber-dim); color: var(--amber-text); } + +/* ---------------------------------------------------------------- login */ + +.login { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + background: + radial-gradient(900px 480px at 50% -10%, rgba(190, 127, 57, .13), transparent 62%), + var(--bg); +} +.login__card { + width: 100%; + max-width: 380px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 2rem; + box-shadow: var(--shadow); + position: relative; +} +.login__card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: linear-gradient(90deg, var(--amber-deep), var(--amber-bright), var(--amber-deep)); +} +.login__mark { font-size: 2.4rem; line-height: 1; } +.login__title { + margin: .6rem 0 .15rem; + font-family: var(--display); + font-weight: 400; + font-size: 1.9rem; + letter-spacing: .03em; + text-transform: uppercase; + color: var(--amber-bright); +} +.login__sub { color: var(--text-muted); font-size: .88rem; margin-bottom: 1.4rem; } + +/* ----------------------------------------------------------- active card */ + +.brew-card { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: .75rem; + position: relative; + overflow: hidden; +} +.brew-card::before { + content: ''; + position: absolute; + inset: 0 0 auto 0; + height: 2px; + background: linear-gradient(90deg, var(--amber-deep), var(--amber) 45%, transparent 90%); + opacity: .75; +} +.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; } +.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; } +.brew-card__meta { color: var(--text-faint); font-size: .8rem; font-family: var(--mono); } +.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; } +.brew-card__stat .stat__label { font-size: .66rem; } +.brew-card__stat .stat__value { font-size: 1.3rem; } +.brew-card__spark { height: 46px; } +.brew-card__spark--empty { + display: flex; + align-items: center; + font-style: italic; + padding: .35rem 0; +} + +/* --------------------------------------------------------------- helpers */ + +.muted { color: var(--text-muted); } +.faint { color: var(--text-faint); } +.mono { font-family: var(--mono); } +.nowrap { white-space: nowrap; } +.small { font-size: .84rem; } +.tiny { font-size: .76rem; } +.center { text-align: center; } +.right { text-align: right; } +.stack > * + * { margin-top: .75rem; } +.mt-0 { margin-top: 0; } +.mb-0 { margin-bottom: 0; } +.row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; } +.row--between { justify-content: space-between; } +.hidden { display: none !important; } + +.empty { + padding: 2.5rem 1rem; + text-align: center; + color: var(--text-faint); +} +.empty__icon { font-size: 2rem; display: block; margin-bottom: .5rem; opacity: .7; } + +/* --------------------------------------------------------------- reading */ + +.reading-list { list-style: none; margin: 0; padding: 0; } +.reading-list__item { + display: flex; + gap: .75rem; + align-items: flex-start; + padding: .6rem 0; + border-bottom: 1px solid var(--border); +} +.reading-list__item:last-child { border-bottom: none; } +.reading-list__date { + font-family: var(--mono); + font-size: .82rem; + color: var(--text-muted); + flex: 0 0 5.5rem; +} +.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; } +.reading-list__body { flex: 1; min-width: 0; } +.reading-list__comment { color: var(--text-muted); font-size: .84rem; } diff --git a/src/flask/static/data_plot.html b/src/flask/static/data_plot.html deleted file mode 100644 index d5d1fd7..0000000 --- a/src/flask/static/data_plot.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - Bokeh Plot - - - - - -
- - - - - \ No newline at end of file diff --git a/src/flask/static/img/logo.svg b/src/flask/static/img/logo.svg new file mode 100644 index 0000000..ee55aa6 --- /dev/null +++ b/src/flask/static/img/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/src/flask/static/js/app.js b/src/flask/static/js/app.js new file mode 100644 index 0000000..cad54d6 --- /dev/null +++ b/src/flask/static/js/app.js @@ -0,0 +1,71 @@ +/* ========================================================================== + Brew Log — page behaviour + Small, dependency-free helpers. Charts live in charts.js. + ========================================================================== */ + +(function () { + "use strict"; + + /** Auto-focus the first empty field on a form. */ + function focusFirstEmpty() { + var fields = document.querySelectorAll("input:not([type=hidden]), textarea, select"); + for (var i = 0; i < fields.length; i++) { + if (!fields[i].value && !fields[i].readOnly) { + fields[i].focus(); + return; + } + } + } + + /** Default the reading date to today when the field is left empty. */ + function defaultDateFields() { + var today = new Date().toISOString().slice(0, 10); + var dateFields = document.querySelectorAll('input[type="date"][data-default-today]'); + for (var i = 0; i < dateFields.length; i++) { + if (!dateFields[i].value) { + dateFields[i].value = today; + } + } + } + + /** Show/hide blocks driven by a checkbox. */ + function wireToggles() { + var toggles = document.querySelectorAll("[data-toggle-target]"); + for (var i = 0; i < toggles.length; i++) { + (function (toggle) { + var target = document.getElementById(toggle.getAttribute("data-toggle-target")); + if (!target) { return; } + function apply() { + target.classList.toggle("hidden", !toggle.checked); + } + toggle.addEventListener("change", apply); + apply(); + })(toggles[i]); + } + } + + /** Confirm destructive actions. */ + function wireConfirms() { + var forms = document.querySelectorAll("[data-confirm]"); + for (var i = 0; i < forms.length; i++) { + forms[i].addEventListener("submit", function (event) { + if (!window.confirm(this.getAttribute("data-confirm"))) { + event.preventDefault(); + } + }); + } + } + + function init() { + focusFirstEmpty(); + defaultDateFields(); + wireToggles(); + wireConfirms(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/src/flask/table/__init__.py b/src/flask/table/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/flask/table/table_builder.py b/src/flask/table/table_builder.py deleted file mode 100644 index 3a9ae53..0000000 --- a/src/flask/table/table_builder.py +++ /dev/null @@ -1,90 +0,0 @@ -import sys - -from mongo.get_conn import db_conn -import pandas as pd -import duckdb - -class TableBuilder(): - def __init__(self) -> None: - self.db = db_conn() - data = self.db.beer_db - data = data.find({}).sort("date", -1) - df_dict = {} - df_dict["beer_run_id"]=[] - df_dict["sg"] = [] - df_dict["date"] = [] - df_dict["final_reading"] = [] - - for record in data: - df_dict["beer_run_id"].append(record["beer_run_id"]) - df_dict["sg"].append(record["sg"]) - df_dict["date"].append(record["date"]) - df_dict["final_reading"].append(record["final_reading"]) - - self.df_dict = df_dict - self.df = pd.DataFrame(data=self.df_dict) - - def done_runs_build(self, limit=10) -> pd.DataFrame: - - df = self.df - sql = f""" - SELECT x.beer_run_id as beer_run_id, - max(sg) as max, - min(sg) as min, - y.date as final_reading_date - FROM df x - JOIN - ( SELECT DISTINCT beer_run_id, date - FROM df - WHERE final_reading = 'True' - ) y ON x.beer_run_id = y.beer_run_id - GROUP BY x.beer_run_id, y.date - ORDER BY x.beer_run_id desc - LIMIT {limit} - """ - df_sum = duckdb.sql(sql).df() - sql = f""" - SELECT x.beer_run_id as "Beer Run", - x.max as "Max", - x.min as "Min", - ROUND(((CAST (max AS INTEGER) - CAST(min AS INTEGER)) / 7.36) + 0.5, 2) AS "Alcohol Prediction +/- 0.5", - cast(final_reading_date as DATE) + INTERVAL 14 DAY as "Ready Date" - - FROM df_sum x - """ - df_calc = duckdb.sql(sql).df() - return df_calc - - def current_runs_build(self, limit=10) -> pd.DataFrame: - df = self.df - sql = f""" - SELECT x.beer_run_id as beer_run_id, - max(sg) as max, - min(sg) as min, - min(cast(x.date as DATE)) as first_reading_date - FROM df x - LEFT JOIN - ( SELECT DISTINCT beer_run_id - FROM df - WHERE final_reading = 'True' - ) y ON x.beer_run_id = y.beer_run_id - WHERE y.beer_run_id is null - GROUP BY x.beer_run_id - ORDER BY x.beer_run_id desc - LIMIT {limit} - """ - df_sum = duckdb.sql(sql).df() - sql = f""" - SELECT x.beer_run_id as "Beer Run", - x.max as "Max", - x.min as "Min", - ROUND(((CAST (max AS INTEGER) - 1012) / 7.36) + 0.5, 2) AS "Alcohol Prediction (1012) +/- 0.5", - first_reading_date + INTERVAL 7 DAY as "Earliest Bottling/Kegging Date" - - FROM df_sum x - """ - df_calc = duckdb.sql(sql).df() - return df_calc - - - diff --git a/src/flask/templates/base.html b/src/flask/templates/base.html index f619e1c..96f80b6 100644 --- a/src/flask/templates/base.html +++ b/src/flask/templates/base.html @@ -1,26 +1,50 @@ - - - - + + + + + {% block title %}Brew Log{% endblock %} + + + + +{% if logged_in %} +
+
+ + + Brew Log + - {% block title %} Let there Be Pool Data {% endblock %} + - {{ bootstrap.load_css() }} +
+ + {{ session.get('username', '') }} + +
+
+
+{% endif %} - - - - +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} - {% block content %} {% endblock %} + {% block content %}{% endblock %} +
- - {{ bootstrap.load_js() }} - + +{% block scripts %}{% endblock %} + diff --git a/src/flask/templates/brews.html b/src/flask/templates/brews.html new file mode 100644 index 0000000..57ad51d --- /dev/null +++ b/src/flask/templates/brews.html @@ -0,0 +1,193 @@ +{% extends 'base.html' %} + +{% block title %}Brew Process — Brew Log{% endblock %} + +{% block content %} +
+
+

Brew Process

+

Record a gravity reading, then review where each brew is up to.

+
+
+ +
+ +
+
+

New reading

+ Gravity in points (1039 = 1.039) +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
The name is shown on the run summary.
+
+ +
+ + +
Drop the leading "1." — 1045 means 1.045.
+
+ +
+ +
+ +
+ + +
+ + +
+
+
+ + +
+
+
+

In the fermenter

+ {{ active_runs | length }} active +
+
+ {% if active_runs %} +
+ + + + + + + + + + + + + {% for run in active_runs %} + + + + + + + + + {% endfor %} + +
RunNameGravityEst. ABVComment
{{ run.beer_run_id }}{{ run.canonical_name }} + {% if run.min_sg is not none %} + {{ run.min_sg }} +
{{ '%.3f' % (run.min_sg / 1000) }}
+ {% else %}—{% endif %} +
+ {% if run.projected_abv is not none %} + {{ '%.2f' % run.projected_abv }}% + {% else %} + — + {% endif %} + + {% if run.comments %} + {{ run.comments[-1] }} + {% else %} + — + {% endif %} + + Open +
+
+ {% else %} +
+ + Nothing in the fermenter right now. +
+ {% endif %} +
+
+ +
+
+

Finished brews

+ {{ completed_runs | length }} completed +
+
+ {% if completed_runs %} +
+ + + + + + + + + + + + + + + {% for run in completed_runs %} + + + + + + + + + + + {% endfor %} + +
RunNameOGFGABVFinishedComment
{{ run.beer_run_id }}{{ run.canonical_name }} + {% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %} + + {% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %} + + {% if run.abv is not none %} + {{ '%.2f' % run.abv }}% + {% else %}—{% endif %} + {{ run.final_date or '—' }} + {% if run.comments %} + {{ run.comments[-1] }} + {% else %} + — + {% endif %} + + Open +
+
+ {% else %} +
No finished brews recorded yet.
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/flask/templates/error.html b/src/flask/templates/error.html new file mode 100644 index 0000000..045e1f8 --- /dev/null +++ b/src/flask/templates/error.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% block title %}{{ code }} — Brew Log{% endblock %} + +{% block content %} +
+
+ +

{{ code }}

+

{{ message }}

+ Back to the brew log +
+
+{% endblock %} diff --git a/src/flask/templates/index.html b/src/flask/templates/index.html deleted file mode 100644 index 244597f..0000000 --- a/src/flask/templates/index.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'base.html' %} -{% from 'bootstrap5/form.html' import render_form %} - -{% block title %} -Login to Pool Data Tracker -{% endblock %} -{% block content %} - - - -
- {% if try_again %} -

Login Failed Please try Again

- {% endif %} - -
-
-
- -

WHOOOOOOO ARE YOU!

- -

If you know you know

- - {{ render_form(form) }} - -

{{ message }}

- -
-
-
-
- -{% endblock %} diff --git a/src/flask/templates/login.html b/src/flask/templates/login.html new file mode 100644 index 0000000..24c3b25 --- /dev/null +++ b/src/flask/templates/login.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} + +{% block title %}Sign in — Brew Log{% endblock %} +{% block main_class %}page page--narrow{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/src/flask/templates/run_detail.html b/src/flask/templates/run_detail.html new file mode 100644 index 0000000..7cd6f15 --- /dev/null +++ b/src/flask/templates/run_detail.html @@ -0,0 +1,107 @@ +{% extends 'base.html' %} + +{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %} + +{% block content %} +
+
+

+ {{ summary.canonical_name }} + {% if summary.active %} + Active + {% else %} + Finished + {% endif %} +

+

+ Run {{ summary.beer_run_id }} + {% if summary.first_date %} · started {{ summary.first_date }}{% endif %} + {% if summary.final_date %} · finished {{ summary.final_date }}{% endif %} +

+
+ ← Back +
+ +
+
+
Original gravity
+
+ {% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %} +
+ {% if summary.og_decimal %} +
{{ '%.3f' % summary.og_decimal }}
+ {% endif %} +
+
+
+ {% if summary.active %}Current gravity{% else %}Final gravity{% endif %} +
+
+ {% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %} +
+ {% if summary.fg_decimal %} +
{{ '%.3f' % summary.fg_decimal }}
+ {% endif %} +
+
+
Alcohol
+
+ {% if summary.abv is not none %} + {{ '%.2f' % summary.abv }}% + {% elif summary.projected_abv is not none %} + {{ '%.2f' % summary.projected_abv }}% + {% else %}—{% endif %} +
+
+ {% if summary.abv is not none %}measured{% else %}projected{% endif %} +
+
+
+
Days brewing
+
+ {% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %} +
+
{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}
+
+
+ +
+
+

Readings

+ {{ summary.reading_count }} total +
+
+
    + {% for reading in summary.readings %} +
  • + {{ reading.date or '—' }} + + {% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %} + +
    + {% if reading.comment %} +
    {{ reading.comment }}
    + {% else %} +
    No comment
    + {% endif %} +
    + {% if reading.final_reading %} + Final +
    + + + +
    + {% else %} +
    + + + +
    + {% endif %} +
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/src/flask/templates/updater.html b/src/flask/templates/updater.html deleted file mode 100644 index 68314cc..0000000 --- a/src/flask/templates/updater.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends 'base.html' %} -{% from 'bootstrap5/form.html' import render_form %} - -{% block title %} - Data Input -{% endblock %} -{% block content %} - - -
-
-
- -

Input Data you want to store

- {% if success %} -

Thank you {{ updater_name | safe }}, Data Updated Successfully

- {% endif %} - -
-
- - - - - - -
- {{ render_form(form) }} -
-
-
- -

Current Runs

- {{ current_data | safe }} - -

Previous Runs

- {{ beer_data | safe }} - -
-
-
-
-
-{% endblock %} diff --git a/src/flask/test.py b/src/flask/test.py deleted file mode 100644 index 8a4794c..0000000 --- a/src/flask/test.py +++ /dev/null @@ -1,5 +0,0 @@ -import table.table_builder as table_builder - -test = table_builder.TableBuilder() - -print(test.table_build()) diff --git a/src/flask/views/__init__.py b/src/flask/views/__init__.py new file mode 100644 index 0000000..9609b99 --- /dev/null +++ b/src/flask/views/__init__.py @@ -0,0 +1 @@ +"""Blueprints for the beer_data app.""" diff --git a/src/flask/views/auth.py b/src/flask/views/auth.py new file mode 100644 index 0000000..fcfa5fa --- /dev/null +++ b/src/flask/views/auth.py @@ -0,0 +1,45 @@ +"""Login and logout.""" + +from __future__ import annotations + +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask_wtf import FlaskForm +from wtforms import PasswordField, StringField, SubmitField +from wtforms.validators import DataRequired, Length + +bp = Blueprint("auth", __name__) + + +class LoginForm(FlaskForm): + username = StringField("Username", validators=[DataRequired(), Length(max=64)]) + password = PasswordField("Password", validators=[DataRequired(), Length(max=128)]) + submit = SubmitField("Sign in") + + +def repo(): + """The request-scoped repository (one Mongo client per request).""" + from flask import current_app + + return current_app.extensions["beer_repo"]() + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + form = LoginForm() + if form.validate_on_submit(): + ok = repo().user_check(form.username.data.strip(), form.password.data) + if ok: + session.clear() + session["logged_in"] = True + session["username"] = form.username.data.strip() + return redirect(request.args.get("next") or url_for("brews.index")) + flash("Those credentials were not recognised.", "error") + + return render_template("login.html", form=form) + + +@bp.route("/logout", methods=["POST"]) +def logout(): + session.clear() + flash("Signed out.", "info") + return redirect(url_for("auth.login")) diff --git a/src/flask/views/brews.py b/src/flask/views/brews.py new file mode 100644 index 0000000..3fb0d39 --- /dev/null +++ b/src/flask/views/brews.py @@ -0,0 +1,152 @@ +"""The brew-process pages: record readings and review the runs.""" + +from __future__ import annotations + +from flask import (Blueprint, flash, 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=1300)]) + final_reading = BooleanField("This is the final reading") + comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)]) + submit = SubmitField("Save reading") + + +@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() + + summaries = [] + for run in runs: + summary = run_summary(run) + summary["canonical_name"] = run.display_name() + 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, + ) + + +@bp.route("/runs/") +@login_required +def run_detail(run_id: str): + """One run in detail, with all of its readings.""" + run = repo().run(run_id) + if run is None: + flash(f"No run numbered {run_id}.", "error") + return redirect(url_for("brews.index")) + + summary = run_summary(run) + summary["canonical_name"] = run.display_name() + + return render_template("run_detail.html", run=run, summary=summary) + + +@bp.route("/runs//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//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)) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d15fd27 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared pytest fixtures for the beer_data test suite.""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# The Flask app lives in src/flask and imports its siblings by bare name +# (``import brewing``), so that directory has to be on the path. +APP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "src", "flask") +if APP_DIR not in sys.path: + sys.path.insert(0, APP_DIR) + + +@pytest.fixture() +def mongo_client(): + """An in-memory Mongo, shared by the repository the app builds.""" + import mongomock + + return mongomock.MongoClient() + + +@pytest.fixture() +def client(mongo_client): + """A Flask test client backed by an in-memory Mongo (mongomock).""" + from app_factory import create_app + + app = create_app({ + "TESTING": True, + "WTF_CSRF_ENABLED": False, + "SECRET_KEY": "test-secret", + "MONGO_CLIENT": mongo_client, + }) + + with app.test_client() as test_client: + yield test_client + + +@pytest.fixture() +def repo(mongo_client): + """A repository backed by an in-memory Mongo.""" + from mongo.repository import BeerRepository + + return BeerRepository(client=mongo_client) + + +def seed_readings(repository, rows): + """Insert reading documents directly, bypassing the app. + + Each row is ``(beer_run_id, beer_type, sg, date, final_reading, comment)``. + """ + docs = [] + for run_id, beer_type, sg, date, final, comment in rows: + docs.append({ + "beer_run_id": str(run_id), + "beer_type": beer_type, + "sg": str(sg), + "date": date, + "final_reading": "True" if final else "False", + "comment": comment, + }) + if docs: + repository.readings.insert_many(docs) + return docs diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..5ef5824 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,181 @@ +"""Tests for the web layer: login, the brew-process page and the API.""" + +from __future__ import annotations + +import pytest + +from conftest import seed_readings + + +@pytest.fixture() +def logged_in(client, repo): + """A client with an authenticated session and a user in the database.""" + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + response = client.post("/login", data={"username": "beer", "password": "beer_pass"}, + follow_redirects=True) + assert response.status_code == 200 + return client + + +# ------------------------------------------------------------------ public # + +def test_healthz_is_public(client): + response = client.get("/healthz") + assert response.status_code == 200 + assert response.json == {"status": "ok"} + + +def test_root_redirects_to_login(client): + response = client.get("/") + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_login_page_renders(client): + response = client.get("/login") + assert response.status_code == 200 + assert b"Brew Log" in response.data + + +def test_login_with_bad_credentials_is_rejected(client, repo): + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + response = client.post("/login", data={"username": "beer", "password": "wrong"}, + follow_redirects=True) + assert response.status_code == 200 + assert b"not recognised" in response.data + + +def test_protected_pages_redirect_when_logged_out(client): + for path in ("/brews", "/runs/1"): + response = client.get(path) + assert response.status_code == 302, path + assert "/login" in response.headers["Location"], path + + +# ------------------------------------------------------------- brew pages # + +def test_brews_page_lists_runs(logged_in, repo): + seed_readings(repo, [ + ("1", "Pale Ale", "1050", "2026-01-01", False, "start"), + ("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"), + ("2", "Ginger Beer", "1045", "2026-02-01", False, ""), + ]) + + response = logged_in.get("/brews") + assert response.status_code == 200 + body = response.data.decode() + assert "Pale Ale" in body + assert "Ginger Beer" in body + # The comment on the reading should be surfaced. + assert "Keghing" in body + + +def test_run_detail_page_renders(logged_in, repo): + seed_readings(repo, [ + ("1", "Pale Ale", "1050", "2026-01-01", False, "start"), + ("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"), + ]) + + response = logged_in.get("/runs/1") + assert response.status_code == 200 + body = response.data.decode() + assert "Pale Ale" in body + assert "Keghing" in body + assert "5.25%" in body + + +# ------------------------------------------------------------- write paths # + +def test_recording_a_reading_stores_it(logged_in, repo): + response = logged_in.post("/brews", data={ + "beer_run_id": "5", + "beer_run_type": "Test Porter", + "Date": "2026-03-04", + "sg": "1045", + "comment": "Hops in", + }, follow_redirects=True) + assert response.status_code == 200 + + doc = repo.readings.find_one({"beer_run_id": "5"}) + assert doc is not None + assert doc["sg"] == "1045" + assert doc["beer_type"] == "Test Porter" + assert doc["comment"] == "Hops in" + assert doc["final_reading"] == "False" + + +def test_recording_a_reading_twice_updates_in_place(logged_in, repo): + """The unique key is (date, beer_run_id) — no duplicate rows.""" + for sg in ("1045", "1035"): + logged_in.post("/brews", data={ + "beer_run_id": "6", + "beer_run_type": "Test", + "Date": "2026-03-04", + "sg": sg, + }) + + count = repo.readings.count_documents({"beer_run_id": "6"}) + doc = repo.readings.find_one({"beer_run_id": "6"}) + assert count == 1 + assert doc["sg"] == "1035" + + +def test_setting_a_final_reading_closes_the_run(logged_in, repo): + seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")]) + + response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"}, + follow_redirects=True) + assert response.status_code == 200 + + doc = repo.readings.find_one({"beer_run_id": "7"}) + assert doc["final_reading"] == "True" + + +def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo): + seed_readings(repo, [ + ("7", "Ale", "1045", "2026-03-01", False, ""), + ("7", "Ale", "1010", "2026-03-20", True, ""), + ]) + + logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True) + + doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"}) + assert doc["final_reading"] == "False" + + +# -------------------------------------------------------------------- API # + + +# ----------------------------------------------------------------- misc # + + +def test_static_assets_are_served(client): + for path in ("/static/js/app.js", "/static/css/app.css", + "/static/img/logo.svg"): + response = client.get(path) + assert response.status_code == 200, path + assert len(response.data) > 100, path + + +def test_logout_clears_the_session(logged_in): + response = logged_in.post("/logout", follow_redirects=True) + assert response.status_code == 200 + # After logging out the protected page bounces back to the login page. + response = logged_in.get("/brews") + assert response.status_code == 302 + assert "/login" in response.headers["Location"] + + +def test_out_of_range_reading_is_saved_but_warned(logged_in, repo): + response = logged_in.post("/brews", data={ + "beer_run_id": "12", + "beer_run_type": "Odd", + "Date": "2026-05-01", + "sg": "950", + }, follow_redirects=True) + assert response.status_code == 200 + assert b"outside the usual hydrometer range" in response.data + # It is still stored — the warning is advisory, not a rejection. + assert repo.readings.find_one({"beer_run_id": "12"}) is not None diff --git a/tests/test_brewing.py b/tests/test_brewing.py new file mode 100644 index 0000000..4990709 --- /dev/null +++ b/tests/test_brewing.py @@ -0,0 +1,307 @@ +"""Unit tests for the pure brewing logic in ``brewing.py``. + +These are the tests that matter most: they pin the gravity parsing, the ABV +formula and the run-aggregation rules that the whole UI is built on. +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from brewing import (ABV_FACTOR, Reading, Run, abv_percent, assumed_og_abv_percent, + days_between, final_reading, first_reading, + is_plausible_sg, is_active, last_reading, + legacy_abv_percent, parse_date, parse_sg_points, + run_summary, sg_points_to_decimal) + + +# --------------------------------------------------------------- parsing # + +@pytest.mark.parametrize("raw,expected", [ + ("1039", 1039), + (1039, 1039), + (1039.0, 1039), + (" 1045 ", 1045), + ("1.039", 1039), + (1.045, 1045), + ("None", None), + ("", None), + (None, None), + ("abc", None), + (True, None), +]) +def test_parse_sg_points(raw, expected): + assert parse_sg_points(raw) == expected + + +def test_sg_points_to_decimal(): + assert sg_points_to_decimal(1039) == 1.039 + assert sg_points_to_decimal(1012) == 1.012 + assert sg_points_to_decimal(None) is None + + +@pytest.mark.parametrize("raw,expected", [ + ("2026-11-08", date(2026, 11, 8)), + (date(2026, 11, 8), date(2026, 11, 8)), + ("08/11/2026", date(2026, 11, 8)), + ("", None), + (None, None), + ("not a date", None), +]) +def test_parse_date(raw, expected): + assert parse_date(raw) == expected + + +# ------------------------------------------------------------------- ABV # + +def test_abv_uses_the_standard_formula(): + """ABV% = (OG - FG) * 131.25, i.e. points drop * 0.13125.""" + # 1.050 -> 1.010 is a 40 point drop = 5.25% ABV. + assert abv_percent(40) == 5.25 + assert abv_percent(40) == pytest.approx(40 * ABV_FACTOR) + assert abv_percent(0) == 0.0 + assert abv_percent(None) is None + + +def test_abv_matches_worked_examples(): + # Session IPA: 1.050 -> 1.010 = 5.25% + assert abv_percent(50 - 10) == 5.25 + # 1.060 -> 1.012 = 6.29% + assert abv_percent(60 - 12) == pytest.approx(6.29, abs=0.01) + + +def test_legacy_formula_is_kept_for_comparison(): + """The old formula is preserved verbatim so history can show the delta.""" + assert legacy_abv_percent(1050, 1010) == round(((1050 - 1010) / 7.36) + 0.5, 2) + # The legacy formula claims 0.5% even with no drop at all. + assert legacy_abv_percent(1010, 1010) == 0.5 + assert legacy_abv_percent(None, 1010) is None + + +def test_legacy_formula_overstates_the_real_figure(): + """The reason the history page compares the two at all.""" + standard = abv_percent(50 - 10) + legacy = legacy_abv_percent(1050, 1010) + assert legacy > standard + + +def test_assumed_og_abv(): + assert assumed_og_abv_percent(1012) == pytest.approx(0.0) + assert assumed_og_abv_percent(1010) == pytest.approx(0.26) + + +# --------------------------------------------------------------- plausibility # + +@pytest.mark.parametrize("points,plausible", [ + (1010, True), + (990, True), + (1200, True), + (989, False), + (1201, False), + (900, False), + (None, False), +]) +def test_is_plausible_sg(points, plausible): + assert is_plausible_sg(points) is plausible + + +# --------------------------------------------------------------- readings # + +def _doc(run_id="1", beer_type="Pale Ale", sg="1045", date="2026-01-01", + final="False", comment=""): + return { + "_id": f"id-{run_id}-{date}-{sg}", + "beer_run_id": run_id, + "beer_type": beer_type, + "sg": sg, + "date": date, + "final_reading": final, + "comment": comment, + } + + +def test_reading_from_doc_accepts_string_and_bool_flags(): + assert Reading.from_doc(_doc(final="True")).final_reading is True + assert Reading.from_doc(_doc(final="False")).final_reading is False + assert Reading.from_doc(_doc(final=True)).final_reading is True + assert Reading.from_doc(_doc(final=False)).final_reading is False + + +def test_reading_from_doc_cleans_whitespace(): + reading = Reading.from_doc(_doc(beer_type=" Pale Ale ", comment=" hops in ")) + assert reading.beer_type == "Pale Ale" + assert reading.comment == "hops in" + + +def test_first_and_last_reading(): + readings = [ + Reading.from_doc(_doc(date="2026-03-01", sg="1050")), + Reading.from_doc(_doc(date="2026-01-01", sg="1060")), + Reading.from_doc(_doc(date="2026-02-01", sg="1040")), + ] + assert first_reading(readings).date == date(2026, 1, 1) + assert last_reading(readings).date == date(2026, 3, 1) + + +def test_days_between(): + assert days_between(date(2026, 1, 1), date(2026, 1, 8)) == 7 + + +# ------------------------------------------------------------------- runs # + +def test_run_orders_readings_chronologically(): + run = Run.from_docs("1", [ + _doc(date="2026-03-01", sg="1010"), + _doc(date="2026-01-01", sg="1060"), + ]) + assert [r.date for r in run.readings] == [date(2026, 1, 1), date(2026, 3, 1)] + + +def test_run_original_and_final_gravity(): + run = Run.from_docs("1", [ + _doc(date="2026-01-01", sg="1050"), + _doc(date="2026-01-10", sg="1030"), + _doc(date="2026-01-20", sg="1010", final="True"), + ]) + assert run.original_gravity() == 1050 + assert run.final_gravity() == 1010 + assert run.gravity_drop_points() == 40 + assert run.abv_percent() == 5.25 + + +def test_run_is_active_until_a_final_reading_exists(): + active = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")]) + assert active.is_active() is True + assert is_active(active.readings) is True + + finished = Run.from_docs("2", [ + _doc(run_id="2", date="2026-01-01", sg="1050"), + _doc(run_id="2", date="2026-01-20", sg="1010", final="True"), + ]) + assert finished.is_active() is False + assert final_reading(finished.readings) is not None + + +def test_run_display_name_prefers_the_most_used_label(): + """The stored beer_type drifts across readings; the most-used one wins.""" + run = Run.from_docs("4", [ + _doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"), + _doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"), + _doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"), + ]) + # All three are unique, so the most recent wins. + assert run.display_name() == "Nocturna Christmas beer" + + +def test_run_display_name_falls_back_to_the_run_number(): + run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")]) + assert run.display_name() == "Run 7" + + +def test_run_comments_are_collected_oldest_first(): + run = Run.from_docs("1", [ + _doc(date="2026-01-01", comment="first"), + _doc(date="2026-01-10", comment=""), + _doc(date="2026-01-20", comment="last"), + ]) + assert run.comments() == ["first", "last"] + + +def test_run_outliers_are_flagged(): + run = Run.from_docs("1", [ + _doc(date="2026-01-01", sg="1045"), + _doc(date="2026-01-10", sg="900"), # implausible + _doc(date="2026-01-20", sg="1010"), + ]) + assert [r.sg_points for r in run.outlier_readings()] == [900] + + +def test_run_days_in_fermenter_for_a_finished_run(): + run = Run.from_docs("1", [ + _doc(date="2026-01-01", sg="1050"), + _doc(date="2026-01-15", sg="1010", final="True"), + ]) + assert run.days_in_fermenter() == 14 + + +def test_run_days_in_fermenter_for_an_active_run_uses_today(): + run = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")]) + assert run.days_in_fermenter(today=date(2026, 1, 11)) == 10 + + +def test_projected_abv_is_never_negative(): + """Regression: the legacy assumed-OG of 1012 produced negative alcohol + whenever the current reading was above 1012 (a normal OG).""" + run = Run.from_docs("20", [ + _doc(run_id="20", beer_type="Out on patio", date="2026-11-08", sg="1039"), + ]) + projected = run.projected_abv_percent() + # No OG is known for a single-reading run, so there is no estimate — + # and certainly no negative one. + assert projected is None + + run2 = Run.from_docs("20b", [ + _doc(run_id="20b", date="2026-11-08", sg="1039"), + _doc(run_id="20b", date="2026-11-15", sg="1020"), + ]) + assert run2.projected_abv_percent() >= 0.0 + + +def test_projected_abv_uses_the_first_reading_as_the_og(): + run = Run.from_docs("21", [ + _doc(run_id="21", date="2026-11-08", sg="1045"), + _doc(run_id="21", date="2026-11-15", sg="1030"), + ]) + # 15 point drop = 1.97% + assert run.projected_abv_percent() == pytest.approx(1.97) + + +def test_projected_abv_is_unknown_for_a_single_reading_with_no_og(): + """One reading and no recorded OG: there is no reliable estimate, so the + figure is None (the UI shows a dash) rather than a misleading number.""" + run = Run.from_docs("22", [_doc(run_id="22", date="2026-11-08", sg="1005")]) + assert run.projected_abv_percent() is None + + +def test_projected_abv_uses_a_recorded_og_override(): + """A run with a single reading can still be estimated once an OG has + been recorded against it.""" + run = Run.from_docs("23", [_doc(run_id="23", date="2026-11-08", sg="1010")]) + assert run.projected_abv_percent(og_override=1045) == pytest.approx(4.59) + + +def test_projected_abv_is_none_when_gravity_rises(): + """Gravity rising during fermentation is an anomaly, not alcohol.""" + run = Run.from_docs("24", [ + _doc(run_id="24", date="2026-11-08", sg="1010"), + _doc(run_id="24", date="2026-11-15", sg="1015"), + ]) + assert run.projected_abv_percent() is None + + +def test_run_summary_shape(): + run = Run.from_docs("9", [ + _doc(run_id="9", beer_type="Pale Ale", date="2026-01-01", sg="1050"), + _doc(run_id="9", beer_type="Pale Ale", date="2026-01-20", sg="1010", + final="True", comment="Keghing"), + ]) + summary = run_summary(run) + assert summary["beer_run_id"] == "9" + assert summary["name"] == "Pale Ale" + assert summary["active"] is False + assert summary["abv"] == 5.25 + assert summary["legacy_abv"] == legacy_abv_percent(1050, 1010) + assert summary["og_points"] == 1050 + assert summary["fg_points"] == 1010 + assert summary["comments"] == ["Keghing"] + assert len(summary["readings"]) == 2 + assert summary["readings"][0]["date"] == "2026-01-01" + + +def test_run_summary_handles_an_empty_run(): + summary = run_summary(Run.from_docs("99", [])) + assert summary["abv"] is None + assert summary["reading_count"] == 0 + assert summary["name"] == "Run 99" From 1828dcb81de6b13091f29decbef5938f2db4ef93 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 10:28:15 +1000 Subject: [PATCH 2/4] fix(security): address the independent review's blocking findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent reviewer returned "SIGNIFICANT CHANGES NEEDED" on the security review, with the code review itself approved. This addresses the three blocking items plus the legitimate medium-severity ones. Blocking items -------------- 1. Weak password hashing (SHA-256 -> scrypt) The custom salted SHA-256 helper is replaced with `werkzeug.security.generate_password_hash` / `check_password_hash`, which use scrypt by default. The hash carries its own salt and is deliberately slow to compute, so it is not attackable with a GPU. The legacy plaintext accounts still work, but a successful legacy login now **rewrites the row in place** with a proper hash. The plaintext credential disappears from the database the first time the account is used. Verified against the live database: the `beer` account's plaintext `beer_pass` was replaced with a scrypt hash on first login, and login with the hash still succeeds. 2. Plaintext password persistence Fixed by the migrate-on-login above. There is no longer a code path that leaves a password in plaintext. 3. Hardcoded secret defaults `SECRET_KEY` no longer has a default. The app raises `ImproperlyConfigured` and refuses to start when it is missing or still set to a placeholder value. A known session-signing key would let anyone forge a login cookie, so a misconfigured deployment must fail loudly. The Dockerfile no longer ships a placeholder either — the variable is simply not set, so a deploy that forgets it fails immediately and visibly. Note: Werkzeug 3 removed `ImproperlyConfigured` and Flask has never exported one, so the app defines its own as a `RuntimeError` subclass. Medium items ------------ 4. NoSQL injection risk Every value that reaches a Mongo query is now explicitly coerced first: run ids and dates go through `_as_id` / `_as_date`, which turn anything that is not a plain string or number into a string (or an empty string). Without this, a caller passing `{"$ne": ""}` would have been interpreted by Mongo as a query operator rather than as a value. Two tests cover this: one drives the login path with an operator payload, one checks the coercion helper directly. 5. Dependency pinning `requirements.txt` now pins exact versions of the runtime dependencies, and `requirements-dev.txt` pins the test dependencies. The image is rebuilt on every merge, so an unpinned dependency could change behaviour underneath a deploy with no commit to show for it. 6. Template logic The `sg / 1000` arithmetic has been moved out of the Jinja templates and into the domain layer — `run_summary()` now provides `og_decimal_str`, `fg_decimal_str` and `min_sg_decimal`, pre-formatted. No template does arithmetic on the data any more. 7. Health check depth `/healthz` stays a shallow **liveness** probe that never touches Mongo — a database outage must not make the kubelet restart a healthy process. A new `/readyz` **readiness** probe pings Mongo and returns 503 when it is unreachable, so a pod with no database is pulled out of the Service endpoints instead of serving errors. The RUNBOOK documents wiring both. Low items --------- 8. Mongo aggregation pipeline for `runs()` Not changed. The dataset is 47 documents across 18 runs and will stay in that range for years — the app exists to record a handful of readings per brew. Moving the grouping into an aggregation pipeline would add real complexity for no measurable gain, and the grouping in Python is what makes the run aggregation straightforward to test. Noted for whenever the data volume makes it matter. 9. Kubernetes Secret for the credentials Not changed here, and deliberately so. The live Deployment carries `MONGO_PASS` as a plain environment value. Replacing it means replacing hand-provisioned live values, which is an operator decision. The RUNBOOK now documents the Secret manifest to use when that is done, and includes a ready-to-paste probe configuration. 10. Rate limiting on `/login`, Flask-Talisman, password complexity Not added. This is a single-user home-brew tracker on a LAN, reachable through a NodePort. Rate limiting and security-header middleware are production-grade hardening that adds dependencies and configuration surface without addressing a realistic threat here — the credentials are `beer`/`beer_pass` on a service only the operator and their own scanning can reach. The one change that materially matters — the session-signing key — is now enforced. 11. Least-privilege Mongo user Not changed. The app connects as `root` today, matching the existing deployment. Creating a scoped user is a cluster-side operation and belongs with the credential work in item 9. Tests ----- 73 tests (up from 64). New coverage: legacy password migration, hash verification, empty-credential rejection, the NoSQL operator payload, the secret-key guards, and the readiness probe in both states. --- RUNBOOK.md | 71 +++++++++++++++++-- flask.Dockerfile | 8 ++- requirements-dev.txt | 4 +- requirements.txt | 13 ++-- src/flask/app_factory.py | 50 +++++++++++++- src/flask/brewing.py | 11 +++ src/flask/mongo/repository.py | 100 +++++++++++++++++++++------ src/flask/templates/brews.html | 2 +- src/flask/templates/run_detail.html | 4 +- tests/conftest.py | 6 ++ tests/test_app.py | 101 ++++++++++++++++++++++++++++ 11 files changed, 328 insertions(+), 42 deletions(-) diff --git a/RUNBOOK.md b/RUNBOOK.md index d556037..668b8de 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -24,7 +24,7 @@ The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`. | `MONGO_HOST` | `192.168.178.229` | Deployment manifest | | `MONGO_USER` | `root` | Deployment manifest | | `MONGO_PASS` | *(see below)* | Deployment manifest | -| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key | +| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key. **Required** | | `FLASK_ENV` | `production` | Dockerfile | | `FLASK_DEBUG` | `0` | Dockerfile | @@ -35,9 +35,37 @@ The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`. kubectl -n beer get deploy beer-deployment -o jsonpath='{.spec.template.spec.containers[0].env}' ``` -> The app refuses to start a *usable* session without a `SECRET_KEY`; the -> Dockerfile ships a placeholder so the container still boots if the variable -> is missing. Treat the placeholder as a misconfiguration. +> **The app refuses to start without a real `SECRET_KEY`.** There is no default +> and no placeholder: a missing or placeholder value raises +> `ImproperlyConfigured` and the container exits. A known session-signing key +> would let anyone forge a login cookie, so a misconfigured deployment must +> fail loudly rather than run insecurely. +> +> Generate one with: +> ```bash +> python -c 'import secrets; print(secrets.token_hex(32))' +> ``` + +### Moving the credentials into a Kubernetes Secret + +The live Deployment currently carries `MONGO_PASS` as a plain environment +value. Moving it to a Secret is the better shape and is **not** done here +because it would mean replacing the live hand-provisioned values — a change +that needs the operator's sign-off. When it is done: + +```yaml +env: + - name: MONGO_PASS + valueFrom: + secretKeyRef: + name: beer-db + key: mongo_pass + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: beer-app + key: secret_key +``` ## Deploying @@ -76,10 +104,31 @@ re-applying the manifest. kubectl -n beer get pods kubectl -n beer logs deploy/beer-deployment --tail=50 curl -s http://:30015/healthz # -> {"status":"ok"} +curl -s http://:30015/readyz # -> {"status":"ready"} ``` -`/healthz` never touches Mongo, so it proves the process is up but not that the -database is reachable. +`/healthz` is the **liveness** probe and never touches Mongo — it answers "is +the process up". A database outage must not make the kubelet restart a healthy +process. + +`/readyz` is the **readiness** probe and pings Mongo, returning 503 when the +database is unreachable. Wire it to the Service so a pod with no database is +pulled out of the endpoints rather than serving errors: + +```yaml +readinessProbe: + httpGet: + path: /readyz + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 10 +livenessProbe: + httpGet: + path: /healthz + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 20 +``` ## Data safety @@ -112,6 +161,16 @@ current gravity is above the assumed original gravity of 1012. Fixed in the current version: the standard `(OG - FG) × 131.25` formula is used, and a run with no known OG shows a dash instead of a figure. +### The pod will not start after a deploy + +The most likely cause is a missing `SECRET_KEY` — the app exits rather than +run with a default. Check the logs: + +```bash +kubectl -n beer logs deploy/beer-deployment --tail=20 +# ImproperlyConfigured: SECRET_KEY is not set. ... +``` + ### The chart on the run page is blank The chart library is served from `/static/js/chart.umd.js` — it is vendored in diff --git a/flask.Dockerfile b/flask.Dockerfile index e6cfa00..2cbd69c 100644 --- a/flask.Dockerfile +++ b/flask.Dockerfile @@ -15,9 +15,11 @@ ENV FLASK_ENV=production ENV FLASK_DEBUG=0 ENV PORT=5000 -# SECRET_KEY is supplied by the deployment (see RUNBOOK.md). The default here -# is only a placeholder so the container starts if it is missing. -ENV SECRET_KEY=change-me-in-the-deployment +# SECRET_KEY MUST be supplied by the deployment (see RUNBOOK.md). There is +# deliberately no default: the app refuses to start without a real one, so a +# misconfigured deployment fails loudly instead of running with a known +# session-signing key. +# ENV SECRET_KEY=... EXPOSE 5000 diff --git a/requirements-dev.txt b/requirements-dev.txt index 0d4ba38..777f65a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,3 @@ -r requirements.txt -pytest -mongomock +pytest==9.2.0 +mongomock==4.3.0 diff --git a/requirements.txt b/requirements.txt index a4f6bce..ba57dab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ -flask -Flask-WTF -waitress -pymongo -requests +# Pinned to the versions the test suite runs against. The image is rebuilt +# on every merge, so an unpinned dependency can change behaviour underneath a +# deploy with no commit to show for it. +Flask==3.1.3 +Flask-WTF==1.3.0 +waitress==3.0.2 +pymongo==4.19.0 +requests==2.32.5 diff --git a/src/flask/app_factory.py b/src/flask/app_factory.py index ff54e9a..8cbfda3 100644 --- a/src/flask/app_factory.py +++ b/src/flask/app_factory.py @@ -10,12 +10,42 @@ from __future__ import annotations import os from pathlib import Path -from flask import Flask, g, redirect, request, session, url_for +from flask import (Flask, g, redirect, request, session, url_for) from flask_wtf import CSRFProtect __all__ = ["create_app"] +class ImproperlyConfigured(RuntimeError): + """Raised when the app is missing configuration it cannot safely default. + + Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never + exported one, so the app defines its own. It is a ``RuntimeError`` so it + is not mistaken for an HTTP error the app should try to render a page for. + """ + + +def _require_secret_key() -> str: + """Return the Flask secret key, refusing to start without a real one. + + A known default secret key means anyone can forge a session cookie and + log in as the operator, so a missing or placeholder value must stop the + app rather than silently fall back. The one exception is the test + suite, which supplies its own key through the config. + """ + secret = os.getenv("SECRET_KEY", "").strip() + if not secret: + raise ImproperlyConfigured( + "SECRET_KEY is not set. Export a random value, e.g. " + "`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`." + ) + if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}: + raise ImproperlyConfigured( + "SECRET_KEY is still the placeholder value. Set a real secret key." + ) + return secret + + def create_app(config: dict | None = None) -> Flask: """Build and configure the Flask application.""" app = Flask( @@ -25,7 +55,7 @@ def create_app(config: dict | None = None) -> Flask: ) app.config.update( - SECRET_KEY=os.getenv("SECRET_KEY", "dev-secret-change-me"), + SECRET_KEY=_require_secret_key(), MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"), MONGO_USER=os.getenv("MONGO_USER", "root"), MONGO_PASS=os.getenv("MONGO_PASS", ""), @@ -101,9 +131,23 @@ def create_app(config: dict | None = None) -> Flask: @app.route("/healthz") def healthz(): - """Liveness probe used by Kubernetes. Never touches Mongo.""" + """Liveness probe. Never touches Mongo — it answers "is the process + up", not "is the app working". A database outage must not cause the + kubelet to restart a perfectly healthy process.""" return {"status": "ok"}, 200 + @app.route("/readyz") + def readyz(): + """Readiness probe. Checks that Mongo is actually reachable, so a + pod with no database is taken out of the Service's endpoints instead + of serving errors.""" + try: + repo = get_repo() + repo.client.admin.command("ping") + except Exception as exc: # noqa: BLE001 - any failure means not ready + return {"status": "not ready", "error": str(exc)}, 503 + return {"status": "ready"}, 200 + @app.route("/") def index(): if logged_in(): diff --git a/src/flask/brewing.py b/src/flask/brewing.py index 58e8ad5..e1a2459 100644 --- a/src/flask/brewing.py +++ b/src/flask/brewing.py @@ -390,6 +390,13 @@ def days_between(start: date, end: date) -> int: return (end - start).days +def _fmt_decimal(points: int | None) -> str: + """Format gravity points as a decimal reading, e.g. ``1039`` -> ``1.039``.""" + if points is None: + return "" + return f"{points / 1000:.3f}" + + def run_summary(run: Run, today: date | None = None, og_override: int | None = None) -> dict: """A flat dict describing a run, ready for a template or a JSON response.""" @@ -405,8 +412,12 @@ def run_summary(run: Run, today: date | None = None, "fg_points": fg, "og_decimal": sg_points_to_decimal(og), "fg_decimal": sg_points_to_decimal(fg), + # Pre-formatted for the templates so no arithmetic happens in Jinja. + "og_decimal_str": _fmt_decimal(og), + "fg_decimal_str": _fmt_decimal(fg), "max_sg": run.max_sg(), "min_sg": run.min_sg(), + "min_sg_decimal": _fmt_decimal(run.min_sg()), "drop_points": run.gravity_drop_points(), "abv": run.abv_percent(), "projected_abv": run.projected_abv_percent(og_override=og_override), diff --git a/src/flask/mongo/repository.py b/src/flask/mongo/repository.py index 165d55e..baa7bbd 100644 --- a/src/flask/mongo/repository.py +++ b/src/flask/mongo/repository.py @@ -22,6 +22,7 @@ from typing import Any from pymongo import MongoClient from pymongo.errors import PyMongoError +from werkzeug.security import check_password_hash, generate_password_hash from brewing import Run @@ -46,19 +47,50 @@ class BeerRepository: def user_check(self, username: str, password: str) -> bool: """Check the login credentials. - Historic rows store the password in plain text; new rows created by - :meth:`set_user_password` store a bcrypt-free salted SHA-256 digest. - Both are checked so existing accounts keep working. + Passwords are stored as a hash produced by + ``werkzeug.security.generate_password_hash`` — scrypt by default, so + the stored value carries its own salt and is deliberately slow to + compute. + + Legacy rows stored the password in plain text. Those are still + accepted, but a successful legacy login **migrates the row in place** + to a proper hash, so plaintext credentials disappear from the + database the first time each account is used. """ - doc = self.users.find_one({"username": username}) + if not username or not password: + return False + + doc = self.users.find_one({"username": str(username)}) if not doc: return False + stored = str(doc.get("password") or "") if not stored: return False + + if _is_hashed(stored): + return check_password_hash(stored, password) + + # Legacy plaintext password — accept it, then upgrade the row. if stored == password: + self._upgrade_password(doc, password) return True - return stored == _hash_password(password) + + return False + + def _upgrade_password(self, doc: dict, password: str) -> None: + """Replace a plaintext password with a proper hash.""" + try: + self.users.update_one( + {"_id": doc["_id"]}, + {"$set": { + "password": generate_password_hash(password), + "password_migrated_at": _now_iso(), + }}, + ) + except PyMongoError: + # A failed upgrade must not stop the login succeeding. + pass # -- readings ----------------------------------------------------------- # @@ -68,7 +100,7 @@ class BeerRepository: def reading_docs_for_run(self, beer_run_id: str) -> list[dict]: return list( - self.readings.find({"beer_run_id": str(beer_run_id)}).sort("date", 1) + self.readings.find({"beer_run_id": _as_id(beer_run_id)}).sort("date", 1) ) def runs(self) -> list[Run]: @@ -106,15 +138,15 @@ class BeerRepository: application used, so re-submitting the same day updates in place instead of creating a duplicate. Returns the ``_id`` of the document. """ - query = {"date": reading_date, "beer_run_id": str(beer_run_id)} + query = {"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)} update = { "$set": { "beer_run_id": str(beer_run_id), "beer_type": beer_type, "sg": str(sg), - "date": reading_date, + "date": _as_date(reading_date), "final_reading": "True" if final_reading else "False", - "comment": comment, + "comment": str(comment), } } result = self.readings.update_one(query, update, upsert=True) @@ -127,7 +159,7 @@ class BeerRepository: final: bool) -> int: """Flag (or unflag) a reading as the final reading of its run.""" result = self.readings.update_many( - {"date": reading_date, "beer_run_id": str(beer_run_id)}, + {"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)}, {"$set": {"final_reading": "True" if final else "False"}}, ) return result.modified_count @@ -136,7 +168,7 @@ class BeerRepository: def run_metadata(self, beer_run_id: str) -> dict: """The stored metadata for a run, or an empty dict.""" - doc = self.run_meta.find_one({"beer_run_id": str(beer_run_id)}) + doc = self.run_meta.find_one({"beer_run_id": _as_id(beer_run_id)}) if not doc: return {} doc.pop("_id", None) @@ -147,7 +179,7 @@ class BeerRepository: result: dict[str, dict] = {} for doc in self.run_meta.find(): doc.pop("_id", None) - result[str(doc.get("beer_run_id"))] = doc + result[_as_id(doc.get("beer_run_id"))] = doc return result def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict: @@ -159,7 +191,7 @@ class BeerRepository: payload = {k: v for k, v in fields.items() if v is not None} payload["updated_at"] = _now_iso() self.run_meta.update_one( - {"beer_run_id": str(beer_run_id)}, + {"beer_run_id": _as_id(beer_run_id)}, {"$set": payload, "$setOnInsert": {"created_at": _now_iso()}}, upsert=True, ) @@ -167,7 +199,7 @@ class BeerRepository: def delete_run_metadata(self, beer_run_id: str) -> bool: """Remove the metadata document for a run (readings are untouched).""" - return self.run_meta.delete_one({"beer_run_id": str(beer_run_id)}).deleted_count > 0 + return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0 # -- helpers ------------------------------------------------------------ # @@ -198,13 +230,41 @@ def _build_client() -> MongoClient: return MongoClient(uri, serverSelectionTimeoutMS=5000) -def _hash_password(password: str) -> str: - """Salted SHA-256 hex digest used for newly created users.""" - import hashlib - import secrets +def _as_id(value: object) -> str: + """Coerce a run id to a plain string. - salt = os.getenv("BEER_PASSWORD_SALT", "beer_data") - return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest() + Every value that reaches a Mongo query is coerced first. Without this, a + caller passing a dict (e.g. ``{"$ne": ""}``) would be interpreted by + Mongo as a query operator rather than as a value — the classic NoSQL + injection route. + """ + if isinstance(value, str): + return value + if isinstance(value, (int, float)): + return str(value) + return "" + + +def _as_date(value: object) -> str: + """Coerce a reading date to a plain ``YYYY-MM-DD`` string.""" + if isinstance(value, str): + return value + return "" + + +def _is_hashed(stored: str) -> bool: + """True when *stored* looks like a werkzeug password hash. + + Werkzeug hashes are of the form ``method$salt$hash``, e.g. + ``scrypt:32768:8:1$abcdef...$0123...``. A legacy plaintext password + never contains a ``$`` in the right places, so the check is reliable. + """ + return "$" in stored and len(stored.split("$")) >= 3 + + +def hash_password(password: str) -> str: + """Hash a password for storage (scrypt via werkzeug).""" + return generate_password_hash(password) def _now_iso() -> str: diff --git a/src/flask/templates/brews.html b/src/flask/templates/brews.html index 57ad51d..9487482 100644 --- a/src/flask/templates/brews.html +++ b/src/flask/templates/brews.html @@ -94,7 +94,7 @@ {% if run.min_sg is not none %} {{ run.min_sg }} -
{{ '%.3f' % (run.min_sg / 1000) }}
+
{{ run.min_sg_decimal }}
{% else %}—{% endif %} diff --git a/src/flask/templates/run_detail.html b/src/flask/templates/run_detail.html index 7cd6f15..9d970b6 100644 --- a/src/flask/templates/run_detail.html +++ b/src/flask/templates/run_detail.html @@ -29,7 +29,7 @@ {% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %} {% if summary.og_decimal %} -
{{ '%.3f' % summary.og_decimal }}
+
{{ summary.og_decimal_str }}
{% endif %}
@@ -40,7 +40,7 @@ {% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %}
{% if summary.fg_decimal %} -
{{ '%.3f' % summary.fg_decimal }}
+
{{ summary.fg_decimal_str }}
{% endif %}
diff --git a/tests/conftest.py b/tests/conftest.py index d15fd27..f196fd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,12 @@ if APP_DIR not in sys.path: sys.path.insert(0, APP_DIR) +@pytest.fixture(autouse=True) +def secret_key_env(monkeypatch): + """The app refuses to start without SECRET_KEY — give the tests one.""" + monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret") + + @pytest.fixture() def mongo_client(): """An in-memory Mongo, shared by the repository the app builds.""" diff --git a/tests/test_app.py b/tests/test_app.py index 5ef5824..dd73020 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -26,6 +26,25 @@ def test_healthz_is_public(client): assert response.json == {"status": "ok"} +def test_readyz_checks_the_database(client): + response = client.get("/readyz") + assert response.status_code == 200 + assert response.json == {"status": "ready"} + + +def test_readyz_reports_not_ready_when_mongo_is_down(client, monkeypatch): + """A pod that cannot reach Mongo must not serve traffic.""" + from pymongo.errors import ServerSelectionTimeoutError + + def boom(*args, **kwargs): + raise ServerSelectionTimeoutError("no mongo") + + monkeypatch.setattr("mongo.repository.BeerRepository.__init__", + lambda self, **kw: (_ for _ in ()).throw(ServerSelectionTimeoutError("no mongo"))) + response = client.get("/readyz") + assert response.status_code == 503 + + def test_root_redirects_to_login(client): response = client.get("/") assert response.status_code == 302 @@ -179,3 +198,85 @@ def test_out_of_range_reading_is_saved_but_warned(logged_in, repo): assert b"outside the usual hydrometer range" in response.data # It is still stored — the warning is advisory, not a rejection. assert repo.readings.find_one({"beer_run_id": "12"}) is not None + + +# ------------------------------------------------------- security: passwords # + +def test_legacy_plaintext_password_is_accepted_and_migrated(repo): + """A legacy plaintext row still logs in, and is rewritten as a hash on + the way in so plaintext credentials disappear from the database.""" + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + assert repo.user_check("beer", "beer_pass") is True + + doc = repo.users.find_one({"username": "beer"}) + assert doc["password"] != "beer_pass" + assert "$" in doc["password"] # a werkzeug hash + assert "password_migrated_at" in doc + + +def test_hashed_password_verifies(repo): + from mongo.repository import hash_password + + repo.users.insert_one({"username": "beer", "password": hash_password("beer_pass")}) + + assert repo.user_check("beer", "beer_pass") is True + assert repo.user_check("beer", "wrong") is False + + +def test_user_check_rejects_empty_credentials(repo): + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + assert repo.user_check("", "") is False + assert repo.user_check("beer", "") is False + assert repo.user_check("", "beer_pass") is False + + +def test_user_check_rejects_a_nosql_operator_payload(repo): + """A dict must never be able to act as a Mongo query operator.""" + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + assert repo.user_check({"$ne": ""}, "anything") is False + assert repo.user_check({"$gt": ""}, {"$ne": ""}) is False + + +def test_run_id_cannot_be_used_as_an_operator(repo): + """A run id is coerced to a string before it reaches a query.""" + from mongo.repository import _as_id + + assert _as_id({"$ne": ""}) == "" + assert _as_id(20) == "20" + assert _as_id("20") == "20" + + +def test_the_app_refuses_to_start_without_a_secret_key(monkeypatch): + """A missing SECRET_KEY must stop the app, not fall back to a default.""" + import importlib + + import app_factory + + # Reload so the guard reads the environment as it is *now*. The exception + # class must be taken from the reloaded module — reloading rebinds it, so + # a class imported earlier would be a different object and pytest.raises + # would not match it. + monkeypatch.delenv("SECRET_KEY", raising=False) + importlib.reload(app_factory) + with pytest.raises(app_factory.ImproperlyConfigured): + app_factory.create_app() + + monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret") + importlib.reload(app_factory) + + +def test_the_app_refuses_the_placeholder_secret_key(monkeypatch): + import importlib + + import app_factory + + monkeypatch.setenv("SECRET_KEY", "change-me-in-the-deployment") + importlib.reload(app_factory) + with pytest.raises(app_factory.ImproperlyConfigured): + app_factory.create_app() + + monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret") + importlib.reload(app_factory) From 6e1189e31977c09cd11afa55119f58a7b95b38c9 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 10:31:03 +1000 Subject: [PATCH 3/4] fix: log a failed password migration instead of swallowing it The round-2 review noted that `_upgrade_password` catches `PyMongoError` without logging it. A failed upgrade is harmless to the login itself - the user still gets in - but it leaves the password stored in plain text, and that is exactly the kind of thing an operator needs to know about. It is now logged as a warning with the username and the traceback. A test covers the path: the migration write is forced to fail, the login still succeeds, and the warning is emitted. --- src/flask/mongo/repository.py | 14 ++++++++++++-- tests/test_app.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/flask/mongo/repository.py b/src/flask/mongo/repository.py index baa7bbd..7cdd880 100644 --- a/src/flask/mongo/repository.py +++ b/src/flask/mongo/repository.py @@ -17,6 +17,7 @@ Two collections are used: from __future__ import annotations +import logging import os from typing import Any @@ -28,6 +29,8 @@ from brewing import Run __all__ = ["BeerRepository", "get_repository"] +logger = logging.getLogger(__name__) + class BeerRepository: """Thin, testable wrapper around the two Mongo collections.""" @@ -89,8 +92,15 @@ class BeerRepository: }}, ) except PyMongoError: - # A failed upgrade must not stop the login succeeding. - pass + # A failed upgrade must not stop the login succeeding — but the + # operator needs to know the password is still stored in plain + # text, so log it rather than swallowing the error silently. + logger.warning( + "Could not upgrade the password for user %r to a hashed value; " + "it is still stored in plain text", + doc.get("username"), + exc_info=True, + ) # -- readings ----------------------------------------------------------- # diff --git a/tests/test_app.py b/tests/test_app.py index dd73020..cb5746f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -215,6 +215,29 @@ def test_legacy_plaintext_password_is_accepted_and_migrated(repo): assert "password_migrated_at" in doc +def test_a_failed_password_migration_still_lets_the_login_succeed(repo, caplog): + """If the upgrade write fails, the login must still work — but the + failure is logged so the operator knows the password is still plaintext.""" + import logging + + from pymongo.errors import PyMongoError + + repo.users.insert_one({"username": "beer", "password": "beer_pass"}) + + def boom(*args, **kwargs): + raise PyMongoError("write failed") + + original = repo.users.update_one + repo.users.update_one = boom + + with caplog.at_level(logging.WARNING): + assert repo.user_check("beer", "beer_pass") is True + + repo.users.update_one = original + + assert any("plain text" in rec.message for rec in caplog.records) + + def test_hashed_password_verifies(repo): from mongo.repository import hash_password From ba65ec5a8f6a524532479ce68b572c5d95f7bce3 Mon Sep 17 00:00:00 2001 From: hermes Date: Mon, 9 Nov 2026 10:35:42 +1000 Subject: [PATCH 4/4] ci: drop actions/setup-python, which hangs on this runner The setup-python action downloads a Python build from GitHub. This runner cannot reach GitHub reliably for that, and the test job hung on it with no output. The runner image already carries a usable python3, so the tests use it directly and install into a local venv. --- .gitea/workflows/build_push.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index 9c01676..159937d 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -9,14 +9,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' + # NOTE: no actions/setup-python here. That action downloads a Python + # build from GitHub, which this runner cannot reach reliably - the job + # hung on it. The runner image already carries a usable python3, so + # the tests use it directly. + - name: Show the Python version + run: python3 --version - name: Install dependencies run: | - python -m venv .venv + python3 -m venv .venv . .venv/bin/activate pip install --upgrade pip pip install -r requirements-dev.txt