Compare commits
59
Commits
dev
..
e83dd212c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83dd212c1 | ||
|
|
59d751d9dd | ||
|
|
8de3d011db | ||
|
|
75f94d47e2 | ||
|
|
cbc71716e8 | ||
|
|
4bedd23c24 | ||
|
|
d18d037bb9 | ||
|
|
7d9d718b41 | ||
|
|
27a28b5e58 | ||
|
|
a78aabe3ea | ||
|
|
8da3601941 | ||
|
|
3e1e4f42b7 | ||
|
|
b414240bb5 | ||
|
|
cf08b751e4 | ||
|
|
0011f30e59 | ||
|
|
21fa0d9a12 | ||
|
|
6dd0b7b046 | ||
|
|
d0aef7a787 | ||
|
|
ab3050194d | ||
|
|
41cf2cbb8e | ||
|
|
76a4d0805c | ||
|
|
21e7553ab4 | ||
|
|
6fec78e9e4 | ||
|
|
bbd9fe17e5 | ||
|
|
4d1e070d74 | ||
|
|
e1a9a7384f | ||
|
|
0785c11790 | ||
|
|
563e22fb00 | ||
|
|
bb071343c2 | ||
|
|
8048a1112d | ||
|
|
9f2671fa71 | ||
|
|
060269b03d | ||
|
|
2a9e0ad15e | ||
|
|
bf0f88dd70 | ||
|
|
1fa9f1dcbe | ||
|
|
081014b8dc | ||
|
|
3e20b558a5 | ||
|
|
a4b3d40e2d | ||
|
|
ce6f5b7962 | ||
|
|
687339bb9e | ||
|
|
c28553a1b8 | ||
|
|
958ae9b365 | ||
|
|
55d5e84150 | ||
|
|
b73df93f20 | ||
|
|
55c5de9d61 | ||
|
|
0f93cbea70 | ||
|
|
c0c6b4c1fc | ||
|
|
9d3ce1d2c4 | ||
|
|
78b3b10131 | ||
|
|
a48fb1c566 | ||
|
|
e9ce0dcf9b | ||
|
|
757774168d | ||
|
|
ba65ec5a8f | ||
|
|
6e1189e319 | ||
|
|
1828dcb81d | ||
|
|
24cdd452c5 | ||
|
|
fb65ba4ee0 | ||
|
|
718c8e87b3 | ||
|
|
4baf08813c |
@@ -1,21 +1,27 @@
|
||||
name: Build and Push Image
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push image
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
if: gitea.ref == 'refs/heads/master'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The kubeconfig is written FIRST, because the buildx `kubernetes`
|
||||
# driver needs it to create its builder pod in the cluster. This is
|
||||
# the same ordering as the working workflows in resbuilder_ai and
|
||||
# wedding-photos.
|
||||
- name: Create Kubeconfig
|
||||
run: |
|
||||
mkdir $HOME/.kube
|
||||
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config
|
||||
echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -32,22 +38,56 @@ jobs:
|
||||
username: armistace
|
||||
password: ${{ secrets.REG_PASSWORD }}
|
||||
|
||||
- name: Build Base Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: base.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
git.aridgwayweb.com/armistace/beer_base_image:latest
|
||||
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
file: flask.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# amd64 only. A multi-arch build runs qemu emulation, which
|
||||
# crashes the buildkit pod on this NUC (exit 255, SandboxChanged)
|
||||
# and the build then hangs for many minutes. The cluster is
|
||||
# amd64, so arm64 images are never used.
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
git.aridgwayweb.com/armistace/beer-data:latest
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
echo "Installing Kubectl"
|
||||
apt-get update
|
||||
apt-get install -y apt-transport-https ca-certificates curl gnupg
|
||||
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
|
||||
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
|
||||
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
|
||||
chmod 644 /etc/apt/sources.list.d/kubernetes.list
|
||||
apt-get update
|
||||
apt-get install kubectl
|
||||
|
||||
# Do NOT delete the namespace - that would destroy the live
|
||||
# Secrets (beer-db, beer-app), which are provisioned by hand.
|
||||
# Apply the namespace (idempotent) and the manifests.
|
||||
kubectl apply -f kube/beer_namespace.yaml
|
||||
|
||||
# The image-pull credentials. Created idempotently so a deploy
|
||||
# does not fail when they already exist.
|
||||
kubectl create secret docker-registry beercred --docker-server=git.aridgwayweb.com --docker-username=armistace --docker-password='${{ secrets.REG_PASSWORD }}' --docker-email=armistace@aridgwayweb.com --namespace=beer --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl apply -f kube/beer-deployment.yaml
|
||||
kubectl apply -f kube/beer-service.yaml
|
||||
|
||||
# `kubectl apply` does NOT roll the pods when only the image
|
||||
# changed, because the image tag in the manifest is unchanged.
|
||||
# Without the restart the deployment reports success while the
|
||||
# old pods keep serving the old image.
|
||||
kubectl -n beer rollout restart deployment/beer-deployment
|
||||
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||
|
||||
- name: Verify the deployed app
|
||||
run: |
|
||||
# Pods Running is not proof the app works. Exercise the real
|
||||
# endpoints from inside the pod.
|
||||
kubectl -n beer exec deploy/beer-deployment -- \
|
||||
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/healthz'); print('healthz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
|
||||
kubectl -n beer exec deploy/beer-deployment -- \
|
||||
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/readyz'); print('readyz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
|
||||
|
||||
@@ -1,7 +1,177 @@
|
||||
# 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!
|
||||
## 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 <http://localhost:5000/> 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.
|
||||
|
||||
## The pages
|
||||
|
||||
| Page | What it is for |
|
||||
|---|---|
|
||||
| `/login` | Sign in |
|
||||
| `/brews` | The brew process: record a reading, review every run |
|
||||
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
|
||||
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
|
||||
| `/analysis` | How consistently the brews ferment — attenuation, fermentation curves, brew timings |
|
||||
|
||||
### `/active` — the active beers page
|
||||
|
||||
A card per brew that is still in the fermenter (a run with no reading marked
|
||||
final), showing:
|
||||
|
||||
- **the tap number** it is on,
|
||||
- **the current gravity**, as a decimal reading with the point scale beneath,
|
||||
- **the projected alcohol**, worked out from the run's original gravity,
|
||||
- **how many days** it has been brewing,
|
||||
- a **sparkline** of the gravity so far,
|
||||
- **the latest comment** on a reading.
|
||||
|
||||
A run with only one reading and no recorded original gravity has no reliable
|
||||
estimate, so the alcohol figure shows a dash rather than a misleading number.
|
||||
Record the OG on the run (or take a second reading) and it appears.
|
||||
|
||||
### `/analysis` — how the brews ferment
|
||||
|
||||
The page that turns the history into something you can brew better from:
|
||||
|
||||
- **Attenuation by brew** — what fraction of the original gravity was
|
||||
fermented away, for every finished brew.
|
||||
- **Fermentation curves** — the typical shape of a fermentation, drawn as a
|
||||
median curve with the min–max envelope behind it. Plotting every brew
|
||||
individually gives sixteen near-identical lines and an unreadable legend.
|
||||
- **Every finished brew** — OG, FG, ABV, attenuation, days in the fermenter,
|
||||
days to reach 1.010, and the finish date.
|
||||
- **Readings that need a second look** — readings outside the plausible
|
||||
hydrometer range.
|
||||
- Headline figures: average attenuation and its spread, average brew time,
|
||||
average time to 1.010.
|
||||
|
||||
A reading that is physically impossible — a final gravity below the original
|
||||
gravity by more than the wort itself could have — is excluded from the
|
||||
averages rather than silently distorting them, and the run is listed on the
|
||||
page so it can be corrected.
|
||||
|
||||
### Run metadata
|
||||
|
||||
Some details cannot be derived from the readings, so they are stored per run in
|
||||
the `beer_runs` collection and edited on the run detail page:
|
||||
|
||||
- the **canonical name** the brew is known by (overriding the drifting
|
||||
per-reading name),
|
||||
- the **tap number**,
|
||||
- the **original gravity**,
|
||||
- free-text **brew notes**.
|
||||
|
||||
### Populating the run metadata from the existing readings
|
||||
|
||||
```bash
|
||||
# dry run - prints what it would do, writes nothing
|
||||
python scripts/backfill_run_metadata.py
|
||||
|
||||
# write it
|
||||
python scripts/backfill_run_metadata.py --apply
|
||||
```
|
||||
|
||||
The script is idempotent and non-destructive: it only inserts into
|
||||
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
|
||||
already set by hand (pass `--force` if you want it to).
|
||||
|
||||
## 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, 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
|
||||
scripts/ operational scripts (the run-metadata backfill)
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
# 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://<any-cluster-node>: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 builds
|
||||
the image, pushes it, **deploys it to the cluster and verifies it**. There is
|
||||
no manual step.
|
||||
|
||||
The workflow is a single job, in this order:
|
||||
|
||||
1. **Checkout**
|
||||
2. **Create Kubeconfig** — writes `secrets.KUBEC_CONFIG_BUILDX_NEW_2` to
|
||||
`$HOME/.kube/config`. This is FIRST because the buildx `kubernetes` driver
|
||||
needs it to create its builder pod in the cluster.
|
||||
3. **Set up Docker Buildx** — `driver: kubernetes`, `namespace=gitea-runner`.
|
||||
4. **Login to the registry** — `secrets.REG_PASSWORD`.
|
||||
5. **Build and Push** — `linux/amd64` only. A multi-arch build runs qemu,
|
||||
which crashes the buildkit pod on this NUC (exit 255, `SandboxChanged`)
|
||||
and the build then hangs for many minutes.
|
||||
6. **Deploy** — installs kubectl, applies `kube/*.yaml`, creates the
|
||||
image-pull secret idempotently, then `rollout restart` + `rollout status`.
|
||||
The rollout restart is required: the image tag in the manifest is
|
||||
`:latest`, so `kubectl apply` alone does not roll the pods.
|
||||
7. **Verify the deployed app** — exercises `/healthz` and `/readyz` from
|
||||
inside the pod, using the pod's own python (there is no `curl` in the
|
||||
slim image).
|
||||
|
||||
> The deploy step does **not** delete the namespace. That would destroy the
|
||||
> hand-provisioned `beer-db` and `beer-app` Secrets.
|
||||
|
||||
### Deploying by hand
|
||||
|
||||
If you need to deploy without going through CI — for example to test a
|
||||
manifest change — do the same thing the workflow does:
|
||||
|
||||
```bash
|
||||
kubectl apply -f kube/beer_namespace.yaml
|
||||
kubectl apply -f kube/beer-deployment.yaml
|
||||
kubectl apply -f kube/beer-service.yaml
|
||||
kubectl -n beer rollout restart deployment/beer-deployment
|
||||
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||
```
|
||||
|
||||
Validate the manifests before applying them. `kubectl apply` **silently
|
||||
rejects** a manifest with a schema error, so a broken manifest leaves the
|
||||
old Deployment in place while the job still reports success:
|
||||
|
||||
```bash
|
||||
kubectl apply --dry-run=client -f kube/
|
||||
```
|
||||
|
||||
### 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://<node>:30015/healthz # -> {"status":"ok"}
|
||||
curl -s http://<node>: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
|
||||
```
|
||||
|
||||
## The run metadata collection
|
||||
|
||||
The `beer_runs` collection holds one document per brew run with the details
|
||||
that cannot be derived from the readings: the canonical name, the tap number,
|
||||
the recorded original gravity and the brew notes.
|
||||
|
||||
It starts empty on a fresh install. Populate it from the existing readings:
|
||||
|
||||
```bash
|
||||
# from the app's source tree, with the usual env vars set
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
```
|
||||
|
||||
The script is idempotent and only ever inserts into `beer_runs`. It does not
|
||||
touch `beer_data`.
|
||||
|
||||
To see what is in it:
|
||||
|
||||
```bash
|
||||
mongosh "mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
|
||||
--eval 'db.beer_runs.find().pretty()'
|
||||
```
|
||||
|
||||
## 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.
|
||||
+19
-4
@@ -1,11 +1,26 @@
|
||||
FROM python:3.10 AS beer_base_image
|
||||
FROM python:3.14.10-slim AS beer_base_image
|
||||
|
||||
WORKDIR /beer_data
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y libsasl2-dev python-dev-is-python3 libldap2-dev libssl-dev
|
||||
# The runtime dependencies (flask, flask-wtf, waitress, pymongo, requests) all
|
||||
# ship as wheels for 3.14, so no compiler toolchain is needed. The old image
|
||||
# installed libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev
|
||||
# for packages that are no longer in the dependency list.
|
||||
#
|
||||
# The Python version matters:
|
||||
# - pymongo 4.19 requires Python >= 3.11, so the base image could not stay
|
||||
# on 3.10 once the requirements were pinned.
|
||||
# - 3.14 is a stable release (first released 2025-10-07, in its bugfix
|
||||
# phase until 2030-10), so the image is not carrying the CVEs fixed in
|
||||
# 3.11, 3.12 and 3.13.
|
||||
# - The patch version is pinned rather than the floating `3.14-slim` tag so
|
||||
# a rebuild is reproducible and does not silently pick up a base image
|
||||
# change.
|
||||
RUN pip install --upgrade pip --no-cache-dir
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip --default-timeout=1000 install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN pip --default-timeout=1000 install -r requirements.txt
|
||||
# Fail the build if the interpreter is not the one the image claims.
|
||||
RUN python -c "import sys; assert sys.version_info[:2] == (3, 14), sys.version"
|
||||
|
||||
+25
-5
@@ -1,14 +1,34 @@
|
||||
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
|
||||
|
||||
# Run as an unprivileged user. The app only needs to read its own source
|
||||
# and talk to Mongo over the network — it writes no files — so it does not
|
||||
# need to be root.
|
||||
RUN useradd --system --create-home --home-dir /home/beer --shell /usr/sbin/nologin beer \
|
||||
&& chown -R beer:beer /beer_data
|
||||
|
||||
ENTRYPOINT ["flask", "--app", "/beer_data/src/flask/beer_data", "run", "--host=0.0.0.0"]
|
||||
# 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 ["python", "/pool_data/src/flask/pool_data.py"]
|
||||
# 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=...
|
||||
|
||||
USER beer
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENTRYPOINT ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--call", "beer_data:create_app"]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: beer-deployment
|
||||
namespace: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: beer-data
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
securityContext:
|
||||
# Pod-level: only runAsNonRoot/runAsUser/runAsGroup/fsGroup belong
|
||||
# here. allowPrivilegeEscalation and capabilities are container-level.
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
containers:
|
||||
- name: beer-data
|
||||
image: git.aridgwayweb.com/armistace/beer-data:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: MONGO_HOST
|
||||
value: "192.168.178.229"
|
||||
- name: MONGO_USER
|
||||
value: "root"
|
||||
# MONGO_PASS and SECRET_KEY come from the live Secret - they are
|
||||
# deliberately NOT in this file. See RUNBOOK.md.
|
||||
- name: MONGO_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-db
|
||||
key: mongo_pass
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-app
|
||||
key: secret_key
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
# Container-level: these two belong here, not on the pod.
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 5000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: 5000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: archlinux-k3s-3
|
||||
imagePullSecrets:
|
||||
- name: beercred
|
||||
terminationGracePeriodSeconds: 30
|
||||
restartPolicy: Always
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: beer-service
|
||||
namespace: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: beer-data
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 5000
|
||||
nodePort: 30015
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==9.2.0
|
||||
mongomock==4.3.0
|
||||
+8
-11
@@ -1,11 +1,8 @@
|
||||
pymongo[srv]
|
||||
flask
|
||||
requests_html
|
||||
beautifulsoup4
|
||||
click
|
||||
Flask-WTF
|
||||
bootstrap-flask
|
||||
waitress
|
||||
bokeh
|
||||
pandas
|
||||
duckdb
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill the ``beer_runs`` metadata collection from the existing readings.
|
||||
|
||||
The ``beer_data`` collection stores a beer name on *each reading*, and that
|
||||
name drifts across a run — run 4 was entered as "Necatron-Christmas Beer",
|
||||
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". The
|
||||
``beer_runs`` collection holds one canonical name per run, which is what the
|
||||
active-beers page and the analysis pages use.
|
||||
|
||||
This script populates that collection from the readings. It is **idempotent**
|
||||
and **non-destructive**:
|
||||
|
||||
- It only ever inserts into ``beer_runs``. It never touches ``beer_data``.
|
||||
- It never overwrites a name you have already set by hand. A run whose
|
||||
metadata already carries a ``name`` is left alone unless ``--force`` is
|
||||
given.
|
||||
- Re-running it produces the same result.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
python scripts/backfill_run_metadata.py --apply --force # overwrite names
|
||||
|
||||
The connection is taken from the same environment variables the app uses:
|
||||
``MONGO_HOST``, ``MONGO_USER``, ``MONGO_PASS`` (or ``MONGO_URI``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask"),
|
||||
)
|
||||
|
||||
from brewing import Run # noqa: E402
|
||||
from mongo.repository import BeerRepository # noqa: E402
|
||||
|
||||
|
||||
def build_plan(repo: BeerRepository) -> list[dict]:
|
||||
"""Work out what should be in ``beer_runs``, one entry per run."""
|
||||
existing = repo.all_run_metadata()
|
||||
plan = []
|
||||
|
||||
for run in repo.runs():
|
||||
metadata = existing.get(run.beer_run_id, {})
|
||||
canonical = metadata.get("name") or run.display_name()
|
||||
og = metadata.get("og_points") or run.original_gravity()
|
||||
|
||||
plan.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": canonical,
|
||||
"og_points": og,
|
||||
"tap_number": metadata.get("tap_number"),
|
||||
"notes": metadata.get("notes", ""),
|
||||
"readings": len(run.readings),
|
||||
"active": run.is_active(),
|
||||
"existing_name": metadata.get("name"),
|
||||
"would_change": not metadata.get("name"),
|
||||
})
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="write the changes (default is a dry run)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="overwrite names that have already been set")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
plan = build_plan(repo)
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
if not plan:
|
||||
print("No runs found in the readings collection — nothing to do.")
|
||||
return 0
|
||||
|
||||
print(f"{'run':>5} {'name':<38} {'OG':>5} {'readings':>8} state")
|
||||
print("-" * 78)
|
||||
for entry in plan:
|
||||
state = "active" if entry["active"] else "finished"
|
||||
marker = " " if entry["would_change"] else "*"
|
||||
print(f"{entry['beer_run_id']:>5} {entry['name'][:38]:<38} "
|
||||
f"{entry['og_points'] or '-':>5} {entry['readings']:>8} {state}{marker}")
|
||||
|
||||
changed = [e for e in plan if e["would_change"] or args.force]
|
||||
kept = [e for e in plan if not e["would_change"] and not args.force]
|
||||
|
||||
print()
|
||||
print(f"{len(changed)} run(s) would be written, {len(kept)} kept as they are.")
|
||||
if kept:
|
||||
print("(* already has a name set by hand — use --force to overwrite)")
|
||||
print(f"{'APPLYING' if args.apply else 'DRY RUN — nothing written'}")
|
||||
print()
|
||||
|
||||
if not args.apply:
|
||||
print("Re-run with --apply to write these.")
|
||||
return 0
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
for entry in changed:
|
||||
repo.upsert_run_metadata_partial(
|
||||
entry["beer_run_id"],
|
||||
name=entry["name"],
|
||||
og_points=entry["og_points"],
|
||||
tap_number=entry["tap_number"],
|
||||
notes=entry["notes"] or None,
|
||||
)
|
||||
print(f" wrote run {entry['beer_run_id']}: {entry['name']}")
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
print(f"\nDone — {len(changed)} run(s) written to the beer_runs collection.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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"]
|
||||
|
||||
|
||||
#: The Content-Security-Policy the app sends with every response.
|
||||
#:
|
||||
#: The app serves only its own vendored assets — CSS, JavaScript, fonts and
|
||||
#: the logo are all in ``static/`` — so a strict ``'self'`` policy is
|
||||
#: enforceable. There is no ``'unsafe-inline'`` anywhere, which is why the
|
||||
#: templates carry no inline scripts or style attributes.
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"object-src 'none'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
)
|
||||
|
||||
|
||||
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.after_request
|
||||
def _set_security_headers(response):
|
||||
"""Add the standard security headers.
|
||||
|
||||
The app serves its own vendored JavaScript and CSS and loads nothing
|
||||
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
||||
and is genuinely enforceable.
|
||||
"""
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||
return response
|
||||
|
||||
@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.analysis import bp as analysis_bp
|
||||
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.register_blueprint(analysis_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)
|
||||
+28
-135
@@ -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 = '<tr align="center" style="border-bottom:1pt solid black;">'
|
||||
return_html = df.to_html(col_space='75px', index=False,
|
||||
justify='center', border=3).replace('<tr>', 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/<int:return_number>/<string:field>")
|
||||
# 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()
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Brewing advice from an LLM, generated for each recorded observation.
|
||||
|
||||
The advice is a *comment on the reading*, not a replacement for the
|
||||
brewer's own comment. Every reading gets its own advice text, stored in
|
||||
its own field, so nothing the brewer wrote is ever overwritten.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- **The LLM is never in the request path.** Recording a reading must not
|
||||
block on a model that may be slow or down, so the advice is generated in
|
||||
a background thread and written when it is ready. The page shows the
|
||||
reading immediately and the advice appears on a later visit.
|
||||
- **Failures are recorded, not raised.** If the model is unreachable the
|
||||
advice field gets a short note saying so. A silent failure would look
|
||||
identical to "no advice yet", which is confusing when you are waiting
|
||||
for a tip that is never going to arrive.
|
||||
- **The model is configurable**, with a default that is known to work on
|
||||
this homelab. Several models on the Ollama instance have been retired
|
||||
and fail at call time while still appearing in the model list, so the
|
||||
default is verified rather than assumed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: The Ollama instance on the homelab. Reachable from the pod.
|
||||
DEFAULT_OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.178.45:11434")
|
||||
|
||||
#: The model to use. Override with the ``OLLAMA_MODEL`` environment
|
||||
#: variable.
|
||||
#:
|
||||
#: A model that does NOT reason is much better here. The reasoning
|
||||
#: models emit their thinking out of the same token allowance as the
|
||||
#: answer, and several of them burned the whole budget on thinking and
|
||||
#: returned an EMPTY reply - which looks like a broken model rather than
|
||||
#: a budget problem. With `think: false` the model still shows its
|
||||
#: working, but inline in the content, so the answer can be taken from
|
||||
#: the end of it.
|
||||
#:
|
||||
#: `deepseek-v4-flash:cloud`, `deepseek-v3.2:cloud` and `qwen3.5:cloud`
|
||||
#: are all retired and fail at call time while still appearing in the
|
||||
#: model list, so the default is one that was probed.
|
||||
DEFAULT_MODEL = os.getenv("OLLAMA_MODEL", "mistral-large-4:cloud")
|
||||
|
||||
#: How long to wait for the model before giving up. The advice is
|
||||
#: generated in the background, so this only bounds the background thread.
|
||||
DEFAULT_TIMEOUT = float(os.getenv("LLM_TIMEOUT", "120"))
|
||||
|
||||
#: The advice is advice, not instruction. Keep it short - it has to be
|
||||
#: readable on the brewing page next to the brewer's own comment.
|
||||
MAX_ADVICE_CHARS = 1200
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are an experienced home brewer giving practical advice to a single \
|
||||
brewer about their own beer. You know brewing science: specific gravity, \
|
||||
attenuation, yeast health, temperature, timing, and what a stalled or \
|
||||
struck-out fermentation looks like.
|
||||
|
||||
You will be given one brew's readings so far: its name, the original \
|
||||
gravity, each reading's date and specific gravity, and any comment the \
|
||||
brewer wrote.
|
||||
|
||||
Give the brewer useful, specific, practical advice about THIS reading and \
|
||||
this brew. Consider things like:
|
||||
|
||||
- Is the fermentation progressing at a normal rate for the style and \
|
||||
temperature?
|
||||
- Has it stalled, and if so what should they do about it?
|
||||
- Is the yeast likely stressed, unhealthy, or done?
|
||||
- Is the gravity drop plausible for the original gravity?
|
||||
- Should they rack, add more hops, raise the temperature, or wait?
|
||||
|
||||
Rules:
|
||||
- Your reply must have this exact shape:
|
||||
|
||||
ADVICE:
|
||||
<the advice itself, two to five sentences of plain prose>
|
||||
|
||||
The line "ADVICE:" comes FIRST, on its own line, with nothing before
|
||||
it. Do not explain your reasoning and do not show your working.
|
||||
- Advise about the reading that is marked as the one to advise on. It
|
||||
may not be the most recent one - the brewer may be correcting an older
|
||||
reading - and the advice must be about the reading they asked about.
|
||||
- If everything looks normal, say so briefly and say what to expect next.
|
||||
- If something looks wrong, say what it is and what to do about it.
|
||||
- Be specific to the numbers given. Do not give generic advice.
|
||||
- If a reading looks like a typo or a mis-entry, say so and suggest \
|
||||
checking it, but do not assume it - a slow or long fermentation can be \
|
||||
genuine.
|
||||
- Never say a reading is impossible. Beer cannot be denser than water, so \
|
||||
a gravity below 1.000 is always a mis-entry or a typo, but the rest of the \
|
||||
run may still be real and worth understanding.
|
||||
- No preamble, no headings, no bullet-point templates, no "Great question!". \
|
||||
Write two to five sentences of plain prose.
|
||||
"""
|
||||
|
||||
|
||||
def _reading_lines(readings: list[dict], subject_id: str | None = None) -> str:
|
||||
"""The readings, as the compact list the model reads.
|
||||
|
||||
The reading being advised about is marked, so the model does not have
|
||||
to work out which one from the dates - the brewer may be correcting
|
||||
an older reading.
|
||||
"""
|
||||
lines = []
|
||||
for r in readings:
|
||||
parts = [f"- {r.get('date')}: gravity {r.get('sg_decimal')} ({r.get('point_scale')})"]
|
||||
if r.get("comment"):
|
||||
parts.append(f"brewer's comment: {r['comment']}")
|
||||
if r.get("final"):
|
||||
parts.append("(marked as the FINAL reading)")
|
||||
if subject_id is not None and str(r.get("_id")) == str(subject_id):
|
||||
parts.append("<<< THIS IS THE READING TO ADVISE ON")
|
||||
lines.append(" ".join(parts))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_prompt(run: dict, reading: dict, history: list[dict],
|
||||
corrected: bool = False) -> str:
|
||||
"""The user-message content for one observation.
|
||||
|
||||
*corrected* says the reading has just been edited, so the advice
|
||||
should take that into account.
|
||||
"""
|
||||
name = run.get("canonical_name") or run.get("name") or "this brew"
|
||||
og = run.get("og_decimal")
|
||||
if og is None and run.get("og_points"):
|
||||
og = f"{run['og_points'] / 1000:.3f}"
|
||||
|
||||
parts = [
|
||||
f"Beer: {name}",
|
||||
]
|
||||
if og:
|
||||
parts.append(f"Original gravity: {og}")
|
||||
if run.get("tap_number"):
|
||||
parts.append(f"Fermenter tap: {run['tap_number']}")
|
||||
if run.get("notes"):
|
||||
parts.append(f"Brewer's notes: {run['notes']}")
|
||||
|
||||
parts.append("")
|
||||
parts.append("Readings so far (oldest first):")
|
||||
parts.append(_reading_lines(history, subject_id=reading.get("_id")))
|
||||
|
||||
parts.append("")
|
||||
# The reading being advised about is NAMED, not just described as
|
||||
# "the most recent one". The brewer may be editing an older reading,
|
||||
# and the advice is about THAT reading - so the prompt has to say
|
||||
# which one, or the model advises about the newest instead.
|
||||
parts.append(
|
||||
f"Give advice about the reading dated {reading.get('date') or 'unknown'}"
|
||||
f" (gravity {reading.get('sg_decimal')}),"
|
||||
+ (" which the brewer has just corrected." if corrected else "")
|
||||
)
|
||||
if corrected:
|
||||
parts.append(
|
||||
"It is the reading they have just corrected, so it may differ "
|
||||
"from what it said before."
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def call_ollama(prompt: str, *, url: str | None = None, model: str | None = None,
|
||||
timeout: float | None = None) -> str:
|
||||
"""Ask the model for advice. Raises on failure."""
|
||||
base = (url or DEFAULT_OLLAMA_URL).rstrip("/")
|
||||
chosen_model = model or DEFAULT_MODEL
|
||||
wait = timeout if timeout is not None else DEFAULT_TIMEOUT
|
||||
|
||||
payload = json.dumps({
|
||||
"model": chosen_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0.4,
|
||||
# The reasoning models emit their thinking first, and it comes out
|
||||
# of the SAME budget as the answer. At 500 tokens the reasoning
|
||||
# used the whole allowance and the reply came back empty - which
|
||||
# looks like a broken model rather than a budget problem.
|
||||
#
|
||||
# `think: false` does NOT help: the models still think, they just
|
||||
# put the working in the content instead of a separate field.
|
||||
# So the budget has to cover the reasoning AND the advice.
|
||||
"max_tokens": 8000,
|
||||
"stream": False,
|
||||
}).encode("utf-8")
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{base}/v1/chat/completions",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=wait) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
choices = body.get("choices") or []
|
||||
if not choices:
|
||||
raise RuntimeError("the model returned no choices")
|
||||
message = choices[0].get("message") or {}
|
||||
|
||||
# Some models put their working in a separate field. It is ignored -
|
||||
# the advice is the content.
|
||||
content = message.get("content") or ""
|
||||
content = content.strip()
|
||||
if not content:
|
||||
raise RuntimeError("the model returned an empty response")
|
||||
|
||||
# A reply cut off at the token ceiling is INCOMPLETE. It may be
|
||||
# advice that stops mid-sentence, so it is marked as truncated rather
|
||||
# than stored as if it were the whole answer.
|
||||
finish_reason = choices[0].get("finish_reason") or body.get("done_reason")
|
||||
truncated = finish_reason == "length"
|
||||
|
||||
advice = _trim_advice(_strip_reasoning(content))
|
||||
|
||||
if truncated:
|
||||
advice = _trim_advice(advice + " (the reply was cut off)")
|
||||
|
||||
return advice, truncated
|
||||
|
||||
|
||||
def _trim_advice(text: str) -> str:
|
||||
"""Cut the advice down to the readable length, keeping the END.
|
||||
|
||||
The advice is at the end of the reply, so a plain truncation from the
|
||||
front would cut the advice off and leave only the model's working -
|
||||
which is worse than no advice at all, because it looks like advice.
|
||||
So an over-long reply is cut at a sentence boundary near the end.
|
||||
"""
|
||||
if len(text) <= MAX_ADVICE_CHARS:
|
||||
return text
|
||||
|
||||
tail = text[-MAX_ADVICE_CHARS:]
|
||||
# Start at the first sentence break in the tail, so it does not begin
|
||||
# mid-word.
|
||||
for separator in (". ", "? ", "! "):
|
||||
index = tail.find(separator)
|
||||
if index != -1 and index < 200:
|
||||
return tail[index + 2:].strip()
|
||||
return tail.strip()
|
||||
|
||||
|
||||
#: The marker the model is asked to put at the start of its advice.
|
||||
#:
|
||||
#: Guessing where the advice begins by classifying prose is unreliable
|
||||
#: in both directions - it emptied legitimate advice, and it let the
|
||||
#: model's working through in full. A marker the model writes itself is
|
||||
#: deterministic: everything before it is working, everything from it on
|
||||
#: is the advice.
|
||||
_ADVICE_MARKER = "ADVICE:"
|
||||
|
||||
|
||||
def _strip_reasoning(text: str) -> str:
|
||||
"""Take the advice out of a reply.
|
||||
|
||||
The model is asked to put ``ADVICE:`` on its own line at the start
|
||||
of the advice, so the working before it can be dropped exactly, and
|
||||
the advice kept exactly.
|
||||
|
||||
A reply with no marker is used as it stands. That is the safe
|
||||
direction: the model was asked for the marker, so a reply without
|
||||
one is one where it wrote the advice straight away - and if the
|
||||
working did leak in, the brewer sees a slightly rambling reply
|
||||
rather than no advice at all.
|
||||
"""
|
||||
index = _find_marker(text)
|
||||
if index is None:
|
||||
return text.strip()
|
||||
|
||||
advice = text[index:].strip()
|
||||
|
||||
# The model may wrap the marker in markdown or a code block. Strip
|
||||
# the leftover formatting so the advice reads as prose.
|
||||
advice = re.sub(r"^\*\*|\*\*$", "", advice).strip()
|
||||
if advice.startswith("```"):
|
||||
advice = advice[3:]
|
||||
if advice.endswith("```"):
|
||||
advice = advice[:-3]
|
||||
if advice.startswith("##"):
|
||||
advice = advice.lstrip("#").strip()
|
||||
|
||||
return advice.strip() or ""
|
||||
|
||||
|
||||
def _find_marker(text: str) -> int | None:
|
||||
"""Where the advice starts, or ``None`` if there is no marker.
|
||||
|
||||
The marker is matched case-insensitively and with the markdown
|
||||
decoration the model might put around it - ``**ADVICE:**``,
|
||||
``## ADVICE:`` - because the instruction asks for "ADVICE:" but the
|
||||
model formats it its own way often enough to matter.
|
||||
"""
|
||||
match = re.search(r"^[\s#>\*]*advice\s*:[\s\*>]*", text,
|
||||
re.IGNORECASE | re.MULTILINE)
|
||||
if match:
|
||||
return match.end()
|
||||
return None
|
||||
|
||||
|
||||
def generate_advice(run: dict, reading: dict, history: list[dict], *,
|
||||
corrected: bool = False,
|
||||
url: str | None = None, model: str | None = None,
|
||||
timeout: float | None = None) -> tuple[bool, str]:
|
||||
"""Generate the advice for one reading.
|
||||
|
||||
Returns ``(ok, text)``. On failure ``ok`` is False and ``text`` is a
|
||||
short note explaining what went wrong, so the reading carries a
|
||||
visible reason instead of silently having no advice.
|
||||
"""
|
||||
prompt = build_prompt(run, reading, history, corrected=corrected)
|
||||
try:
|
||||
advice, _truncated = call_ollama(prompt, url=url, model=model,
|
||||
timeout=timeout)
|
||||
if not advice:
|
||||
# The reply had no advice in it - the model wrote only its
|
||||
# working, or the marker was missing and nothing followed it.
|
||||
# Storing an empty string with advice_ok=True would render
|
||||
# as "Thinking..." for ever, so it is recorded as a failure
|
||||
# with a note the brewer can see.
|
||||
log.warning("the model returned no advice")
|
||||
return False, ("Advice could not be generated - the model "
|
||||
"returned only its reasoning.")
|
||||
return True, advice
|
||||
except urllib.error.HTTPError as exc:
|
||||
# HTTPError is a subclass of URLError, so it has to be caught
|
||||
# FIRST or it would be swallowed by the unreachable branch.
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("the model returned HTTP %s: %s", exc.code, detail)
|
||||
return False, (f"Advice could not be generated - the model returned "
|
||||
f"HTTP {exc.code}. {detail}".strip())
|
||||
except urllib.error.URLError as exc:
|
||||
log.warning("the model was unreachable: %s", exc)
|
||||
return False, "Advice could not be generated - the model was unreachable."
|
||||
except TimeoutError:
|
||||
log.warning("the model timed out")
|
||||
return False, "Advice could not be generated - the model timed out."
|
||||
except Exception as exc: # noqa: BLE001 - the caller must never crash
|
||||
log.warning("advice generation failed: %s", exc)
|
||||
return False, f"Advice could not be generated - {exc}"
|
||||
|
||||
|
||||
def generate_advice_in_background(run: dict, reading: dict,
|
||||
history: list[dict], writer,
|
||||
corrected: bool = False) -> None:
|
||||
"""Generate the advice and hand it to *writer* when it is ready.
|
||||
|
||||
*writer* is called as ``writer(reading_id, ok, text)``. It runs in a
|
||||
background thread, so it must not touch the request context - the
|
||||
caller is responsible for giving it a way to reach Mongo.
|
||||
"""
|
||||
ok, text = generate_advice(run, reading, history, corrected=corrected)
|
||||
try:
|
||||
writer(reading.get("_id"), ok, text)
|
||||
except Exception: # noqa: BLE001 - never let the thread die noisily
|
||||
log.exception("could not store the generated advice")
|
||||
@@ -0,0 +1,675 @@
|
||||
"""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. The upper bound is
|
||||
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
|
||||
#: still catching typos such as 900 or 1500.
|
||||
_MIN_PLAUSIBLE_SG = 990
|
||||
_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 = ""
|
||||
# The LLM's advice for this reading. It lives in its own field in
|
||||
# Mongo, so the brewer's own comment is never overwritten.
|
||||
advice: str = ""
|
||||
advice_ok: bool = True
|
||||
# Marks this generation of the reading. Every edit writes a fresh
|
||||
# one, so an advice thread working on an earlier version of the
|
||||
# reading can be told apart from the current one.
|
||||
advice_token: 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 ""),
|
||||
advice=(doc.get("advice") or "").strip(),
|
||||
advice_ok=bool(doc.get("advice_ok", True)),
|
||||
advice_token=str(doc.get("advice_token") 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 latest_advice(self) -> str:
|
||||
"""The advice for the most recent reading that has any.
|
||||
|
||||
The advice is generated in the background, so the newest reading
|
||||
may not have any yet - in which case the newest one that does is
|
||||
used, rather than showing nothing.
|
||||
"""
|
||||
for reading in reversed(self.readings):
|
||||
if reading.advice:
|
||||
return reading.advice
|
||||
return ""
|
||||
|
||||
def latest_advice_ok(self) -> bool:
|
||||
"""False when the advice is a note saying it could not be generated."""
|
||||
for reading in reversed(self.readings):
|
||||
if reading.advice:
|
||||
return reading.advice_ok
|
||||
return True
|
||||
|
||||
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 dated_sg_values(self) -> list[int]:
|
||||
"""Gravity points of the readings that have a date, chronologically.
|
||||
|
||||
An undated reading cannot be placed in the run's timeline, so it
|
||||
must not be used as the original gravity: it sorts to the front
|
||||
and would put the OG after the FG.
|
||||
"""
|
||||
dated = [r for r in self.readings
|
||||
if r.sg_points is not None and r.date is not None]
|
||||
return [r.sg_points for r in dated]
|
||||
|
||||
def original_gravity(self) -> int | None:
|
||||
"""OG = the first *dated* usable reading of the run."""
|
||||
values = self.dated_sg_values()
|
||||
return values[0] if values else None
|
||||
|
||||
def final_gravity(self) -> int | None:
|
||||
"""FG = the last *dated* usable reading of the run (if it is finished).
|
||||
|
||||
For a finished run the final reading is the one flagged as final,
|
||||
which is the reading the brewer took when they racked it.
|
||||
"""
|
||||
final = self.final_reading()
|
||||
if final is not None and final.sg_points is not None:
|
||||
return final.sg_points
|
||||
values = self.dated_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.
|
||||
|
||||
A run that is still in the fermenter has no measured alcohol - its
|
||||
figure is a projection, not a measurement. Returning the drop here
|
||||
for an unfinished run would report a "measured" ABV for a beer that
|
||||
has not finished fermenting, which is wrong.
|
||||
"""
|
||||
if self.is_active():
|
||||
return None
|
||||
drop = self.gravity_drop_points()
|
||||
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 name is resolved by preferring, in order:
|
||||
|
||||
1. The name on the **final** reading. That is the one the operator
|
||||
wrote when the brew was finished, so it is the name they settled
|
||||
on. Run 4 resolves to "Nocturna Christmas beer" rather than
|
||||
"Necatron-Christmas Beer" from the first reading.
|
||||
2. The name used on the most readings, breaking ties towards the most
|
||||
recent. This catches runs whose name changed exactly once.
|
||||
3. The name on the most recent reading, if it appears only once.
|
||||
|
||||
A run whose readings carry no name at all falls back to "Run N".
|
||||
"""
|
||||
named = [r for r in self.readings if r.beer_type]
|
||||
if not named:
|
||||
return f"Run {self.beer_run_id}"
|
||||
|
||||
final = self.final_reading()
|
||||
if final is not None and final.beer_type:
|
||||
return final.beer_type
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for reading in named:
|
||||
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1
|
||||
|
||||
return max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
||||
|
||||
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 the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points / 1000:.3f}"
|
||||
|
||||
|
||||
def _fmt_point_scale(points: int | None) -> str:
|
||||
"""Format the gravity in brewing "points".
|
||||
|
||||
A reading of 1.039 is 39 points in the homebrew sense - the digits after
|
||||
the decimal point. The stored value ``1039`` is the full reading, not a
|
||||
point count, so it must not be labelled "points".
|
||||
"""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points % 1000}"
|
||||
|
||||
|
||||
def fermentation_curve(run: Run) -> list[dict]:
|
||||
"""The run's gravity readings shaped for plotting.
|
||||
|
||||
Each point carries the reading date, the gravity, and how far through
|
||||
the fermentation it was - the fraction of the total gravity drop that
|
||||
had happened by that reading. That makes two runs with different
|
||||
gravities directly comparable, which is the point of the chart.
|
||||
"""
|
||||
values = run.sg_values()
|
||||
if len(values) < 2:
|
||||
return []
|
||||
|
||||
og = values[0]
|
||||
total_drop = og - values[-1]
|
||||
if total_drop <= 0:
|
||||
# No drop to measure progress against.
|
||||
return []
|
||||
|
||||
points = []
|
||||
for reading in run.readings:
|
||||
if reading.sg_points is None:
|
||||
continue
|
||||
drop = og - reading.sg_points
|
||||
points.append({
|
||||
"date": reading.date.isoformat() if reading.date else None,
|
||||
"sg": reading.sg_points,
|
||||
"sg_decimal": _fmt_decimal(reading.sg_points),
|
||||
"point_scale": _fmt_point_scale(reading.sg_points),
|
||||
# 0.0 at the start, 1.0 at the final reading.
|
||||
"progress": round(max(0.0, min(1.0, drop / total_drop)), 4),
|
||||
"final": reading.final_reading,
|
||||
"comment": reading.comment,
|
||||
})
|
||||
return points
|
||||
|
||||
|
||||
def attenuation_percent(run: Run) -> float | None:
|
||||
"""The apparent attenuation: how much of the original gravity was
|
||||
fermented away.
|
||||
|
||||
This is the number a brewer actually compares between recipes, and it is
|
||||
more informative than ABV alone because it normalises for how strong the
|
||||
starting wort was.
|
||||
|
||||
Returns ``None`` when the figure would be meaningless:
|
||||
|
||||
- The original gravity was not above 1.000, so there is nothing to
|
||||
attenuate.
|
||||
- The final gravity is below 1.000. Beer cannot be less dense than
|
||||
water, so a reading below 1.000 means the reading is wrong (a
|
||||
mis-entry, or a hydrometer read at the wrong temperature). Run 12 in
|
||||
the live data has a final gravity of 900, which would otherwise show
|
||||
as a nonsense 338% attenuation. The run is reported separately by
|
||||
:func:`implausible_runs` rather than being quietly folded into the
|
||||
averages.
|
||||
"""
|
||||
og = run.original_gravity()
|
||||
fg = run.final_gravity()
|
||||
if og is None or fg is None or og <= 1000 or fg < 1000:
|
||||
return None
|
||||
if fg > og:
|
||||
# The gravity went UP. That is not attenuation - it is either a
|
||||
# mis-ordered pair of readings or a contaminated sample, and a
|
||||
# negative attenuation is not a number a brewer can act on.
|
||||
return None
|
||||
attenuation = (og - fg) / (og - 1000) * 100
|
||||
if attenuation > 100:
|
||||
# Cannot ferment away more than the sugar that was there.
|
||||
return None
|
||||
return round(attenuation, 1)
|
||||
|
||||
|
||||
def implausible_runs(runs: Iterable[Run]) -> list[dict]:
|
||||
"""The runs whose gravity readings are physically impossible.
|
||||
|
||||
Beer cannot be less dense than water, so a final gravity below 1.000
|
||||
means a reading is wrong. These runs are excluded from the averages and
|
||||
listed on the analysis page instead of being silently included.
|
||||
"""
|
||||
problems = []
|
||||
for run in runs:
|
||||
og = run.original_gravity()
|
||||
fg = run.final_gravity()
|
||||
if fg is not None and fg < 1000:
|
||||
problems.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"og_points": og,
|
||||
"fg_points": fg,
|
||||
"problem": (
|
||||
f"final gravity {fg / 1000:.3f} is below 1.000 — "
|
||||
"beer cannot be less dense than water, so this reading "
|
||||
"is probably wrong"
|
||||
),
|
||||
})
|
||||
elif og is not None and fg is not None and fg > og:
|
||||
problems.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"og_points": og,
|
||||
"fg_points": fg,
|
||||
"problem": (
|
||||
f"the final gravity {fg / 1000:.3f} is higher than the "
|
||||
f"original {og / 1000:.3f} — the gravity went up, which "
|
||||
"is usually a mis-ordered pair of readings"
|
||||
),
|
||||
})
|
||||
elif og is not None and fg is not None and og > 1000 and fg > 1000 \
|
||||
and (og - fg) / (og - 1000) * 100 > 100:
|
||||
problems.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"og_points": og,
|
||||
"fg_points": fg,
|
||||
"problem": "the gravity drop implies more than 100% attenuation",
|
||||
})
|
||||
return problems
|
||||
|
||||
|
||||
def days_to_target(run: Run, target: int) -> int | None:
|
||||
"""How many days the run took to reach *target* gravity.
|
||||
|
||||
Used to answer "how long does this usually take?", which is the question
|
||||
a brewer actually has when planning the next batch.
|
||||
"""
|
||||
first = run.first_reading()
|
||||
if first is None or first.date is None:
|
||||
# Without a start date there is no elapsed time to measure.
|
||||
return None
|
||||
|
||||
# Skip the readings that cannot be placed on the timeline (no date)
|
||||
# rather than giving up on the whole run because of one of them.
|
||||
for reading in run.readings:
|
||||
if reading.sg_points is None or reading.sg_points > target:
|
||||
continue
|
||||
if reading.date is None:
|
||||
continue
|
||||
return days_between(first.date, reading.date)
|
||||
return None
|
||||
|
||||
|
||||
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),
|
||||
"og_point_scale": _fmt_point_scale(og),
|
||||
"fg_point_scale": _fmt_point_scale(fg),
|
||||
"max_sg": run.max_sg(),
|
||||
"min_sg": run.min_sg(),
|
||||
"min_sg_decimal": _fmt_decimal(run.min_sg()),
|
||||
"min_sg_point_scale": _fmt_point_scale(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())
|
||||
if not run.is_active() else None),
|
||||
"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(),
|
||||
"last_comment": run.comments()[-1] if run.comments() else "",
|
||||
# The advice the LLM gave for the most recent reading, if it has
|
||||
# arrived yet.
|
||||
"advice": run.latest_advice(),
|
||||
"advice_ok": run.latest_advice_ok(),
|
||||
"attenuation": attenuation_percent(run),
|
||||
"curve": fermentation_curve(run),
|
||||
"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,
|
||||
"advice": r.advice,
|
||||
"advice_ok": r.advice_ok,
|
||||
}
|
||||
for r in run.readings
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,556 @@
|
||||
"""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
|
||||
import threading
|
||||
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"]
|
||||
self._ensure_indexes()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying Mongo client and release it.
|
||||
|
||||
A ``MongoClient`` keeps a connection pool and spawns three
|
||||
monitoring threads. ``close()`` alone does NOT reap them - they
|
||||
go when the client object is garbage collected - so the
|
||||
reference is dropped as well.
|
||||
|
||||
The request-scoped repository is closed by the app's teardown, but
|
||||
a caller that builds its own - the advice background thread, for
|
||||
instance - has to close it itself or the threads and the pool
|
||||
accumulate, three per reading.
|
||||
"""
|
||||
client = self.client
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Drop the reference so the monitor threads can be reaped.
|
||||
self.client = None
|
||||
|
||||
def __enter__(self) -> "BeerRepository":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self.close()
|
||||
|
||||
def _ensure_indexes(self) -> None:
|
||||
"""Make sure the lookup keys are indexed.
|
||||
|
||||
Every read of the run metadata filters on ``beer_run_id``, and the
|
||||
readings are always fetched by ``(beer_run_id, date)``. Without the
|
||||
indexes those are collection scans.
|
||||
"""
|
||||
try:
|
||||
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
|
||||
self.readings.create_index(
|
||||
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
|
||||
)
|
||||
except PyMongoError:
|
||||
# An index failure must not stop the app from serving, but it
|
||||
# should be visible: without the index the lookup is a collection
|
||||
# scan, which gets slow as the data grows.
|
||||
logger.warning(
|
||||
"Could not create the Mongo indexes; the lookups will be "
|
||||
"collection scans until they exist",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# -- 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),
|
||||
},
|
||||
# The advice was written for the reading that was there when
|
||||
# it was generated. If the reading has been edited, that
|
||||
# advice no longer describes it, so it is cleared and a fresh
|
||||
# one is generated. (Advice is only ever cleared here - never
|
||||
# the brewer's comment.)
|
||||
"$unset": {"advice": "", "advice_ok": "", "advice_at": ""},
|
||||
}
|
||||
# A fresh token marks this generation of the reading. The advice
|
||||
# thread carries the token it was given, so a thread that is
|
||||
# still working on an EARLIER version of the reading finds a
|
||||
# token that no longer matches and its write is refused. The
|
||||
# gravity alone is not enough - a comment-only correction leaves
|
||||
# the gravity unchanged.
|
||||
update["$set"]["advice_token"] = _new_token()
|
||||
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_reading_advice(self, reading_id: str, advice: str,
|
||||
ok: bool = True,
|
||||
expected_sg: int | None = None,
|
||||
expected_token: str | None = None) -> int:
|
||||
"""Attach the LLM's advice to a reading.
|
||||
|
||||
The advice lives in its own field, so the brewer's own comment is
|
||||
never overwritten. ``ok`` says whether the advice was actually
|
||||
generated or is a note explaining that it could not be.
|
||||
|
||||
The write is CONDITIONAL, so a slow advice thread cannot
|
||||
overwrite a correction:
|
||||
|
||||
- ``expected_token`` is the editing token of the reading at the
|
||||
moment the advice was generated. Every edit writes a fresh
|
||||
token, so a late thread's token no longer matches.
|
||||
- ``expected_sg`` is the gravity the advice was generated for,
|
||||
as a second check.
|
||||
|
||||
This matters because the advice is generated in the background: if
|
||||
the brewer corrects a reading while the advice for the old value
|
||||
is still being generated, the slow thread would land afterwards
|
||||
and overwrite the fresh advice with advice about a reading that
|
||||
no longer exists.
|
||||
|
||||
The token is needed as well as the gravity, because a
|
||||
comment-only correction leaves the gravity unchanged - the token
|
||||
is what tells the two generations apart.
|
||||
"""
|
||||
doc_id = _as_doc_id(reading_id)
|
||||
if not doc_id:
|
||||
return 0
|
||||
|
||||
query: dict = {"_id": {"$in": doc_id}}
|
||||
if expected_token:
|
||||
# The editing token is the PRIMARY guard: it changes on every
|
||||
# edit, so it catches a stale generation whatever else
|
||||
# changed - the gravity, the comment, or nothing.
|
||||
query["advice_token"] = str(expected_token)
|
||||
elif expected_token == "":
|
||||
# The reading has NEVER been edited, so it has no token at
|
||||
# all. Match on the ABSENCE of one, which still tells the
|
||||
# generations apart: an edit writes a token, so a stale
|
||||
# thread's "" stops matching the moment the reading is
|
||||
# corrected.
|
||||
#
|
||||
# Falling back to the gravity here would re-open the hole
|
||||
# this guard exists to close - a comment-only correction
|
||||
# leaves the gravity unchanged.
|
||||
# `$in` with null also matches a MISSING field, so this
|
||||
# covers a reading with no token AND one whose token field
|
||||
# is present but empty.
|
||||
query["advice_token"] = {"$in": [None, ""]}
|
||||
elif expected_sg is not None:
|
||||
# A direct call with a gravity to check against.
|
||||
query["sg"] = str(expected_sg)
|
||||
|
||||
# `$in` matches either form. A plain list would be an exact
|
||||
# ARRAY match in Mongo, which is not what is wanted here.
|
||||
result = self.readings.update_one(
|
||||
query,
|
||||
{"$set": {
|
||||
"advice": str(advice),
|
||||
"advice_ok": bool(ok),
|
||||
"advice_at": _now_iso(),
|
||||
}},
|
||||
)
|
||||
return result.modified_count
|
||||
|
||||
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. A field passed as ``None`` is **cleared** — that is how the
|
||||
form removes a name or a tap number the operator has deleted.
|
||||
|
||||
Use ``upsert_run_metadata_partial`` to update some fields while
|
||||
leaving the rest untouched.
|
||||
"""
|
||||
payload = dict(fields)
|
||||
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 upsert_run_metadata_partial(self, beer_run_id: str, **fields: Any) -> dict:
|
||||
"""Update only the given fields, leaving every other field alone.
|
||||
|
||||
Fields passed as ``None`` are ignored rather than cleared.
|
||||
"""
|
||||
payload = {k: v for k, v in fields.items() if v is not None}
|
||||
if not payload:
|
||||
return self.run_metadata(beer_run_id)
|
||||
payload["updated_at"] = _now_iso()
|
||||
self.run_meta.update_one(
|
||||
{"beer_run_id": _as_id(beer_run_id)},
|
||||
{"$set": payload},
|
||||
upsert=True,
|
||||
)
|
||||
return self.run_metadata(beer_run_id)
|
||||
|
||||
def next_free_tap_number(self) -> int:
|
||||
"""The lowest tap number not already in use.
|
||||
|
||||
Taps are numbered from 1. A run with no tap number recorded does not
|
||||
occupy one.
|
||||
"""
|
||||
used = {
|
||||
meta.get("tap_number")
|
||||
for meta in self.all_run_metadata().values()
|
||||
if meta.get("tap_number") is not None
|
||||
}
|
||||
candidate = 1
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
return candidate
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
#: A process-wide repository, for the code that runs outside a request.
|
||||
#:
|
||||
#: A ``MongoClient`` spawns three monitor threads and pymongo does not
|
||||
#: reap them on ``close()`` - they go only when the client is garbage
|
||||
#: collected. So building a client per advice write accumulated three
|
||||
#: threads and a connection pool for every reading recorded. One shared
|
||||
#: client for the life of the process is what the request-scoped
|
||||
#: repository does too.
|
||||
_shared_repository: "BeerRepository | None" = None
|
||||
|
||||
#: Guards the check-then-set above.
|
||||
#:
|
||||
#: Without it, concurrent advice threads each saw ``None`` and each
|
||||
#: built their own repository - so the fix reintroduced the very leak it
|
||||
#: was meant to remove, just bounded by a race instead of by the number
|
||||
#: of readings. Eight threads on a cold process built eight clients and
|
||||
#: twenty-five threads.
|
||||
_shared_repository_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_shared_repository() -> "BeerRepository":
|
||||
"""The process-wide repository, built on first use.
|
||||
|
||||
Thread-safe: the first caller builds it, the rest wait and get the
|
||||
same one.
|
||||
"""
|
||||
global _shared_repository
|
||||
|
||||
if _shared_repository is None:
|
||||
with _shared_repository_lock:
|
||||
# Checked again inside the lock - another thread may have
|
||||
# built it while this one was waiting.
|
||||
if _shared_repository is None:
|
||||
_shared_repository = BeerRepository()
|
||||
return _shared_repository
|
||||
|
||||
|
||||
def _as_doc_id(value: object):
|
||||
"""Coerce a reading document id to something Mongo will match.
|
||||
|
||||
The ``_id`` of a reading is not a run id, so the ``_as_id`` coercion
|
||||
does not apply - it would turn the id into an empty string and the
|
||||
update would silently match nothing.
|
||||
|
||||
Two shapes exist in this database:
|
||||
|
||||
- the ORIGINAL app's ids are 24 random CHARACTERS stored as a string
|
||||
(``l78ZidBJ3QaYn98tvB88x4uM``), and
|
||||
- ids written by Mongo itself are ``ObjectId`` objects.
|
||||
|
||||
So the id is returned in BOTH forms and the query matches either.
|
||||
Accepting only the ObjectId form would mean the advice was never
|
||||
stored for any of the existing readings.
|
||||
|
||||
Only an ObjectId or a plain string is accepted, so a dict can never
|
||||
be injected as a query operator.
|
||||
"""
|
||||
from bson import ObjectId
|
||||
|
||||
if isinstance(value, ObjectId):
|
||||
return [value]
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
# A 24-character hex string may or may not be the stored
|
||||
# ObjectId or the stored string, so try both.
|
||||
return [ObjectId(value), value]
|
||||
except Exception:
|
||||
# Not hex - it can only be the original app's string id.
|
||||
return [value]
|
||||
# Anything else - a dict, a list, an int, None - is not a reading
|
||||
# id. An EMPTY LIST is returned rather than None: it is a valid
|
||||
# `$in` argument that matches nothing, so the helper cannot be
|
||||
# misused into a query error or into matching every document.
|
||||
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 _new_token() -> str:
|
||||
"""A fresh token, to mark one generation of a reading from the next."""
|
||||
import secrets
|
||||
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Brew Log">
|
||||
<!-- pint glass -->
|
||||
<path d="M6 5h16l-1.6 20.2a3 3 0 0 1-3 2.8h-6.8a3 3 0 0 1-3-2.8Z"
|
||||
fill="none" stroke="#be7f39" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- beer -->
|
||||
<path d="M7.6 11h12.9l-1.3 13.9a1.4 1.4 0 0 1-1.4 1.3h-7.5a1.4 1.4 0 0 1-1.4-1.3Z"
|
||||
fill="#be7f39" opacity="0.55"/>
|
||||
<!-- foam -->
|
||||
<path d="M6.6 11.5c1.6-1.7 3.2.7 4.8-.6s2.8 1.4 4.4-.2 2.6 1.1 3.6.3"
|
||||
fill="none" stroke="#f2ede8" stroke-width="1.9" stroke-linecap="round"/>
|
||||
<!-- bubbles -->
|
||||
<circle cx="12" cy="16" r="1.1" fill="#f2ede8" opacity="0.75"/>
|
||||
<circle cx="16.5" cy="20" r="0.9" fill="#f2ede8" opacity="0.6"/>
|
||||
<circle cx="11" cy="22.5" r="0.8" fill="#f2ede8" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 823 B |
@@ -0,0 +1,247 @@
|
||||
/* ==========================================================================
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The brewing advice pop-out.
|
||||
*
|
||||
* On a desktop the panel opens on hover, which the CSS does. A
|
||||
* phone has no hover, so the trigger is a button that toggles it -
|
||||
* and it is a real button, so it works with a keyboard and with a
|
||||
* screen reader.
|
||||
*/
|
||||
/**
|
||||
* The brewing advice pop-out.
|
||||
*
|
||||
* The advice runs to several hundred characters. In a table cell it
|
||||
* pushed the table past the width of a phone, so it is a compact
|
||||
* trigger instead and the text is shown in a floating panel.
|
||||
*
|
||||
* The panel is position:fixed so it escapes the table's scroll
|
||||
* container - absolutely positioned inside the cell it was clipped
|
||||
* by the container and sliced in half by the card's edge.
|
||||
*
|
||||
* It opens on hover on a desktop, and on tap on a phone, which has
|
||||
* no hover. The trigger is a real button, so it also works with a
|
||||
* keyboard.
|
||||
*/
|
||||
function wireAdvicePopouts() {
|
||||
var triggers = document.querySelectorAll(".advice-trigger");
|
||||
for (var i = 0; i < triggers.length; i++) {
|
||||
(function (trigger) {
|
||||
var panel = trigger.parentNode.querySelector(".advice-pop");
|
||||
if (!panel) { return; }
|
||||
|
||||
var closeTimer = null;
|
||||
|
||||
function isPhone() {
|
||||
return window.matchMedia("(max-width: 720px)").matches;
|
||||
}
|
||||
|
||||
function place() {
|
||||
if (isPhone()) {
|
||||
/* The CSS centres it on a phone - clear any
|
||||
inline position left over from a desktop
|
||||
resize. */
|
||||
panel.style.top = "";
|
||||
panel.style.left = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var box = trigger.getBoundingClientRect();
|
||||
var gap = 10;
|
||||
var height = panel.offsetHeight;
|
||||
var width = panel.offsetWidth;
|
||||
var spaceBelow = window.innerHeight - box.bottom - gap;
|
||||
var spaceAbove = box.top - gap;
|
||||
|
||||
/* Prefer below; flip above when there is not enough
|
||||
room and there is more room above. */
|
||||
var top;
|
||||
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
|
||||
top = box.bottom + gap;
|
||||
} else {
|
||||
top = Math.max(gap, box.top - height - gap);
|
||||
}
|
||||
|
||||
/* Keep it on screen horizontally. */
|
||||
var left = Math.min(box.left, window.innerWidth - width - gap);
|
||||
left = Math.max(gap, left);
|
||||
|
||||
panel.style.top = Math.round(top) + "px";
|
||||
panel.style.left = Math.round(left) + "px";
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
panel.classList.add("is-open");
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
place();
|
||||
}
|
||||
|
||||
function close() {
|
||||
panel.classList.remove("is-open");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function closeSoon() {
|
||||
/* A short delay, so the mouse can travel from the
|
||||
trigger into the panel without it closing. */
|
||||
if (closeTimer) { clearTimeout(closeTimer); }
|
||||
closeTimer = setTimeout(function () {
|
||||
closeTimer = null;
|
||||
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
|
||||
close();
|
||||
}
|
||||
}, 180);
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (panel.classList.contains("is-open")) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
trigger.addEventListener("mouseenter", function () {
|
||||
if (!isPhone()) { open(); }
|
||||
});
|
||||
trigger.addEventListener("mouseleave", closeSoon);
|
||||
panel.addEventListener("mouseenter", function () {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
});
|
||||
panel.addEventListener("mouseleave", closeSoon);
|
||||
|
||||
var closeButton = panel.querySelector(".advice-pop__close");
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
close();
|
||||
trigger.focus();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape" && panel.classList.contains("is-open")) {
|
||||
close();
|
||||
trigger.focus();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", function (event) {
|
||||
if (!panel.contains(event.target) && event.target !== trigger) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (panel.classList.contains("is-open")) { place(); }
|
||||
});
|
||||
window.addEventListener("scroll", function () {
|
||||
if (panel.classList.contains("is-open")) { close(); }
|
||||
}, true);
|
||||
})(triggers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function labelTableCells() {
|
||||
var tables = document.querySelectorAll("table.data");
|
||||
for (var t = 0; t < tables.length; t++) {
|
||||
var headerRow = tables[t].querySelector("thead tr");
|
||||
if (!headerRow) { continue; }
|
||||
var labels = [];
|
||||
var headers = headerRow.querySelectorAll("th");
|
||||
for (var h = 0; h < headers.length; h++) {
|
||||
labels.push(headers[h].textContent.trim());
|
||||
}
|
||||
|
||||
var rows = tables[t].querySelectorAll("tbody tr");
|
||||
for (var r = 0; r < rows.length; r++) {
|
||||
var cells = rows[r].querySelectorAll("td");
|
||||
for (var c = 0; c < cells.length && c < labels.length; c++) {
|
||||
if (labels[c] && !cells[c].getAttribute("data-label")) {
|
||||
cells[c].setAttribute("data-label", labels[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
focusFirstEmpty();
|
||||
defaultDateFields();
|
||||
wireToggles();
|
||||
wireConfirms();
|
||||
wireAdvicePopouts();
|
||||
labelTableCells();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,635 @@
|
||||
/* ==========================================================================
|
||||
Brew Log — charts
|
||||
Uses the vendored Chart.js (static/js/chart.umd.js) — no CDN at runtime, so
|
||||
the container works on an isolated network.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
if (typeof Chart === "undefined") {
|
||||
console.warn("Chart.js is not loaded; charts are disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ACCENT = "#be7f39";
|
||||
var ACCENT_BRIGHT = "#dda05c";
|
||||
var MUTED = "#afafaf";
|
||||
var GRID = "rgba(175, 175, 175, 0.13)";
|
||||
var WARN = "#e8a33d";
|
||||
|
||||
Chart.defaults.color = MUTED;
|
||||
Chart.defaults.font.family =
|
||||
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
Chart.defaults.font.size = 11;
|
||||
Chart.defaults.borderColor = GRID;
|
||||
|
||||
function withAlpha(hex, alpha) {
|
||||
var r = parseInt(hex.slice(1, 3), 16);
|
||||
var g = parseInt(hex.slice(3, 5), 16);
|
||||
var b = parseInt(hex.slice(5, 7), 16);
|
||||
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
|
||||
}
|
||||
|
||||
function baseOptions(extra) {
|
||||
var options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true } }
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { color: GRID }, ticks: { maxRotation: 0, autoSkipPadding: 12 } },
|
||||
y: { grid: { color: GRID } }
|
||||
}
|
||||
};
|
||||
if (extra) {
|
||||
Object.keys(extra).forEach(function (key) {
|
||||
options[key] = extra[key];
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- sparklines */
|
||||
|
||||
function drawSparklines() {
|
||||
var canvases = document.querySelectorAll("canvas.sparkline");
|
||||
for (var i = 0; i < canvases.length; i++) {
|
||||
(function (canvas) {
|
||||
var labels = JSON.parse(canvas.getAttribute("data-labels") || "[]");
|
||||
var values = JSON.parse(canvas.getAttribute("data-values") || "[]");
|
||||
var points = [];
|
||||
for (var j = 0; j < values.length; j++) {
|
||||
if (values[j] !== null && values[j] !== undefined) {
|
||||
points.push({ x: labels[j], y: values[j] });
|
||||
}
|
||||
}
|
||||
if (!points.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
data: points,
|
||||
borderColor: ACCENT_BRIGHT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.18),
|
||||
borderWidth: 2,
|
||||
pointRadius: points.length > 12 ? 0 : 2,
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) { return ctx.parsed.y + " (" + ctx.parsed.x + ")"; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { display: false, grid: { display: false } },
|
||||
y: { display: false, grid: { display: false } }
|
||||
}
|
||||
})
|
||||
});
|
||||
})(canvases[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- run detail chart */
|
||||
|
||||
function drawRunChart() {
|
||||
var canvas = document.getElementById("run-chart");
|
||||
if (!canvas) { return; }
|
||||
// The run id comes from a data attribute rather than a global, so the
|
||||
// page needs no inline script (a strict CSP forbids inline scripts).
|
||||
var runId = canvas.getAttribute("data-run-id");
|
||||
if (!runId) { return; }
|
||||
|
||||
fetch("/api/runs/" + encodeURIComponent(runId), {
|
||||
headers: { "Accept": "application/json" }
|
||||
})
|
||||
.then(function (response) {
|
||||
if (!response.ok) { throw new Error("run fetch failed"); }
|
||||
return response.json();
|
||||
})
|
||||
.then(function (run) {
|
||||
var points = [];
|
||||
(run.readings || []).forEach(function (r) {
|
||||
if (r.sg !== null && r.sg !== undefined) {
|
||||
points.push({ x: r.date, y: r.sg });
|
||||
}
|
||||
});
|
||||
if (!points.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [{
|
||||
label: "Gravity",
|
||||
data: points,
|
||||
borderColor: ACCENT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT,
|
||||
fill: true,
|
||||
tension: 0.25
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
return ctx.parsed.y + " points (" + ctx.parsed.x + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
// The dates are strings, so this axis stays
|
||||
// categorical - but it must be declared,
|
||||
// because Chart.js would otherwise infer
|
||||
// something else from the {x, y} points.
|
||||
type: "category",
|
||||
ticks: { maxRotation: 0, autoSkipPadding: 12 }
|
||||
},
|
||||
y: {
|
||||
// NOT reversed - see the fermentation curve.
|
||||
// Gravity falls as the beer ferments, so the
|
||||
// line must descend left to right.
|
||||
title: { display: true, text: "Specific gravity (points)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ history charts */
|
||||
|
||||
function drawAbvChart() {
|
||||
var canvas = document.getElementById("abv-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetchRuns()
|
||||
.then(function (runs) {
|
||||
// Only finished brews have a measured ABV. An unfinished run
|
||||
// has a projection, not a measurement, so it is not plotted
|
||||
// here.
|
||||
var completed = runs.filter(function (r) {
|
||||
return r.abv !== null && r.abv !== undefined;
|
||||
});
|
||||
if (!completed.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: completed.map(function (r) {
|
||||
return r.canonical_name || ("Run " + r.beer_run_id);
|
||||
}),
|
||||
datasets: [{
|
||||
label: "ABV %",
|
||||
data: completed.map(function (r) { return r.abv; }),
|
||||
backgroundColor: withAlpha(ACCENT, 0.65),
|
||||
borderColor: ACCENT,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) { return ctx.parsed.y.toFixed(2) + "% ABV"; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
|
||||
x: {
|
||||
// The names are long and the chart is narrow,
|
||||
// so the labels are trimmed above and
|
||||
// rotated. autoSkip keeps them from
|
||||
// overlapping; the tooltip carries the
|
||||
// full name for every bar.
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
function drawMonthsChart() {
|
||||
var canvas = document.getElementById("months-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetchRuns()
|
||||
.then(function (runs) {
|
||||
var counts = {};
|
||||
runs.forEach(function (r) {
|
||||
var stamp = r.final_date || r.first_date;
|
||||
if (!stamp) { return; }
|
||||
var month = stamp.slice(0, 7);
|
||||
counts[month] = (counts[month] || 0) + 1;
|
||||
});
|
||||
var months = Object.keys(counts).sort();
|
||||
if (!months.length) { return; }
|
||||
|
||||
// Include the months with no brews, with a zero. Leaving the
|
||||
// gaps out would space the busy months evenly on the axis and
|
||||
// make the chart read as continuous activity.
|
||||
var filled = [];
|
||||
var cursor = months[0];
|
||||
var last = months[months.length - 1];
|
||||
while (cursor <= last) {
|
||||
filled.push(cursor);
|
||||
var year = parseInt(cursor.slice(0, 4), 10);
|
||||
var month = parseInt(cursor.slice(5, 7), 10);
|
||||
month += 1;
|
||||
if (month > 12) { month = 1; year += 1; }
|
||||
cursor = year + "-" + (month < 10 ? "0" + month : month);
|
||||
}
|
||||
months = filled;
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: months,
|
||||
datasets: [{
|
||||
label: "Brews",
|
||||
data: months.map(function (m) { return counts[m]; }),
|
||||
borderColor: ACCENT,
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT,
|
||||
fill: true,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1 } }
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ attenuation by brew */
|
||||
|
||||
function drawAttenuationChart() {
|
||||
var canvas = document.getElementById("attenuation-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetchRuns()
|
||||
.then(function (runs) {
|
||||
// A run with an impossible reading has no attenuation, and
|
||||
// is flagged as suspect. Both are excluded, so one bad
|
||||
// reading cannot become a bar in the chart.
|
||||
var done = runs.filter(function (r) {
|
||||
return !r.suspect &&
|
||||
r.attenuation !== null && r.attenuation !== undefined;
|
||||
});
|
||||
if (!done.length) { return; }
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: done.map(function (r) {
|
||||
var name = r.canonical_name || ("Run " + r.beer_run_id);
|
||||
// Long names were clipped at the left edge of
|
||||
// the plot when rotated. Trim them to a
|
||||
// readable length; the tooltip and the table
|
||||
// below carry the full name.
|
||||
if (name.length > 18) {
|
||||
name = name.slice(0, 17) + "\u2026";
|
||||
}
|
||||
return name;
|
||||
}),
|
||||
datasets: [{
|
||||
label: "Attenuation %",
|
||||
data: done.map(function (r) { return r.attenuation; }),
|
||||
backgroundColor: withAlpha(ACCENT, 0.65),
|
||||
borderColor: ACCENT,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
}]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
return ctx.parsed.y.toFixed(1) + "% attenuated";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
// Attenuation cannot exceed 100%, so the axis
|
||||
// is capped there. Without the cap a single
|
||||
// bad reading would squash every other bar
|
||||
// into an unreadable stub.
|
||||
suggestedMax: 100,
|
||||
title: { display: true, text: "% attenuated" }
|
||||
},
|
||||
x: {
|
||||
// The names are long and the chart is narrow,
|
||||
// so the labels are trimmed above and
|
||||
// rotated. autoSkip keeps them from
|
||||
// overlapping; the tooltip carries the
|
||||
// full name for every bar.
|
||||
ticks: {
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* -------------------------------------------- fermentation curve overlay */
|
||||
|
||||
function drawCurveChart() {
|
||||
var canvas = document.getElementById("curve-chart");
|
||||
if (!canvas) { return; }
|
||||
|
||||
fetchRuns()
|
||||
.then(function (runs) {
|
||||
// A run with an impossible reading has a broken curve - it
|
||||
// plunges to a gravity below water - which would drag the
|
||||
// median line down and stretch the envelope to cover it.
|
||||
// Only FINISHED brews belong on the curve. An active run's
|
||||
// curve is a partial fermentation, and the curve is meant to
|
||||
// describe how a completed fermentation behaves - including
|
||||
// a partial one pulls the median line off the real shape.
|
||||
var withCurve = runs.filter(function (r) {
|
||||
return !r.suspect && !r.active &&
|
||||
r.curve && r.curve.length > 1;
|
||||
});
|
||||
if (!withCurve.length) { return; }
|
||||
|
||||
// Plotting every brew individually gives 16 near-identical
|
||||
// lines and an unreadable legend. Instead: a MEDIAN curve
|
||||
// with the min/max envelope behind it, which shows the
|
||||
// typical shape and the spread without the clutter.
|
||||
//
|
||||
// Each curve is the fraction of its own gravity drop
|
||||
// completed, so brews with different starting gravities can
|
||||
// be compared directly.
|
||||
var samples = 21; // 0%, 5%, ... 100%
|
||||
|
||||
// Interpolate each brew's curve onto the common progress axis.
|
||||
//
|
||||
// The brew's readings are at its own progress values (say
|
||||
// 0%, 40%, 100%), so each sample point is interpolated
|
||||
// BETWEEN the two readings that bracket it. Looking the
|
||||
// value up by an exact key only works when every brew
|
||||
// happened to be read at the same progress points, which is
|
||||
// why the first attempt produced three points.
|
||||
function interpolate(curve, pct) {
|
||||
if (!curve.length) { return null; }
|
||||
if (pct <= curve[0].x) { return curve[0].y; }
|
||||
var last = curve[curve.length - 1];
|
||||
if (pct >= last.x) { return last.y; }
|
||||
for (var k = 0; k < curve.length - 1; k++) {
|
||||
var a = curve[k];
|
||||
var b = curve[k + 1];
|
||||
if (pct >= a.x && pct <= b.x) {
|
||||
var span = b.x - a.x;
|
||||
if (span === 0) { return a.y; }
|
||||
return a.y + (b.y - a.y) * ((pct - a.x) / span);
|
||||
}
|
||||
}
|
||||
return last.y;
|
||||
}
|
||||
|
||||
// The raw readings of each brew, sorted by progress.
|
||||
var rawCurves = withCurve.map(function (r) {
|
||||
var points = r.curve.map(function (p) {
|
||||
return {
|
||||
x: p.progress * 100,
|
||||
y: parseFloat(p.sg_decimal)
|
||||
};
|
||||
}).filter(function (p) { return !isNaN(p.y); });
|
||||
points.sort(function (a, b) { return a.x - b.x; });
|
||||
return points;
|
||||
}).filter(function (c) { return c.length > 1; });
|
||||
|
||||
if (!rawCurves.length) { return; }
|
||||
|
||||
// The interpolated value of every brew at every sample point.
|
||||
var sampled = rawCurves.map(function (curve) {
|
||||
var out = [];
|
||||
for (var i = 0; i < samples; i++) {
|
||||
var pct = (i / (samples - 1)) * 100;
|
||||
var y = interpolate(curve, pct);
|
||||
if (y === null) { continue; }
|
||||
out.push({ x: pct, y: y });
|
||||
}
|
||||
return out;
|
||||
}).filter(function (c) { return c.length > 1; });
|
||||
|
||||
if (!sampled.length) { return; }
|
||||
|
||||
// The envelope: the highest and lowest gravity at each
|
||||
// progress point.
|
||||
var envelope = [];
|
||||
var median = [];
|
||||
var envelopeMax = [];
|
||||
for (var i = 0; i < samples; i++) {
|
||||
var pct = (i / (samples - 1)) * 100;
|
||||
var values = [];
|
||||
sampled.forEach(function (curve) {
|
||||
var match = curve.filter(function (p) {
|
||||
return Math.abs(p.x - pct) < 0.01;
|
||||
});
|
||||
if (match.length) { values.push(match[0].y); }
|
||||
});
|
||||
if (!values.length) { continue; }
|
||||
values.sort(function (a, b) { return a - b; });
|
||||
envelope.push({
|
||||
x: pct,
|
||||
y: values[0],
|
||||
yMax: values[values.length - 1]
|
||||
});
|
||||
envelopeMax.push({ x: pct, y: values[values.length - 1] });
|
||||
var mid = Math.floor(values.length / 2);
|
||||
median.push({
|
||||
x: pct,
|
||||
y: values.length % 2
|
||||
? values[mid]
|
||||
: (values[mid - 1] + values[mid]) / 2
|
||||
});
|
||||
}
|
||||
|
||||
new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
// The band is drawn as TWO datasets: the min
|
||||
// curve filled down to the max curve. With
|
||||
// a single dataset and `fill: "+1"` the band
|
||||
// was filled to the NEXT dataset - which was
|
||||
// the median line, not the max - so the
|
||||
// tooltip and the shading disagreed.
|
||||
label: "Range (min–max)",
|
||||
data: envelope,
|
||||
borderColor: withAlpha(ACCENT, 0.35),
|
||||
backgroundColor: withAlpha(ACCENT, 0.12),
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
fill: "+1",
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
// The upper edge of the band. It is the
|
||||
// dataset the one above fills to.
|
||||
label: "Range max",
|
||||
data: envelopeMax,
|
||||
borderColor: withAlpha(ACCENT, 0.35),
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
fill: false,
|
||||
tension: 0.3
|
||||
},
|
||||
{
|
||||
label: "Median brew",
|
||||
data: median,
|
||||
borderColor: ACCENT_BRIGHT,
|
||||
backgroundColor: withAlpha(ACCENT_BRIGHT, 0.15),
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: ACCENT_BRIGHT,
|
||||
fill: false,
|
||||
tension: 0.3
|
||||
}
|
||||
]
|
||||
},
|
||||
options: baseOptions({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
usePointStyle: true,
|
||||
font: { size: 11 },
|
||||
// The max curve is the helper the band
|
||||
// fills to, not a series of its own.
|
||||
filter: function (item, data) {
|
||||
return item.text !== "Range max";
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (ctx) {
|
||||
var point = ctx.parsed;
|
||||
if (ctx.datasetIndex === 0) {
|
||||
return "range " + point.y +
|
||||
" to " + (point.yMax || point.y);
|
||||
}
|
||||
if (ctx.datasetIndex === 1) {
|
||||
return null;
|
||||
}
|
||||
return "median " + point.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
// The x values are NUMERIC (0-100), so the
|
||||
// axis must be linear. Chart.js defaults a
|
||||
// line chart's x-axis to category, which
|
||||
// collapses every point onto index 0 and
|
||||
// renders the curve as a sliver at the
|
||||
// origin.
|
||||
type: "linear",
|
||||
title: { display: true, text: "% of gravity drop" },
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { stepSize: 10 }
|
||||
},
|
||||
y: {
|
||||
// NOT reversed. Specific gravity falls as
|
||||
// the beer ferments, and the scale puts the
|
||||
// HIGH reading at the top by default, so the
|
||||
// curve descends left to right - the way a
|
||||
// brewer reads a fermentation. With
|
||||
// reverse:true the curve climbed instead,
|
||||
// which read as gravity rising.
|
||||
title: { display: true, text: "Specific gravity" }
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
.catch(function (err) { console.warn(err); });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- shared run payload */
|
||||
|
||||
// The analysis page has two charts that both need /api/runs, and the
|
||||
// payload contains every reading of every run. Fetch it once and
|
||||
// share it rather than making two identical round-trips.
|
||||
var runsPromise = null;
|
||||
|
||||
function fetchRuns() {
|
||||
if (!runsPromise) {
|
||||
runsPromise = fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||
.then(function (r) { return r.json(); })
|
||||
.catch(function (err) {
|
||||
console.warn(err);
|
||||
runsPromise = null;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
return runsPromise;
|
||||
}
|
||||
|
||||
function init() {
|
||||
drawSparklines();
|
||||
drawRunChart();
|
||||
drawAbvChart();
|
||||
drawMonthsChart();
|
||||
drawAttenuationChart();
|
||||
drawCurveChart();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Active Beers — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">Active Beers</h1>
|
||||
<p class="page__sub">Everything currently in the fermenter, with its tap and projected strength.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Record a reading</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--3 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Active brews</div>
|
||||
<div class="stat__value">{{ totals.count }}</div>
|
||||
<div class="stat__meta">in the fermenter now</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average age</div>
|
||||
<div class="stat__value">{{ totals.avg_days }}</div>
|
||||
<div class="stat__meta">day{{ '' if totals.avg_days == '1' else 's' }} since first reading</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Longest running</div>
|
||||
<div class="stat__value">{{ totals.longest_days if totals.longest_days is not none else '—' }}</div>
|
||||
<div class="stat__meta">day{{ '' if totals.longest_days == 1 else 's' }} in the fermenter</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if cards %}
|
||||
<div class="grid grid--cards">
|
||||
{% for run in cards %}
|
||||
<article class="brew-card">
|
||||
<div class="brew-card__head">
|
||||
<div>
|
||||
<h2 class="brew-card__name">
|
||||
<a href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">{{ run.canonical_name }}</a>
|
||||
</h2>
|
||||
<div class="brew-card__meta">
|
||||
Run {{ run.beer_run_id }}
|
||||
{% if run.first_date %} · since {{ run.first_date }}{% endif %}
|
||||
{% if run.reading_count == 1 %} · 1 reading{% else %} · {{ run.reading_count }} readings{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if run.tap_number is not none %}
|
||||
<span class="chip chip--tap">Tap {{ run.tap_number }}</span>
|
||||
{% else %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="brew-card__stats">
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Gravity</div>
|
||||
<div class="stat__value">
|
||||
{% if run.min_sg is not none %}{{ run.min_sg_decimal }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if run.min_sg is not none %}
|
||||
<div class="stat__meta">{{ run.min_sg_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Est. ABV</div>
|
||||
<div class="stat__value">
|
||||
{% if run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if run.projected_abv is not none %}projected{% else %}needs 2nd reading{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="brew-card__stat">
|
||||
<div class="stat__label">Days</div>
|
||||
<div class="stat__value">
|
||||
{% if run.days_in_fermenter is not none %}{{ run.days_in_fermenter }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">day{{ '' if run.days_in_fermenter == 1 else 's' }} brewing</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if run.readings | length > 1 %}
|
||||
<div class="brew-card__spark">
|
||||
<canvas class="sparkline"
|
||||
data-labels='{{ run.readings | map(attribute="date") | list | tojson }}'
|
||||
data-values='{{ run.readings | map(attribute="sg") | list | tojson }}'></canvas>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="brew-card__spark brew-card__spark--empty faint tiny">
|
||||
One reading so far — the trend appears after the next one.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.last_comment %}
|
||||
<div class="small muted">
|
||||
<strong class="faint tiny">Latest:</strong> {{ run.last_comment }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if run.advice %}
|
||||
<div class="advice advice--reading{% if not run.advice_ok %} advice--failed{% endif %}">
|
||||
<span class="advice__label">Brewing advice</span>
|
||||
{{ run.advice }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
|
||||
<a class="btn btn--sm" href="{{ url_for('brews.index') }}">Add reading</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{# A tile that fills the last slot and gives the page its missing
|
||||
affordance: there is currently no way to start a brew from here. #}
|
||||
<a class="brew-card brew-card--new" href="{{ url_for('brews.index') }}">
|
||||
<span class="brew-card__new-mark" aria-hidden="true">+</span>
|
||||
<span class="brew-card__new-text">
|
||||
<strong>Start a new beer</strong>
|
||||
<span class="faint tiny">Record its first reading</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<p><strong>Nothing in the fermenter.</strong></p>
|
||||
<p class="small">Start a run by recording its first reading on the
|
||||
<a href="{{ url_for('brews.index') }}">brew process page</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Analysis — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">Analysis</h1>
|
||||
<p class="page__sub">How consistently the brews ferment, and how long each one takes.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not rows %}
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<p><strong>No finished brews to analyse yet.</strong></p>
|
||||
<p class="small">Once a run has a final reading it appears here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="grid grid--4 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average attenuation</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.avg_attenuation is not none %}{{ stats.avg_attenuation }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">of the original gravity fermented</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Spread</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.consistency %}±{{ stats.consistency.stdev }}pp{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.consistency %}
|
||||
standard deviation of attenuation, lower is better
|
||||
{% else %}
|
||||
needs at least two brews
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average brew time</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.avg_days is not none %}{{ stats.avg_days }}d{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">first reading to final reading</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Time to 1.010</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.avg_days_to_1010 is not none %}{{ stats.avg_days_to_1010 }}d{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.reached_1010 %}
|
||||
average of {{ stats.reached_1010 }} brew{{ '' if stats.reached_1010 == 1 else 's' }} that got there
|
||||
{% else %}
|
||||
no brew reached 1.010
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2 mb-lg">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Attenuation by brew</h2>
|
||||
<span class="card__hint">How much of the original gravity was fermented away</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="attenuation-chart" height="130"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Fermentation curves</h2>
|
||||
<span class="card__hint">Progress through fermentation, 0% to 100%</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="curve-chart" height="130"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every finished brew</h2>
|
||||
<span class="card__hint">{{ stats.count }} brews{% if stats.suspect_count %} ({{ stats.trusted_count }} counted){% endif %}</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">ABV</th>
|
||||
<th class="num">Attenuation</th>
|
||||
<th class="num">Days</th>
|
||||
<th class="num">To 1.010</th>
|
||||
<th>Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.suspect %} class="is-suspect"{% endif %}>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}{% if row.suspect %} <span class="chip chip--warn">not counted</span>{% endif %}</td>
|
||||
<td class="num">
|
||||
{% if row.og_points is not none %}
|
||||
{{ '%.3f' % (row.og_points / 1000) }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if row.fg_points is not none %}
|
||||
{{ '%.3f' % (row.fg_points / 1000) }}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if row.abv is not none %}{{ '%.2f' % row.abv }}%{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if row.attenuation is not none %}
|
||||
<strong>{{ row.attenuation }}%</strong>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ row.days if row.days is not none else '—' }}</td>
|
||||
<td class="num">{{ row.days_to_1010 if row.days_to_1010 is not none else '—' }}</td>
|
||||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if suspect %}
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings that need a second look</h2>
|
||||
<span class="card__hint">{{ suspect | length }} run{{ '' if suspect | length == 1 else 's' }} excluded from the averages</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in suspect %}
|
||||
<tr>
|
||||
<td class="mono">
|
||||
<a href="{{ url_for('brews.run_detail', run_id=row.beer_run_id) }}">{{ row.beer_run_id }}</a>
|
||||
</td>
|
||||
<td class="num">{{ '%.3f' % (row.og_points / 1000) }}</td>
|
||||
<td class="num"><span class="chip chip--warn">{{ '%.3f' % (row.fg_points / 1000) }}</span></td>
|
||||
<td class="comment">{{ row.problem }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if stats.consistency %}
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">What the numbers say</h2>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<p>
|
||||
Across {% if stats.suspect_count %}{{ stats.trusted_count }} of {{ stats.count }}{% else %}{{ stats.count }}{% endif %} finished brews the attenuation averages
|
||||
<strong>{{ stats.consistency.mean }}%</strong>, with a standard
|
||||
deviation of <strong>{{ stats.consistency.stdev }} percentage
|
||||
points</strong> and a total range of {{ stats.consistency.spread }}
|
||||
percentage points.
|
||||
{% if stats.consistency.stdev <= 3 %}
|
||||
That is a tight spread — the process is repeatable.
|
||||
{% elif stats.consistency.stdev <= 6 %}
|
||||
That is a moderate spread — some variation between batches.
|
||||
{% else %}
|
||||
That is a wide spread — something in the process is not
|
||||
consistent between batches.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if stats.fastest and stats.slowest and stats.fastest != stats.slowest %}
|
||||
<p>
|
||||
The quickest brew was <strong>{{ stats.fastest.name }}</strong> at
|
||||
{{ stats.fastest.days }} days; the longest was
|
||||
<strong>{{ stats.slowest.name }}</strong> at
|
||||
{{ stats.slowest.days }} days.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if stats.avg_days_to_1010 %}
|
||||
<p>
|
||||
On average a brew reaches gravity 1.010 after
|
||||
{{ stats.avg_days_to_1010 }} days — the number to plan around when
|
||||
deciding when to check on a fermenter.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,26 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Brew Log{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/logo.svg') }}" />
|
||||
</head>
|
||||
<body>
|
||||
{% if logged_in %}
|
||||
<header class="topbar">
|
||||
<div class="topbar__inner">
|
||||
<a class="brand" href="{{ url_for('brews.index') }}">
|
||||
<img class="brand__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="26" height="26">
|
||||
<span class="brand__text">Brew Log</span>
|
||||
</a>
|
||||
|
||||
<title>{% block title %} Let there Be Pool Data {% endblock %}</title>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
||||
href="{{ url_for('brews.index') }}">Brew Process</a>
|
||||
<a class="nav__link {% if current_path == '/active' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.active') }}">Active Beers</a>
|
||||
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.history') }}">History</a>
|
||||
<a class="nav__link {% if current_path == '/analysis' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.deep') }}">Analysis</a>
|
||||
</nav>
|
||||
|
||||
{{ bootstrap.load_css() }}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<span class="topbar__user" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span>
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: #e8f1f9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- this is a base template using Bootstrap-Flask
|
||||
https://bootstrap-flask.readthedocs.io/ -->
|
||||
<main class="{% block main_class %}page{% endblock %}">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash--{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %} {% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- you can delete the next line if you're not using any Bootstrap JS -->
|
||||
{{ bootstrap.load_js() }}
|
||||
</body>
|
||||
<script src="{{ url_for('static', filename='js/chart.umd.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Brew Process — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">Brew Process</h1>
|
||||
<p class="page__sub">Record a gravity reading, then review where each brew is up to.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--sidebar">
|
||||
<!-- ------------------------------------------------ record a reading -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">New reading</h2>
|
||||
<span class="card__hint">Gravity in points (1039 = 1.039)</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="beer_run_id">Run number</label>
|
||||
<input type="number" id="beer_run_id" name="beer_run_id"
|
||||
min="1" step="1" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="Date">Reading date</label>
|
||||
<input type="date" id="Date" name="Date" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="beer_run_type">Beer / cider name</label>
|
||||
<input type="text" id="beer_run_type" name="beer_run_type"
|
||||
maxlength="120" placeholder="e.g. Necatron">
|
||||
<div class="field__hint">The name is shown on the run summary.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sg">Gravity reading (points)</label>
|
||||
<input type="number" id="sg" name="sg" min="900" max="1300"
|
||||
step="1" required placeholder="1045">
|
||||
<div class="field__hint">Drop the leading "1." — 1045 means 1.045.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="final_reading" value="y">
|
||||
<span>This is the final reading (closes the run)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="comment">Comment</label>
|
||||
<textarea id="comment" name="comment" maxlength="2000"
|
||||
placeholder="Hops in, bottling, kegging…"></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Save reading</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- --------------------------------------------------- run summaries -->
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">In the fermenter</h2>
|
||||
<span class="card__hint">{{ active_runs | length }} active</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
{% if active_runs %}
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">Gravity</th>
|
||||
<th class="num">Est. ABV</th>
|
||||
<th>Comment</th>
|
||||
<th>Brewing advice</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in active_runs %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td class="num">
|
||||
{% if run.min_sg is not none %}
|
||||
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||
<div class="faint tiny">{{ run.min_sg_point_scale }} points</div>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.projected_abv is not none %}
|
||||
{{ '%.2f' % run.projected_abv }}%
|
||||
{% else %}
|
||||
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}
|
||||
{{ run.comments[-1] }}
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="advice-cell">
|
||||
{% if run.advice %}
|
||||
<button type="button"
|
||||
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
|
||||
aria-expanded="false">
|
||||
<span class="advice-trigger__icon" aria-hidden="true">✎</span>
|
||||
<span class="advice-trigger__label">
|
||||
{% if run.advice_ok %}Read the advice{% else %}Advice failed{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<div class="advice-pop" role="tooltip">
|
||||
<div class="advice-pop__head">
|
||||
<span class="advice__label">Brewing advice</span>
|
||||
<button type="button" class="advice-pop__close"
|
||||
aria-label="Close">×</button>
|
||||
</div>
|
||||
<p class="advice-pop__text">{{ run.advice }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">Thinking…</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty__icon" aria-hidden="true">🍺</span>
|
||||
Nothing in the fermenter right now.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Finished brews</h2>
|
||||
<span class="card__hint">{{ completed_runs | length }} completed</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
{% if completed_runs %}
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">ABV</th>
|
||||
<th>Finished</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in completed_runs %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td class="num">
|
||||
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.abv is not none %}
|
||||
<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}
|
||||
{{ run.comments[-1] }}
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">No finished brews recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mt-lg">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<h1>{{ code }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Back to the brew log</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}History — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">History</h1>
|
||||
<p class="page__sub">How every brew has gone, and what it turned out like.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Brew process</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--4 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Total brews</div>
|
||||
<div class="stat__value">{{ stats.total_runs }}</div>
|
||||
<div class="stat__meta">{{ stats.completed }} finished · {{ stats.active }} active</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average ABV</div>
|
||||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||||
<div class="stat__meta">across finished brews</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Strongest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.best %}{{ '%.2f' % stats.best.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.best %}{{ stats.best.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Lightest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.worst %}{{ '%.2f' % stats.worst.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.worst %}{{ stats.worst.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2 mb-lg">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Alcohol by brew</h2>
|
||||
<span class="card__hint">Standard formula (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="abv-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Brews over time</h2>
|
||||
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="months-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if outliers %}
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings worth a second look</h2>
|
||||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Date</th>
|
||||
<th class="num">Reading</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in outliers %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="mono nowrap">{{ row.date or '—' }}</td>
|
||||
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
||||
<td class="comment">{{ row.comment or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every brew</h2>
|
||||
<span class="card__hint">{{ summaries | length }} runs</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">Drop</th>
|
||||
<th class="num">ABV</th>
|
||||
<th class="num">Legacy</th>
|
||||
<th>Finished</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in summaries %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td>
|
||||
{% if run.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ run.og_points or '—' }}</td>
|
||||
<td class="num">{{ run.fg_points or '—' }}</td>
|
||||
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
||||
<td class="num">
|
||||
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
|
||||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Standard vs legacy alcohol figures</h2>
|
||||
<span class="card__hint">The old app used (max − min) ÷ 7.36 + 0.5, which is not a recognised formula</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">Standard ABV</th>
|
||||
<th class="num">Legacy ABV</th>
|
||||
<th class="num">Overstated by</th>
|
||||
<th>Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in abv_rows %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="num">{{ '%.2f' % row.abv }}%</td>
|
||||
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
||||
<td class="num">
|
||||
{% if row.delta > 0 %}
|
||||
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
||||
{% else %}
|
||||
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Login to Pool Data Tracker
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="log-form">
|
||||
{% if try_again %}
|
||||
<h4>Login Failed Please try Again</h4>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">WHOOOOOOO ARE YOU!</h1>
|
||||
|
||||
<p class="lead">If you know you know</p>
|
||||
|
||||
{{ render_form(form) }}
|
||||
|
||||
<p class="pt-5"><strong>{{ message }}</strong></p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!--end log form -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Sign in — Brew Log{% endblock %}
|
||||
{% block main_class %}page page--narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login">
|
||||
<div class="login__card">
|
||||
<img class="login__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="46" height="46">
|
||||
<h1 class="login__title">Brew Log</h1>
|
||||
<p class="login__sub">Track the gravity of every brew.</p>
|
||||
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username"
|
||||
autofocus required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,173 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">
|
||||
{{ summary.canonical_name }}
|
||||
{% if summary.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="page__sub">
|
||||
Run <span class="mono">{{ summary.beer_run_id }}</span>
|
||||
{% if summary.first_date %} · started {{ summary.first_date }}{% endif %}
|
||||
{% if summary.final_date %} · finished {{ summary.final_date }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--sidebar mb-lg">
|
||||
<!-- ------------------------------------------------- run metadata -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Run details</h2>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Beer / cider name</label>
|
||||
<input type="text" id="name" name="name" maxlength="120"
|
||||
value="{{ meta_form.name.data or '' }}">
|
||||
<div class="field__hint">Overrides the drifting name on each reading.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tap_number">Tap number</label>
|
||||
<input type="number" id="tap_number" name="tap_number"
|
||||
min="1" max="99" step="1"
|
||||
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="og_points">Original gravity</label>
|
||||
<input type="number" id="og_points" name="og_points"
|
||||
min="900" max="1300" step="1"
|
||||
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
|
||||
<div class="field__hint">Points, e.g. 1045.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="notes">Brew notes</label>
|
||||
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Save run details</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid--4">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Original gravity</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.og_points is not none %}
|
||||
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">
|
||||
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
||||
</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.fg_points is not none %}
|
||||
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Alcohol</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.abv is not none %}
|
||||
{{ '%.2f' % summary.abv }}%
|
||||
{% elif summary.projected_abv is not none %}
|
||||
{{ '%.2f' % summary.projected_abv }}%
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if summary.abv is not none %}
|
||||
measured
|
||||
{% elif summary.projected_abv is not none %}
|
||||
projected
|
||||
{% else %}
|
||||
needs 2nd reading
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Days brewing</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Gravity over time</h2>
|
||||
<span class="card__hint">Standard ABV: (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings</h2>
|
||||
<span class="card__hint">{{ summary.reading_count }} total</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="reading-list">
|
||||
{% for reading in summary.readings %}
|
||||
<li class="reading-list__item">
|
||||
<span class="reading-list__date">{{ reading.date or '—' }}</span>
|
||||
<span class="reading-list__sg">
|
||||
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
|
||||
</span>
|
||||
<div class="reading-list__body">
|
||||
{% if reading.comment %}
|
||||
<div class="reading-list__comment">{{ reading.comment }}</div>
|
||||
{% else %}
|
||||
<div class="faint tiny">No comment</div>
|
||||
{% endif %}
|
||||
{% if reading.advice %}
|
||||
<div class="advice advice--reading{% if not reading.advice_ok %} advice--failed{% endif %}">
|
||||
<span class="advice__label">Brewing advice</span>
|
||||
{{ reading.advice }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if reading.final_reading %}
|
||||
<span class="chip chip--done">Final</span>
|
||||
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Data Input
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">Input Data you want to store</h1>
|
||||
{% if success %}
|
||||
<h3 class="pt-3 pb02">Thank you {{ updater_name | safe }}, Data Updated Successfully</h1>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table border=0>
|
||||
<tr>
|
||||
<td>
|
||||
{{ render_form(form) }}
|
||||
</td>
|
||||
<!-- <td>
|
||||
<div class="container">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<table border = 0>
|
||||
{% for row in list %}
|
||||
<table border=0>
|
||||
{% for key, value in row.items() %}
|
||||
<tr>
|
||||
<th> {{ key }} </th>
|
||||
<td> {{ value }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td> -->
|
||||
<!--<iframe style="width: 100vw; height: 40vh;" src="static/data_plot.html" frameborder="0" allowfullscreen>
|
||||
<!--<iframe style="width: 100vw;height: 80vh;position: relative;" src="static/data_plot.html" frameborder="0" allowfullscreen>-->
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
||||
<h3>Current Runs</h3>
|
||||
{{ current_data | safe }}
|
||||
|
||||
<h3>Previous Runs</h3>
|
||||
{{ beer_data | safe }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +0,0 @@
|
||||
import table.table_builder as table_builder
|
||||
|
||||
test = table_builder.TableBuilder()
|
||||
|
||||
print(test.table_build())
|
||||
@@ -0,0 +1 @@
|
||||
"""Blueprints for the beer_data app."""
|
||||
@@ -0,0 +1,282 @@
|
||||
"""The analysis pages: what is active, and how the history has gone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
|
||||
from flask import Blueprint, redirect, render_template, request, session, url_for
|
||||
|
||||
from brewing import (Run, attenuation_percent, days_to_target, implausible_runs,
|
||||
is_plausible_sg, run_summary)
|
||||
|
||||
bp = Blueprint("analysis", __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
|
||||
|
||||
|
||||
@bp.route("/analysis")
|
||||
@login_required
|
||||
def deep():
|
||||
"""The deeper analysis: how the brews compare, and how long they take.
|
||||
|
||||
This is the page that answers the questions the history page cannot:
|
||||
how consistent is my attenuation, which brew fermented fastest, and what
|
||||
do the gravity curves look like side by side.
|
||||
"""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
finished = [r for r in runs if not r.is_active()]
|
||||
|
||||
# Runs whose gravity readings are physically impossible are excluded from
|
||||
# EVERY aggregate and listed separately, rather than being silently
|
||||
# folded in. A final gravity below 1.000 would otherwise show as a
|
||||
# 338% attenuation AND a 56-day brew AND the strongest beer in the
|
||||
# history - one bad reading poisoning three different statistics.
|
||||
suspect = implausible_runs(finished)
|
||||
suspect_ids = {s["beer_run_id"] for s in suspect}
|
||||
|
||||
rows = []
|
||||
for run in finished:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = _decorate(run, metadata)
|
||||
rows.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": meta.get("name") or run.display_name(),
|
||||
"final_date": summary["final_date"],
|
||||
"first_date": summary["first_date"],
|
||||
"og_points": summary["og_points"],
|
||||
"fg_points": summary["fg_points"],
|
||||
"abv": summary["abv"],
|
||||
"attenuation": attenuation_percent(run),
|
||||
"days": summary["days_in_fermenter"],
|
||||
"days_to_1010": days_to_target(run, 1010),
|
||||
"readings": summary["reading_count"],
|
||||
"curve": summary["curve"],
|
||||
# A suspect run is shown in the table so it can be corrected,
|
||||
# but it contributes to none of the averages.
|
||||
"suspect": run.beer_run_id in suspect_ids,
|
||||
})
|
||||
|
||||
# Only the trustworthy runs feed the statistics.
|
||||
good = [r for r in rows if not r["suspect"]]
|
||||
|
||||
attenuations = [r["attenuation"] for r in good if r["attenuation"] is not None]
|
||||
durations = [r["days"] for r in good if r["days"] is not None]
|
||||
to_1010 = [r["days_to_1010"] for r in good if r["days_to_1010"] is not None]
|
||||
|
||||
# --- consistency ------------------------------------------------------- #
|
||||
# How much the attenuation varies. A tight spread means the process is
|
||||
# repeatable; a wide one means something is inconsistent.
|
||||
consistency = None
|
||||
if len(attenuations) >= 2:
|
||||
mean = sum(attenuations) / len(attenuations)
|
||||
variance = sum((a - mean) ** 2 for a in attenuations) / len(attenuations)
|
||||
consistency = {
|
||||
"mean": round(mean, 1),
|
||||
"stdev": round(variance ** 0.5, 1),
|
||||
"spread": round(max(attenuations) - min(attenuations), 1),
|
||||
}
|
||||
|
||||
fastest = min((r for r in good if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
slowest = max((r for r in good if r["days"] is not None),
|
||||
key=lambda r: r["days"], default=None)
|
||||
|
||||
return render_template(
|
||||
"analysis.html",
|
||||
rows=rows,
|
||||
suspect=suspect,
|
||||
stats={
|
||||
"count": len(rows),
|
||||
"suspect_count": len(suspect),
|
||||
# How many runs the figures are actually based on.
|
||||
"trusted_count": len(good),
|
||||
"avg_attenuation": round(sum(attenuations) / len(attenuations), 1)
|
||||
if attenuations else None,
|
||||
"avg_days": _fmt_days(sum(durations) / len(durations)) if durations else None,
|
||||
"avg_days_to_1010": _fmt_days(sum(to_1010) / len(to_1010)) if to_1010 else None,
|
||||
"reached_1010": len(to_1010),
|
||||
"consistency": consistency,
|
||||
"fastest": fastest,
|
||||
"slowest": slowest,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _month_range(first: str, last: str) -> list[str]:
|
||||
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
|
||||
|
||||
Used to include the months with no brews in the chart, so a quiet period
|
||||
shows as a gap rather than being compressed out of the axis.
|
||||
"""
|
||||
import calendar
|
||||
|
||||
year, month = int(first[:4]), int(first[5:7])
|
||||
end_year, end_month = int(last[:4]), int(last[5:7])
|
||||
|
||||
months = []
|
||||
while (year, month) <= (end_year, end_month):
|
||||
months.append(f"{year:04d}-{month:02d}")
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
return months
|
||||
|
||||
|
||||
def _fmt_days(value: float) -> str:
|
||||
"""Format a day count: a whole number stays whole, a fraction keeps one
|
||||
decimal place."""
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.1f}"
|
||||
|
||||
|
||||
def _decorate(run: Run, metadata: dict) -> dict:
|
||||
"""Attach the stored metadata to a run summary."""
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
return summary
|
||||
|
||||
|
||||
@bp.route("/active")
|
||||
@login_required
|
||||
def active():
|
||||
"""The active beers page: what is in the fermenter right now."""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
today = date.today()
|
||||
|
||||
cards = []
|
||||
for run in runs:
|
||||
if not run.is_active():
|
||||
continue
|
||||
summary = _decorate(run, metadata)
|
||||
summary["days_in_fermenter"] = run.days_in_fermenter(today)
|
||||
cards.append(summary)
|
||||
|
||||
# Most recently started first — that is what is fermenting right now.
|
||||
cards.sort(key=lambda s: (s["first_date"] or "0000-00-00"), reverse=True)
|
||||
|
||||
ages = [c["days_in_fermenter"] for c in cards if c["days_in_fermenter"] is not None]
|
||||
|
||||
return render_template(
|
||||
"active.html",
|
||||
cards=cards,
|
||||
today=today,
|
||||
totals={
|
||||
"count": len(cards),
|
||||
"avg_days": _fmt_days(sum(ages) / len(ages)) if ages else 0,
|
||||
"longest_days": max(ages) if ages else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/history")
|
||||
@login_required
|
||||
def history():
|
||||
"""Historical brews and how they performed."""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
summaries = [_decorate(run, metadata) for run in runs]
|
||||
completed = [s for s in summaries if not s["active"]]
|
||||
# The Run objects for the finished brews, for the plausibility check.
|
||||
completed_runs = [run for run in runs if not run.is_active()]
|
||||
|
||||
# --- ABV comparison: standard formula vs the legacy one ---------------- #
|
||||
abv_rows = []
|
||||
for s in completed:
|
||||
if s["abv"] is None:
|
||||
continue
|
||||
abv_rows.append({
|
||||
"beer_run_id": s["beer_run_id"],
|
||||
"name": s["canonical_name"],
|
||||
"final_date": s["final_date"],
|
||||
"abv": s["abv"],
|
||||
"legacy_abv": s["legacy_abv"],
|
||||
"delta": round((s["legacy_abv"] or 0) - s["abv"], 2),
|
||||
})
|
||||
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
|
||||
|
||||
# --- brews per month --------------------------------------------------- #
|
||||
# Every month between the first and the last brew is included, with zero
|
||||
# for the months with no brews. Leaving the gaps out would space the
|
||||
# remaining months evenly and make the chart read as continuous activity.
|
||||
per_month: dict[str, int] = defaultdict(int)
|
||||
for s in summaries:
|
||||
stamp = s["final_date"] or s["first_date"]
|
||||
if stamp:
|
||||
per_month[stamp[:7]] += 1
|
||||
|
||||
months = []
|
||||
if per_month:
|
||||
first, last = min(per_month), max(per_month)
|
||||
for month in _month_range(first, last):
|
||||
months.append((month, per_month.get(month, 0)))
|
||||
|
||||
# --- outliers ---------------------------------------------------------- #
|
||||
outliers = []
|
||||
for s in summaries:
|
||||
for reading in s["readings"]:
|
||||
sg = reading["sg"]
|
||||
if sg is not None and not is_plausible_sg(sg):
|
||||
outliers.append({
|
||||
"beer_run_id": s["beer_run_id"],
|
||||
"name": s["canonical_name"],
|
||||
"date": reading["date"],
|
||||
"sg": sg,
|
||||
"comment": reading["comment"],
|
||||
})
|
||||
|
||||
# A run with an impossible final gravity produces an impossible ABV
|
||||
# (run 12 reads 18.64%), which would make it the "strongest" beer in
|
||||
# the history and drag the average up. Those runs are excluded from
|
||||
# the ABV figures here and listed on the analysis page.
|
||||
history_suspect = {s["beer_run_id"] for s in implausible_runs(completed_runs)}
|
||||
good_abv_rows = [r for r in abv_rows if r["beer_run_id"] not in history_suspect]
|
||||
|
||||
avg_abv = round(sum(r["abv"] for r in good_abv_rows) / len(good_abv_rows), 2) \
|
||||
if good_abv_rows else 0
|
||||
best = max(good_abv_rows, key=lambda r: r["abv"]) if good_abv_rows else None
|
||||
worst = min(good_abv_rows, key=lambda r: r["abv"]) if good_abv_rows else None
|
||||
|
||||
return render_template(
|
||||
"history.html",
|
||||
summaries=summaries,
|
||||
completed=completed,
|
||||
abv_rows=abv_rows,
|
||||
months=months,
|
||||
outliers=outliers,
|
||||
stats={
|
||||
"total_runs": len(summaries),
|
||||
"completed": len(completed),
|
||||
"active": len(summaries) - len(completed),
|
||||
"avg_abv": avg_abv,
|
||||
"best": best,
|
||||
"worst": worst,
|
||||
},
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,402 @@
|
||||
"""The brew-process pages: record readings and review the runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from flask import (Blueprint, current_app, flash, jsonify, redirect,
|
||||
render_template, request, session, url_for)
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||
SubmitField, TextAreaField)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
import brew_advice
|
||||
from brewing import implausible_runs, is_plausible_sg, run_summary
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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 advice_enabled() -> bool:
|
||||
"""Whether the LLM advice should be generated.
|
||||
|
||||
It is off in testing, so the suite never reaches out to a model, and
|
||||
it can be turned off in production with ``LLM_ADVICE=0`` if the model
|
||||
is misbehaving.
|
||||
"""
|
||||
if current_app.config.get("TESTING"):
|
||||
return False
|
||||
return os.getenv("LLM_ADVICE", "1").strip().lower() not in {"0", "false", "no"}
|
||||
|
||||
|
||||
def _start_advice(run_id: str, reading_id: str, corrected: bool = False) -> None:
|
||||
"""Generate the LLM's advice for a reading, in the background.
|
||||
|
||||
The advice is a separate field on the reading, so the brewer's own
|
||||
comment is never touched.
|
||||
|
||||
The background thread cannot use the request-scoped repository - that
|
||||
client is closed when the request ends - so it uses the shared
|
||||
process-wide one.
|
||||
"""
|
||||
if not reading_id:
|
||||
return
|
||||
if not advice_enabled():
|
||||
return
|
||||
|
||||
try:
|
||||
repository = repo()
|
||||
run = repository.run(run_id)
|
||||
if run is None:
|
||||
return
|
||||
meta = repository.run_metadata(run_id)
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
run_info = {
|
||||
"canonical_name": meta.get("name") or run.display_name(),
|
||||
"name": run.display_name(),
|
||||
"og_points": summary.get("og_points"),
|
||||
"og_decimal": summary.get("og_decimal"),
|
||||
"tap_number": meta.get("tap_number"),
|
||||
"notes": meta.get("notes", ""),
|
||||
}
|
||||
|
||||
# The model's view of the run: every reading, with the gravity in
|
||||
# both forms a brewer would recognise.
|
||||
readings = []
|
||||
for reading in run.readings:
|
||||
points = reading.sg_points
|
||||
readings.append({
|
||||
"_id": str(reading._id),
|
||||
"advice_token": reading.advice_token,
|
||||
"date": reading.date.isoformat() if reading.date else "",
|
||||
"sg": points,
|
||||
"sg_decimal": reading.sg_decimal,
|
||||
"point_scale": f"{points} points" if points is not None else "",
|
||||
"comment": reading.comment or "",
|
||||
"final": bool(reading.final_reading),
|
||||
})
|
||||
|
||||
# The reading that is actually being advised about. It is not
|
||||
# necessarily the newest one: the brewer may be editing an older
|
||||
# reading, and advice about the newest reading would be about the
|
||||
# wrong number.
|
||||
saved = next((r for r in readings if r.get("_id") == str(reading_id)), None)
|
||||
if saved is None:
|
||||
saved = dict(readings[-1]) if readings else {"date": "", "sg": None}
|
||||
latest = dict(saved)
|
||||
latest["_id"] = reading_id
|
||||
|
||||
except Exception: # noqa: BLE001 - advice must never break the save
|
||||
log.exception("could not gather the reading for the advice")
|
||||
return
|
||||
|
||||
# The gravity and the editing token of the reading being advised
|
||||
# about. The write is conditional on both, so a slow advice thread
|
||||
# cannot overwrite a correction with advice about the old value.
|
||||
advice_sg = latest.get("sg")
|
||||
advice_token = latest.get("advice_token")
|
||||
|
||||
def write(read_id: str, ok: bool, text: str) -> None:
|
||||
# The client is SHARED, not built per reading. A MongoClient
|
||||
# spawns three monitor threads and pymongo does not reap them
|
||||
# on close(), so building one per advice write accumulated three
|
||||
# threads and a connection pool for every reading recorded.
|
||||
#
|
||||
# The shared client is created on first use and lives for the
|
||||
# life of the process, which is what the request-scoped
|
||||
# repository does too.
|
||||
from mongo.repository import get_shared_repository
|
||||
|
||||
try:
|
||||
shared = get_shared_repository()
|
||||
written = shared.set_reading_advice(
|
||||
read_id, text, ok,
|
||||
expected_sg=advice_sg,
|
||||
expected_token=advice_token,
|
||||
)
|
||||
if not written:
|
||||
# The reading was corrected while the advice was being
|
||||
# generated, so the advice no longer matches it. That is
|
||||
# the guard working - the fresh advice for the corrected
|
||||
# reading will land in its own turn.
|
||||
log.info(
|
||||
"the advice for reading %s was not stored: the reading "
|
||||
"has been edited since the advice was generated, so the "
|
||||
"advice no longer matches it",
|
||||
read_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never let the thread die noisily
|
||||
log.exception("could not store the generated advice")
|
||||
|
||||
thread = threading.Thread(
|
||||
target=brew_advice.generate_advice_in_background,
|
||||
args=(run_info, latest, readings, write, corrected),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
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()])
|
||||
# The floor is deliberately lower than is_plausible_sg's. An
|
||||
# out-of-range reading is SAVED with a warning rather than rejected:
|
||||
# the brewer may have mis-typed it, and seeing it recorded is how they
|
||||
# spot and correct the mistake.
|
||||
sg = IntegerField("Gravity reading (points)",
|
||||
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||
final_reading = BooleanField("This is the final reading")
|
||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||
submit = SubmitField("Save reading")
|
||||
|
||||
|
||||
class RunMetaForm(FlaskForm):
|
||||
"""Edit the canonical metadata of a run.
|
||||
|
||||
These are the details that cannot be derived from the readings: the name
|
||||
the brew is actually known by, which tap it is on, and its original
|
||||
gravity.
|
||||
"""
|
||||
|
||||
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
|
||||
tap_number = IntegerField("Tap number",
|
||||
validators=[Optional(), NumberRange(min=1, max=99)])
|
||||
og_points = IntegerField("Original gravity (points)",
|
||||
validators=[Optional(), NumberRange(min=900, max=1500)])
|
||||
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
||||
submit = SubmitField("Save run")
|
||||
|
||||
|
||||
@bp.route("/brews", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
"""The brew-process page: record a reading, then review the runs."""
|
||||
form = ReadingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
sg_points = int(form.sg.data)
|
||||
if not is_plausible_sg(sg_points):
|
||||
if sg_points < 1000:
|
||||
# Beer cannot be less dense than water, so this is always
|
||||
# a mis-entry - and a mis-typed "1.012" as "012" is the
|
||||
# likely cause. It is still saved so the mistake is
|
||||
# visible and can be corrected.
|
||||
flash(
|
||||
f"{sg_points} is below 1.000 — beer cannot be less dense "
|
||||
"than water, so this is almost certainly a typo (a 1.012 "
|
||||
"reading entered as 012?). It was saved anyway; correct "
|
||||
"it on the run's page.",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"{sg_points} is outside the usual hydrometer range "
|
||||
"(990-1200). It was saved anyway — double-check the reading.",
|
||||
"warning",
|
||||
)
|
||||
run_id = str(form.beer_run_id.data)
|
||||
# Whether the reading was already there: a re-submitted reading
|
||||
# is a CORRECTION, and the advice should say so.
|
||||
existing = repo().reading_docs_for_run(run_id)
|
||||
was_existing = any(
|
||||
str(doc.get("date")) == form.Date.data.isoformat()
|
||||
for doc in existing
|
||||
)
|
||||
|
||||
reading_id = repo().upsert_reading(
|
||||
beer_run_id=run_id,
|
||||
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")
|
||||
|
||||
# The advice is generated in the background: recording a reading
|
||||
# must not block on the model. The page shows the reading straight
|
||||
# away and the advice is there on a later visit.
|
||||
_start_advice(run_id, reading_id, corrected=was_existing)
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
summaries = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summaries.append(summary)
|
||||
|
||||
# Active runs: most recent activity first — that is what is fermenting now.
|
||||
active = sorted([s for s in summaries if s["active"]],
|
||||
key=lambda s: s["last_date"] or "", reverse=True)
|
||||
# Finished runs: most recently finished first.
|
||||
completed = sorted([s for s in summaries if not s["active"]],
|
||||
key=lambda s: s["final_date"] or "", reverse=True)
|
||||
|
||||
return render_template(
|
||||
"brews.html",
|
||||
form=form,
|
||||
summaries=summaries,
|
||||
active_runs=active,
|
||||
completed_runs=completed,
|
||||
next_tap=repo().next_free_tap_number(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def run_detail(run_id: str):
|
||||
"""One run in detail, with its readings and its editable metadata."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
flash(f"No run numbered {run_id}.", "error")
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
meta = repo().run_metadata(run_id)
|
||||
meta_form = RunMetaForm()
|
||||
|
||||
if meta_form.validate_on_submit():
|
||||
repo().upsert_run_metadata(
|
||||
run_id,
|
||||
name=(meta_form.name.data or "").strip() or None,
|
||||
tap_number=meta_form.tap_number.data,
|
||||
og_points=meta_form.og_points.data,
|
||||
notes=(meta_form.notes.data or "").strip() or None,
|
||||
)
|
||||
flash("Run details saved.", "success")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
if not meta_form.is_submitted():
|
||||
meta_form.name.data = meta.get("name") or run.display_name()
|
||||
meta_form.tap_number.data = meta.get("tap_number")
|
||||
meta_form.og_points.data = meta.get("og_points")
|
||||
meta_form.notes.data = meta.get("notes", "")
|
||||
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
|
||||
return render_template("run_detail.html", run=run, summary=summary,
|
||||
meta_form=meta_form, meta=meta)
|
||||
|
||||
|
||||
@bp.route("/api/runs")
|
||||
@login_required
|
||||
def api_runs():
|
||||
"""JSON feed of the run summaries, used by the charts on the analysis
|
||||
pages.
|
||||
|
||||
The charts fetch this rather than having the data inlined into the page,
|
||||
so one fetch serves every chart on a page.
|
||||
"""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
# The runs whose readings are physically impossible. They are flagged
|
||||
# rather than dropped: the charts use the flag to leave them out of the
|
||||
# averages and the curve envelope, but a caller can still see them.
|
||||
suspect_ids = {s["beer_run_id"] for s in implausible_runs(runs)}
|
||||
|
||||
payload = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summary["suspect"] = run.beer_run_id in suspect_ids
|
||||
payload.append(summary)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.route("/api/runs/<run_id>")
|
||||
@login_required
|
||||
def api_run(run_id: str):
|
||||
"""JSON detail of a single run."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
meta = repo().run_metadata(run_id)
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
# The same flag the list endpoint carries, so a consumer of the detail
|
||||
# endpoint can tell an impossible run from a good one.
|
||||
summary["suspect"] = bool(implausible_runs([run]))
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||
@login_required
|
||||
def mark_final(run_id: str):
|
||||
"""Flag a reading as the final one for its run."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
changed = repo().set_final_reading(run_id, reading_date, True)
|
||||
if changed:
|
||||
flash("Final reading recorded.", "success")
|
||||
else:
|
||||
flash("That reading could not be updated.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
|
||||
@login_required
|
||||
def unfinal(run_id: str):
|
||||
"""Un-flag a final reading, putting the run back into the active list."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
repo().set_final_reading(run_id, reading_date, False)
|
||||
flash("Reading is no longer marked final.", "info")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Tests for the deeper analysis: attenuation, fermentation curves, timings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from brewing import (Run, attenuation_percent, days_to_target,
|
||||
fermentation_curve, implausible_runs)
|
||||
from conftest import seed_readings
|
||||
|
||||
|
||||
def _doc(run_id="1", beer_type="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,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------ attenuation #
|
||||
|
||||
def test_attenuation_percent():
|
||||
"""Attenuation is the fraction of the original gravity fermented away."""
|
||||
# OG 1050, FG 1010: 40 of the 50 points above 1.000 went away.
|
||||
run = Run.from_docs("1", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run) == 80.0
|
||||
|
||||
|
||||
def test_attenuation_percent_of_a_full_fermentation():
|
||||
"""A wort that finishes at 1.000 has 100% attenuation."""
|
||||
run = Run.from_docs("2", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1000", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run) == 100.0
|
||||
|
||||
|
||||
def test_attenuation_percent_needs_a_gravity_above_1000():
|
||||
"""A run that started at or below 1.000 has no meaningful attenuation."""
|
||||
run = Run.from_docs("3", [
|
||||
_doc(sg="1000", date_="2026-01-01"),
|
||||
_doc(sg="0995", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run) is None
|
||||
|
||||
|
||||
def test_attenuation_percent_of_an_unfinished_run_uses_its_current_gravity():
|
||||
"""The current gravity stands in for the final one until the run ends."""
|
||||
run = Run.from_docs("4", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1020", date_="2026-01-10"),
|
||||
])
|
||||
assert attenuation_percent(run) == 60.0
|
||||
|
||||
|
||||
# ------------------------------------------------------- fermentation curve #
|
||||
|
||||
def test_fermentation_curve_is_empty_for_a_single_reading():
|
||||
"""One reading is no curve."""
|
||||
run = Run.from_docs("5", [_doc(sg="1045", date_="2026-01-01")])
|
||||
assert fermentation_curve(run) == []
|
||||
|
||||
|
||||
def test_fermentation_curve_progresses_from_zero_to_one():
|
||||
"""The curve is the fraction of the gravity drop completed."""
|
||||
run = Run.from_docs("6", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1030", date_="2026-01-10"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
curve = fermentation_curve(run)
|
||||
|
||||
assert len(curve) == 3
|
||||
assert curve[0]["progress"] == 0.0
|
||||
assert curve[1]["progress"] == 0.5
|
||||
assert curve[2]["progress"] == 1.0
|
||||
|
||||
|
||||
def test_fermentation_curve_carries_the_reading_details():
|
||||
run = Run.from_docs("7", [
|
||||
_doc(sg="1050", date_="2026-01-01", comment="brew day"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True", comment="kegging"),
|
||||
])
|
||||
curve = fermentation_curve(run)
|
||||
|
||||
assert curve[0]["date"] == "2026-01-01"
|
||||
assert curve[0]["sg_decimal"] == "1.050"
|
||||
assert curve[0]["point_scale"] == "50"
|
||||
assert curve[0]["comment"] == "brew day"
|
||||
assert curve[1]["final"] is True
|
||||
|
||||
|
||||
def test_fermentation_curve_is_empty_when_the_gravity_never_falls():
|
||||
"""A run that only goes up has no drop to measure progress against."""
|
||||
run = Run.from_docs("8", [
|
||||
_doc(sg="1010", date_="2026-01-01"),
|
||||
_doc(sg="1020", date_="2026-01-10", final="True"),
|
||||
])
|
||||
assert fermentation_curve(run) == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------- days to target #
|
||||
|
||||
def test_days_to_target():
|
||||
"""How long the brew took to reach a given gravity."""
|
||||
run = Run.from_docs("9", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1020", date_="2026-01-08"),
|
||||
_doc(sg="1005", date_="2026-01-20", final="True"),
|
||||
])
|
||||
# 1020 is still above 1015, so the run does not reach 1015 until the
|
||||
# third reading, 19 days in.
|
||||
assert days_to_target(run, 1015) == 19
|
||||
assert days_to_target(run, 1010) == 19
|
||||
# 1020 is below 1025, so that target is reached on the second reading.
|
||||
assert days_to_target(run, 1025) == 7
|
||||
assert days_to_target(run, 1000) is None
|
||||
|
||||
|
||||
def test_days_to_target_returns_none_when_never_reached():
|
||||
run = Run.from_docs("10", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1040", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert days_to_target(run, 1010) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- summary #
|
||||
|
||||
def test_run_summary_includes_the_new_analysis_fields():
|
||||
from brewing import run_summary
|
||||
|
||||
run = Run.from_docs("11", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
|
||||
assert summary["attenuation"] == 80.0
|
||||
assert len(summary["curve"]) == 2
|
||||
assert summary["curve"][0]["progress"] == 0.0
|
||||
|
||||
|
||||
def test_attenuation_is_none_for_a_final_gravity_below_water():
|
||||
"""Beer cannot be less dense than water. Run 12 in the live data has a
|
||||
final gravity of 900, which would otherwise show as 338% attenuation."""
|
||||
run = Run.from_docs("12", [
|
||||
_doc(sg="1042", date_="2025-10-05"),
|
||||
_doc(sg="900", date_="2025-11-30", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run) is None
|
||||
|
||||
|
||||
def test_attenuation_is_none_when_it_would_exceed_100_percent():
|
||||
run = Run.from_docs("13", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1000", date_="2026-01-20", final="True"),
|
||||
])
|
||||
# 1050 -> 1000 is exactly 100%, which is the limit but still valid.
|
||||
assert attenuation_percent(run) == 100.0
|
||||
|
||||
run2 = Run.from_docs("14", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run2) == 80.0
|
||||
|
||||
|
||||
def test_implausible_runs_are_identified():
|
||||
from brewing import implausible_runs
|
||||
|
||||
good = Run.from_docs("1", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
bad = Run.from_docs("12", [
|
||||
_doc(sg="1042", date_="2025-10-05"),
|
||||
_doc(sg="900", date_="2025-11-30", final="True"),
|
||||
])
|
||||
|
||||
problems = implausible_runs([good, bad])
|
||||
assert len(problems) == 1
|
||||
assert problems[0]["beer_run_id"] == "12"
|
||||
assert problems[0]["fg_points"] == 900
|
||||
assert "below 1.000" in problems[0]["problem"]
|
||||
|
||||
|
||||
def test_implausible_runs_ignores_good_runs():
|
||||
from brewing import implausible_runs
|
||||
|
||||
good = Run.from_docs("1", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
_doc(sg="1010", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert implausible_runs([good]) == []
|
||||
|
||||
# ------------------------------------------------------- chart rendering #
|
||||
|
||||
def test_charts_do_not_reverse_the_gravity_axis():
|
||||
"""Gravity must FALL on screen as the beer ferments.
|
||||
|
||||
The gravity charts originally set `reverse: true` on the y axis, which
|
||||
puts the HIGH reading at the bottom of the plot - so the fermentation
|
||||
curve climbed left to right and read as gravity *rising*. A brewer
|
||||
reads a fermentation as a descending line, so the axis must not be
|
||||
reversed. The point-scale run chart had the same bug.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert "reverse: true" not in charts_js, (
|
||||
"a gravity chart is reversing the y axis; the fermentation curve "
|
||||
"would climb instead of descend"
|
||||
)
|
||||
|
||||
|
||||
def test_attenuation_chart_truncates_long_names():
|
||||
"""Long beer names were clipped at the left edge of the plot.
|
||||
|
||||
The rotated x-axis labels were cut off ("...x crying out loud"), so the
|
||||
names are trimmed to a readable length before they are drawn. The
|
||||
tooltip and the table below carry the full name.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert "name.length > 18" in charts_js, (
|
||||
"the attenuation chart is not trimming long beer names"
|
||||
)
|
||||
|
||||
# ------------------------------------------- suspect runs are not counted #
|
||||
|
||||
def _two_runs(repo):
|
||||
"""One good brew and one with an impossible final gravity.
|
||||
|
||||
The bad run mimics the live data: run 12 has a final gravity of 900,
|
||||
which is below water and makes the run look like a 56-day brew that
|
||||
reached 18.64% alcohol.
|
||||
"""
|
||||
seed_readings(repo, [
|
||||
("1", "Good ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Good ale", "1010", "2026-01-10", True, ""),
|
||||
# 56 days, and a final gravity below 1.000.
|
||||
("12", "Bad brew", "1042", "2026-01-01", False, ""),
|
||||
("12", "Bad brew", "0900", "2026-02-26", True, "typo"),
|
||||
])
|
||||
|
||||
|
||||
def _stat_values(body):
|
||||
"""The figures in the analysis stat tiles, in order.
|
||||
|
||||
The tiles are rendered as label/value pairs, so the numbers can be
|
||||
pulled out without depending on the exact markup.
|
||||
"""
|
||||
import re
|
||||
values = re.findall(r'<div class="stat__value">([^<]+)</div>', body)
|
||||
return [v.strip() for v in values]
|
||||
|
||||
|
||||
def test_a_suspect_run_is_excluded_from_the_brew_time_average(logged_in, repo):
|
||||
"""The 56-day bad run must not drag the average brew time up.
|
||||
|
||||
This was the review finding: the run was excluded from the attenuation
|
||||
average but not from the brew-time average, so one bad reading was
|
||||
poisoning two different statistics.
|
||||
"""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.get_data(as_text=True)
|
||||
values = _stat_values(body)
|
||||
assert values, "the analysis page rendered no stat tiles"
|
||||
|
||||
# The stat tiles are: average attenuation, spread, average brew time,
|
||||
# time to 1.010. The good run took 9 days; with the bad run folded
|
||||
# in the average would be (9 + 56) / 2 = 32.5 days.
|
||||
assert "9d" in values, f"expected a 9 day brew time, got {values}"
|
||||
|
||||
|
||||
def test_a_suspect_run_is_excluded_from_the_time_to_1010_average(logged_in, repo):
|
||||
"""Same for the "time to 1.010" figure."""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
body = response.get_data(as_text=True)
|
||||
values = _stat_values(body)
|
||||
assert values
|
||||
# The good run reached 1.010 in 9 days; the bad one "reached" it in 56.
|
||||
assert "9d" in values, f"expected 9 days to 1.010, got {values}"
|
||||
|
||||
|
||||
def test_the_suspect_run_is_still_listed_for_review(logged_in, repo):
|
||||
"""Excluding it from the averages must not hide it.
|
||||
|
||||
The run has to stay visible so it can be corrected.
|
||||
"""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
body = response.get_data(as_text=True)
|
||||
assert "Readings that need a second look" in body
|
||||
assert "0.900" in body
|
||||
|
||||
|
||||
def test_a_suspect_run_is_flagged_in_the_api(logged_in, repo):
|
||||
"""The charts fetch /api/runs, so the flag has to be in the payload."""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.get_json()
|
||||
by_run = {str(r["beer_run_id"]): r for r in payload}
|
||||
assert by_run["12"]["suspect"] is True
|
||||
assert by_run["1"]["suspect"] is False
|
||||
|
||||
|
||||
def test_a_suspect_run_is_not_the_strongest_brew_in_the_history(logged_in, repo):
|
||||
"""The history page showed the bad run as the strongest beer.
|
||||
|
||||
An impossible final gravity produced an 18.64% ABV, which made run 12
|
||||
the "strongest" brew in the history and pulled the average up.
|
||||
|
||||
The run's own row still shows its ABV - it is real data, and hiding it
|
||||
would be worse - but it must not be counted as the strongest brew, and
|
||||
it must not appear in the "Strongest" figure.
|
||||
"""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/history")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = response.get_data(as_text=True)
|
||||
|
||||
# The "Strongest" tile shows the good run's 4.76%, not the bad
|
||||
# run's 18.64%. Find the Strongest tile and read its value.
|
||||
import re
|
||||
strongest = re.search(
|
||||
r"Strongest.*?stat__value[^>]*>([^<]+)<", body, re.DOTALL
|
||||
)
|
||||
assert strongest, "the history page has no Strongest figure"
|
||||
assert "18.64" not in strongest.group(1), (
|
||||
f"the suspect run was counted as the strongest brew: {strongest.group(1)}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_run_detail_api_also_flags_a_suspect_run(logged_in, repo):
|
||||
"""The detail endpoint carries the same flag as the list endpoint.
|
||||
|
||||
A consumer of /api/runs/<run_id> must be able to tell an impossible
|
||||
run from a good one.
|
||||
"""
|
||||
_two_runs(repo)
|
||||
|
||||
response = logged_in.get("/api/runs/12")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["suspect"] is True
|
||||
|
||||
response = logged_in.get("/api/runs/1")
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["suspect"] is False
|
||||
|
||||
|
||||
def test_the_charts_skip_suspect_runs():
|
||||
"""The chart script must leave the suspect runs out.
|
||||
|
||||
A suspect run's fermentation curve plunges to a gravity below water,
|
||||
which would drag the median line down and stretch the envelope.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert charts_js.count("!r.suspect") >= 2, (
|
||||
"the charts must filter out the runs flagged as suspect"
|
||||
)
|
||||
|
||||
# ------------------------------------------------- rising gravity #
|
||||
|
||||
def test_attenuation_is_none_when_the_gravity_went_up():
|
||||
"""A negative attenuation is not a number a brewer can act on.
|
||||
|
||||
The guard rejected a final gravity below 1.000 and one implying more
|
||||
than 100% attenuation, but a run whose gravity ROSE produced a
|
||||
negative figure that was averaged into the headline number.
|
||||
"""
|
||||
run = Run.from_docs("30", [
|
||||
_doc(sg="1010", date_="2026-01-01"),
|
||||
_doc(sg="1020", date_="2026-01-20", final="True"),
|
||||
])
|
||||
assert attenuation_percent(run) is None
|
||||
|
||||
|
||||
def test_a_rising_gravity_run_is_reported_as_implausible():
|
||||
"""It must be listed for review, not just quietly dropped."""
|
||||
run = Run.from_docs("31", [
|
||||
_doc(sg="1010", date_="2026-01-01"),
|
||||
_doc(sg="1020", date_="2026-01-20", final="True"),
|
||||
])
|
||||
problems = implausible_runs([run])
|
||||
assert len(problems) == 1
|
||||
assert problems[0]["beer_run_id"] == "31"
|
||||
assert "went up" in problems[0]["problem"]
|
||||
|
||||
|
||||
# ------------------------------------------------- undated readings #
|
||||
|
||||
def test_the_original_gravity_comes_from_the_first_dated_reading():
|
||||
"""An undated reading cannot be placed on the timeline.
|
||||
|
||||
It used to sort to the front, so it became the OG - which put the OG
|
||||
after the FG and produced a negative attenuation.
|
||||
"""
|
||||
run = Run.from_docs("32", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
{"_id": "x", "beer_run_id": "32", "beer_type": "Ale", "sg": "1005",
|
||||
"date": None, "final_reading": "False", "comment": ""},
|
||||
_doc(sg="1010", date_="2026-01-25", final="True"),
|
||||
])
|
||||
assert run.original_gravity() == 1050
|
||||
assert run.final_gravity() == 1010
|
||||
assert attenuation_percent(run) is not None
|
||||
assert attenuation_percent(run) > 0
|
||||
|
||||
|
||||
def test_days_to_target_skips_an_undated_reading():
|
||||
"""One undated reading must not lose the whole figure.
|
||||
|
||||
The function gave up and returned None when the first reading at or
|
||||
below the target had no date, even when a later reading did.
|
||||
"""
|
||||
run = Run.from_docs("33", [
|
||||
_doc(sg="1050", date_="2026-01-01"),
|
||||
{"_id": "y", "beer_run_id": "33", "beer_type": "Ale", "sg": "1005",
|
||||
"date": None, "final_reading": "False", "comment": ""},
|
||||
_doc(sg="1010", date_="2026-01-25", final="True"),
|
||||
])
|
||||
assert days_to_target(run, 1010) == 24
|
||||
|
||||
|
||||
# ------------------------------------------------------- the charts #
|
||||
|
||||
def test_the_curve_chart_excludes_active_and_suspect_runs():
|
||||
"""Only finished, plausible brews belong on the curve.
|
||||
|
||||
An active run's curve is a partial fermentation, and the curve is
|
||||
meant to describe how a completed fermentation behaves.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert "!r.active" in charts_js, (
|
||||
"the curve chart must leave the active runs out"
|
||||
)
|
||||
|
||||
|
||||
def test_the_curve_band_is_filled_to_the_max_not_the_median():
|
||||
"""The shaded band has to span min to max.
|
||||
|
||||
With one dataset and `fill: "+1"` the band was filled to the NEXT
|
||||
dataset - which was the median line, so the upper half of the range
|
||||
was drawn as a hairline but never shaded, and the tooltip disagreed
|
||||
with the shading.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert "envelopeMax" in charts_js, (
|
||||
"the band needs a separate max dataset to fill down to"
|
||||
)
|
||||
assert 'label: "Range max"' in charts_js
|
||||
|
||||
|
||||
def test_the_charts_fetch_the_run_payload_once():
|
||||
"""/api/runs carries every reading of every run.
|
||||
|
||||
Four charts each fetched it independently, so the analysis page made
|
||||
two identical round-trips of the whole collection.
|
||||
"""
|
||||
charts_js = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src" / "flask" / "static" / "js" / "charts.js"
|
||||
).read_text()
|
||||
|
||||
assert charts_js.count('fetch("/api/runs"') == 1, (
|
||||
"the run payload should be fetched once and shared"
|
||||
)
|
||||
assert "function fetchRuns()" in charts_js
|
||||
@@ -0,0 +1,716 @@
|
||||
"""Tests for the web layer: login, the brew-process page and the API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import seed_readings
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 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_the_chart_library_is_loaded_exactly_once(logged_in):
|
||||
"""Regression: the chart script was included both in the base template
|
||||
and again by each page, so every chart was drawn twice."""
|
||||
for path in ("/brews", "/active", "/history"):
|
||||
body = logged_in.get(path).data.decode()
|
||||
assert body.count("js/charts.js") == 1, path
|
||||
assert body.count("chart.umd.js") == 1, path
|
||||
|
||||
|
||||
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):
|
||||
"""A reading outside the range is SAVED with a warning.
|
||||
|
||||
Rejecting it would lose the brewer's actual observation, and seeing it
|
||||
recorded is how they spot and correct a typo.
|
||||
"""
|
||||
response = logged_in.post("/brews", data={
|
||||
"beer_run_id": "12",
|
||||
"beer_run_type": "Odd",
|
||||
"Date": "2026-05-01",
|
||||
"sg": "1250",
|
||||
}, 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
|
||||
|
||||
|
||||
def test_a_reading_below_water_says_it_is_always_a_typo(logged_in, repo):
|
||||
"""A gravity below 1.000 cannot be real, so the warning says so.
|
||||
|
||||
This is the run 12 case: a 1.012 reading entered as "012". The
|
||||
reading is still saved, but the message names the likely cause.
|
||||
"""
|
||||
response = logged_in.post("/brews", data={
|
||||
"beer_run_id": "13",
|
||||
"beer_run_type": "Typo",
|
||||
"Date": "2026-05-01",
|
||||
"sg": "900",
|
||||
}, follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert b"below 1.000" in response.data
|
||||
assert b"typo" in response.data
|
||||
# Still saved, so the mistake is visible and can be corrected.
|
||||
assert repo.readings.find_one({"beer_run_id": "13"}) 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)
|
||||
|
||||
|
||||
# ------------------------------------------------- active beers and metadata #
|
||||
|
||||
def test_active_page_shows_only_unfinished_runs(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Finished Beer", "1050", "2026-01-01", False, ""),
|
||||
("1", "Finished Beer", "1010", "2026-01-20", True, ""),
|
||||
("2", "Still Brewing", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/active")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Still Brewing" in body
|
||||
assert "Finished Beer" not in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_tap_number(logged_in, repo):
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
repo.upsert_run_metadata("2", tap_number=4)
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Tap 4" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_projected_alcohol(logged_in, repo):
|
||||
"""With two readings the first is the OG, so the estimate is meaningful."""
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, ""),
|
||||
("2", "Still Brewing", "1020", "2026-02-10", False, ""),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
# 30 point drop = 3.94%
|
||||
assert "3.94%" in body
|
||||
|
||||
|
||||
def test_active_page_shows_a_dash_when_no_og_is_known(logged_in, repo):
|
||||
"""A single reading with no recorded OG has no reliable estimate."""
|
||||
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "needs 2nd reading" in body
|
||||
|
||||
|
||||
def test_active_page_shows_the_latest_comment(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("2", "Still Brewing", "1050", "2026-02-01", False, "Hops in"),
|
||||
("2", "Still Brewing", "1030", "2026-02-10", False, "Almost there"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/active").data.decode()
|
||||
assert "Almost there" in body
|
||||
|
||||
|
||||
def test_run_metadata_is_saved_and_surfaced(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
|
||||
logged_in.post("/runs/8", data={
|
||||
"name": "Canonical Name",
|
||||
"tap_number": "3",
|
||||
"og_points": "1050",
|
||||
"notes": "Starter was a 50F mess.",
|
||||
}, follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
assert meta["name"] == "Canonical Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["og_points"] == 1050
|
||||
assert meta["notes"] == "Starter was a 50F mess."
|
||||
|
||||
# The canonical name must appear on the brew-process page and the active page.
|
||||
assert "Canonical Name" in logged_in.get("/brews").data.decode()
|
||||
assert "Canonical Name" in logged_in.get("/active").data.decode()
|
||||
|
||||
|
||||
def test_run_metadata_can_be_cleared(logged_in, repo):
|
||||
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
|
||||
repo.upsert_run_metadata("8", name="Set Name", tap_number=2)
|
||||
|
||||
logged_in.post("/runs/8", data={"name": "", "tap_number": "", "og_points": "", "notes": ""},
|
||||
follow_redirects=True)
|
||||
|
||||
meta = repo.run_metadata("8")
|
||||
# The name was cleared, so it falls back to the reading-derived name.
|
||||
assert not meta.get("name")
|
||||
assert meta.get("tap_number") is None
|
||||
|
||||
|
||||
def test_the_stored_name_overrides_the_drifting_reading_name(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("9", "Old Name", "1050", "2026-03-01", False, ""),
|
||||
("9", "Newer Name", "1040", "2026-03-10", False, ""),
|
||||
])
|
||||
repo.upsert_run_metadata("9", name="The Name I Want")
|
||||
|
||||
body = logged_in.get("/brews").data.decode()
|
||||
assert "The Name I Want" in body
|
||||
|
||||
|
||||
def test_next_free_tap_number_skips_the_ones_in_use(repo):
|
||||
assert repo.next_free_tap_number() == 1
|
||||
|
||||
repo.upsert_run_metadata("1", tap_number=1)
|
||||
assert repo.next_free_tap_number() == 2
|
||||
|
||||
repo.upsert_run_metadata("2", tap_number=2)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
# A gap is filled before extending the range.
|
||||
repo.upsert_run_metadata("3", tap_number=4)
|
||||
assert repo.next_free_tap_number() == 3
|
||||
|
||||
|
||||
def test_history_page_shows_completed_brews(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/history")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "5.25%" in body # standard ABV for a 40 point drop
|
||||
assert "Legacy" in body # the comparison table is present
|
||||
|
||||
|
||||
def test_history_page_flags_an_implausible_reading(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "900", "2026-01-20", True, "typo?"),
|
||||
])
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
assert "worth a second look" in body
|
||||
assert "typo?" in body
|
||||
|
||||
|
||||
def test_partial_metadata_update_leaves_other_fields_alone(repo):
|
||||
"""The backfill uses this: it must not wipe a tap number the operator
|
||||
has already set."""
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3, notes="Some notes")
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name="A Better Name")
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Better Name"
|
||||
assert meta["tap_number"] == 3
|
||||
assert meta["notes"] == "Some notes"
|
||||
|
||||
|
||||
def test_partial_metadata_update_ignores_none(repo):
|
||||
repo.upsert_run_metadata("1", name="A Name", tap_number=3)
|
||||
|
||||
repo.upsert_run_metadata_partial("1", name=None, tap_number=None)
|
||||
|
||||
meta = repo.run_metadata("1")
|
||||
assert meta["name"] == "A Name"
|
||||
assert meta["tap_number"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------- security headers #
|
||||
|
||||
def test_security_headers_are_set(logged_in):
|
||||
"""The app serves only its own vendored assets, so a strict CSP of
|
||||
'self' is enforceable."""
|
||||
response = logged_in.get("/brews")
|
||||
headers = response.headers
|
||||
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "same-origin"
|
||||
|
||||
csp = headers.get("Content-Security-Policy")
|
||||
assert csp is not None
|
||||
assert "default-src 'self'" in csp
|
||||
assert "script-src 'self'" in csp
|
||||
assert "object-src 'none'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
# No CDN is used, so nothing external should be allowed.
|
||||
assert "https://" not in csp
|
||||
# 'unsafe-inline' would defeat the point of the policy.
|
||||
assert "unsafe-inline" not in csp
|
||||
assert "unsafe-eval" not in csp
|
||||
|
||||
|
||||
def test_security_headers_are_set_on_the_login_page(client):
|
||||
response = client.get("/login")
|
||||
assert response.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert response.headers.get("Content-Security-Policy") is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ indexes #
|
||||
|
||||
def test_the_repository_creates_its_indexes(repo):
|
||||
"""The run metadata is always looked up by beer_run_id, and the readings
|
||||
by (beer_run_id, date) — both need an index."""
|
||||
index_names = repo.run_meta.index_information()
|
||||
assert "beer_run_id_1" in index_names
|
||||
|
||||
reading_indexes = repo.readings.index_information()
|
||||
assert "run_date_1" in reading_indexes
|
||||
|
||||
|
||||
def test_no_template_has_an_inline_script_or_style(client):
|
||||
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
|
||||
scripts and style attributes, so the templates must have neither."""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
template_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask", "templates",
|
||||
)
|
||||
|
||||
for path in glob.glob(os.path.join(template_dir, "*.html")):
|
||||
with open(path) as fh:
|
||||
body = fh.read()
|
||||
|
||||
# An inline <script> is one with content rather than a src attribute.
|
||||
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
|
||||
body, re.S):
|
||||
assert match.group(1).strip() == "", (
|
||||
f"{os.path.basename(path)} has an inline <script>, which the "
|
||||
"CSP blocks"
|
||||
)
|
||||
|
||||
assert not re.search(r"\sstyle\s*=", body), (
|
||||
f"{os.path.basename(path)} has an inline style attribute, which "
|
||||
"the CSP blocks"
|
||||
)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- API #
|
||||
|
||||
def test_api_runs_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/api/runs")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["abv"] == 5.25
|
||||
assert payload[0]["active"] is False
|
||||
|
||||
|
||||
def test_api_run_detail_returns_json(logged_in, repo):
|
||||
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
|
||||
|
||||
response = logged_in.get("/api/runs/11")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["beer_run_id"] == "11"
|
||||
assert payload["name"] == "Cider"
|
||||
assert len(payload["readings"]) == 1
|
||||
|
||||
|
||||
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
|
||||
response = logged_in.get("/api/runs/does-not-exist")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_the_api_is_protected(client):
|
||||
"""The charts fetch from the API, so it must not be public."""
|
||||
for path in ("/api/runs", "/api/runs/1"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302, path
|
||||
assert "/login" in response.headers["Location"], path
|
||||
|
||||
|
||||
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
|
||||
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
|
||||
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
|
||||
|
||||
payload = logged_in.get("/api/runs").get_json()
|
||||
entry = next(r for r in payload if r["beer_run_id"] == "12")
|
||||
assert entry["canonical_name"] == "Canonical"
|
||||
assert entry["tap_number"] == 7
|
||||
|
||||
|
||||
def test_the_months_chart_includes_the_gaps(logged_in, repo):
|
||||
"""Months with no brews must appear with a zero, or the chart spaces the
|
||||
busy months evenly and reads as continuous activity."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
from views.analysis import _month_range
|
||||
|
||||
months = _month_range("2026-01", "2026-06")
|
||||
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
|
||||
"2026-05", "2026-06"]
|
||||
|
||||
|
||||
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
|
||||
"""The chart is fed by the API, so the quiet months are visible there."""
|
||||
seed_readings(repo, [
|
||||
("1", "Spring", "1050", "2026-01-05", False, ""),
|
||||
("1", "Spring", "1010", "2026-01-20", True, ""),
|
||||
("2", "Autumn", "1050", "2026-06-05", False, ""),
|
||||
("2", "Autumn", "1010", "2026-06-20", True, ""),
|
||||
])
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
body = logged_in.get("/history").data.decode()
|
||||
# The page embeds the month labels for the chart.
|
||||
assert "2026-06" in body
|
||||
|
||||
|
||||
def test_month_range_covers_a_year_boundary():
|
||||
from views.analysis import _month_range
|
||||
|
||||
assert _month_range("2025-11", "2026-02") == [
|
||||
"2025-11", "2025-12", "2026-01", "2026-02",
|
||||
]
|
||||
assert _month_range("2026-05", "2026-05") == ["2026-05"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------- deep analysis #
|
||||
|
||||
def test_the_analysis_page_renders(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
|
||||
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
||||
("2", "Ginger Beer", "1015", "2026-02-15", True, "done"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "Ginger Beer" in body
|
||||
# The attenuation figures and the summary sentence.
|
||||
assert "80.0%" in body
|
||||
assert "attenuation" in body.lower()
|
||||
|
||||
|
||||
def test_the_analysis_page_excludes_active_runs(logged_in, repo):
|
||||
"""An unfinished brew has no final reading, so it is not comparable."""
|
||||
seed_readings(repo, [
|
||||
("1", "Finished", "1050", "2026-01-01", False, ""),
|
||||
("1", "Finished", "1010", "2026-01-20", True, ""),
|
||||
("2", "Still Going", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
body = logged_in.get("/analysis").data.decode()
|
||||
assert "Finished" in body
|
||||
assert "Still Going" not in body
|
||||
|
||||
|
||||
def test_the_analysis_page_needs_at_least_one_finished_brew(logged_in, repo):
|
||||
seed_readings(repo, [("1", "Just Started", "1045", "2026-02-01", False, "")])
|
||||
|
||||
response = logged_in.get("/analysis")
|
||||
assert response.status_code == 200
|
||||
assert b"No finished brews" in response.data
|
||||
|
||||
|
||||
def test_the_analysis_page_is_protected(client):
|
||||
response = client.get("/analysis")
|
||||
assert response.status_code == 302
|
||||
assert "/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_the_api_exposes_the_attenuation_and_curve(logged_in, repo):
|
||||
"""The charts fetch the curve data from the API."""
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
|
||||
])
|
||||
|
||||
payload = logged_in.get("/api/runs").get_json()
|
||||
entry = payload[0]
|
||||
assert entry["attenuation"] == 80.0
|
||||
assert len(entry["curve"]) == 2
|
||||
assert entry["curve"][0]["progress"] == 0.0
|
||||
assert entry["curve"][1]["progress"] == 1.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,372 @@
|
||||
"""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_name_on_the_final_reading():
|
||||
"""The stored beer_type drifts across readings. The name on the final
|
||||
reading is the one the operator settled on."""
|
||||
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"),
|
||||
])
|
||||
assert run.display_name() == "Nocturna Christmas beer"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_most_used_label():
|
||||
"""Without a final reading, the most-used name wins, breaking ties
|
||||
towards the most recent."""
|
||||
run = Run.from_docs("5", [
|
||||
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
|
||||
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
|
||||
])
|
||||
# The final reading's name wins.
|
||||
assert run.display_name() == "Going Nowhere fast"
|
||||
|
||||
run2 = Run.from_docs("6", [
|
||||
_doc(date="2024-12-03", beer_type="Balter"),
|
||||
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
|
||||
_doc(date="2024-12-10", beer_type="Balter"),
|
||||
])
|
||||
# No final reading; "Balter" was used twice.
|
||||
assert run2.display_name() == "Balter"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_point_scale_is_the_digits_after_the_decimal():
|
||||
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
|
||||
reading and must not be labelled as a point count."""
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1039"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_decimal"] == "1.039"
|
||||
assert summary["min_sg_point_scale"] == "39"
|
||||
|
||||
|
||||
def test_point_scale_for_a_round_reading():
|
||||
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_point_scale"] == "10"
|
||||
assert summary["min_sg_decimal"] == "1.010"
|
||||
|
||||
|
||||
def test_an_unfinished_run_has_no_measured_abv():
|
||||
"""A run still in the fermenter has a projection, not a measurement.
|
||||
Reporting a "measured" ABV for it would be wrong."""
|
||||
run = Run.from_docs("20", [
|
||||
_doc(run_id="20", date="2026-11-01", sg="1045"),
|
||||
_doc(run_id="20", date="2026-11-08", sg="1039"),
|
||||
])
|
||||
assert run.is_active() is True
|
||||
assert run.abv_percent() is None
|
||||
assert run.projected_abv_percent() is not None
|
||||
|
||||
summary = run_summary(run)
|
||||
assert summary["abv"] is None
|
||||
assert summary["projected_abv"] is not None
|
||||
assert summary["legacy_abv"] is None
|
||||
|
||||
|
||||
def test_a_finished_run_still_has_a_measured_abv():
|
||||
run = Run.from_docs("21", [
|
||||
_doc(run_id="21", date="2026-11-01", sg="1050"),
|
||||
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
|
||||
])
|
||||
assert run.abv_percent() == 5.25
|
||||
summary = run_summary(run)
|
||||
assert summary["abv"] == 5.25
|
||||
assert summary["legacy_abv"] is not None
|
||||
Reference in New Issue
Block a user