diff --git a/.gitea/workflows/build_push.yml b/.gitea/workflows/build_push.yml index 3fc4ad1..159937d 100644 --- a/.gitea/workflows/build_push.yml +++ b/.gitea/workflows/build_push.yml @@ -2,10 +2,37 @@ name: Build and Push Image on: [push] jobs: + test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # 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: | + python3 -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..668b8de --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,178 @@ +# 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. **Required** | +| `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 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 + +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"} +curl -s http://:30015/readyz # -> {"status":"ready"} +``` + +`/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 + +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 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 +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..2cbd69c 100644 --- a/flask.Dockerfile +++ b/flask.Dockerfile @@ -1,14 +1,26 @@ 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 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=... -#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..777f65a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.2.0 +mongomock==4.3.0 diff --git a/requirements.txt b/requirements.txt index ef76f0b..ba57dab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,8 @@ -pymongo[srv] -flask -requests_html -beautifulsoup4 -click -Flask-WTF -bootstrap-flask -waitress -bokeh -pandas -duckdb \ No newline at end of file +# 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/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..8cbfda3 --- /dev/null +++ b/src/flask/app_factory.py @@ -0,0 +1,164 @@ +"""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"] + + +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( + __name__, + template_folder="templates", + static_folder="static", + ) + + app.config.update( + 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", ""), + 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. 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(): + 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..e1a2459 --- /dev/null +++ b/src/flask/brewing.py @@ -0,0 +1,442 @@ +"""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 _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.""" + 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), + # 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), + "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..7cdd880 --- /dev/null +++ b/src/flask/mongo/repository.py @@ -0,0 +1,304 @@ +"""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 logging +import os +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 + +__all__ = ["BeerRepository", "get_repository"] + +logger = logging.getLogger(__name__) + + +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. + + 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. + """ + 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 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 — 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 ----------------------------------------------------------- # + + 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": _as_id(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": _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": _as_date(reading_date), + "final_reading": "True" if final_reading else "False", + "comment": str(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": _as_date(reading_date), "beer_run_id": _as_id(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": _as_id(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[_as_id(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": _as_id(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": _as_id(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 _as_id(value: object) -> str: + """Coerce a run id to a plain string. + + 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: + 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..9487482 --- /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 }} +
{{ run.min_sg_decimal }}
+ {% 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..9d970b6 --- /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 %} +
{{ summary.og_decimal_str }}
+ {% 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 %} +
{{ summary.fg_decimal_str }}
+ {% 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..f196fd5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,73 @@ +"""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(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.""" + 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..cb5746f --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,305 @@ +"""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_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 + 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 + + +# ------------------------------------------------------- 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_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 + + 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) 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"