Compare commits
9
Commits
6538eb3258
...
563e22fb00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563e22fb00 | ||
|
|
bb071343c2 | ||
|
|
8048a1112d | ||
|
|
9f2671fa71 | ||
|
|
060269b03d | ||
|
|
2a9e0ad15e | ||
|
|
bf0f88dd70 | ||
|
|
1fa9f1dcbe | ||
|
|
081014b8dc |
+46
-175
@@ -1,78 +1,35 @@
|
|||||||
name: Build and Push Image
|
name: Build and Push Image
|
||||||
on: [push]
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
name: Unit tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# NOTE: no actions/setup-python here. That action downloads a Python
|
|
||||||
# build from GitHub, which this runner cannot reach reliably - the job
|
|
||||||
# hung on it. The runner image already carries a usable python3, so
|
|
||||||
# the tests use it directly.
|
|
||||||
- name: Show the Python version
|
|
||||||
run: python3 --version
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python3 -m venv .venv
|
|
||||||
. .venv/bin/activate
|
|
||||||
pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
python -m pytest tests/ -q
|
|
||||||
|
|
||||||
- name: Shellcheck the scripts
|
|
||||||
run: |
|
|
||||||
# The operational scripts are shell, so they get the same treatment
|
|
||||||
# as the Python code.
|
|
||||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq shellcheck
|
|
||||||
fi
|
|
||||||
shellcheck scripts/*.sh
|
|
||||||
|
|
||||||
# NOTE: the build job deliberately does NOT have a kubeconfig. The
|
|
||||||
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
|
|
||||||
# that has no need of the cluster should not be able to read it. The deploy
|
|
||||||
# step, added in a later PR, gets its own job with the kubeconfig - that way
|
|
||||||
# a compromised dependency in the build cannot reach the cluster.
|
|
||||||
build:
|
build:
|
||||||
name: Build and push image
|
name: Build and push image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
|
||||||
needs: test
|
|
||||||
if: gitea.ref == 'refs/heads/master'
|
if: gitea.ref == 'refs/heads/master'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
|
# 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_NEW_2 }}" > $HOME/.kube/config
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
# The `kubernetes` driver talks to the cluster API, which needs the
|
driver: kubernetes
|
||||||
# k3s CA certificate. The job container does not have it, so the
|
driver-opts: |
|
||||||
# bootstrap failed with "x509: certificate signed by unknown
|
namespace=gitea-runner
|
||||||
# authority".
|
qemu.install=true
|
||||||
#
|
|
||||||
# The `remote` driver talks straight to the buildkit daemon that is
|
|
||||||
# already running in the cluster, so no cluster API access (and no
|
|
||||||
# kubeconfig) is needed at all. This is the same pattern the
|
|
||||||
# wedding-photos workflow uses successfully.
|
|
||||||
#
|
|
||||||
# NOTE: `endpoint` must be passed as a top-level key. The action
|
|
||||||
# appends it as the POSITIONAL argument to
|
|
||||||
# `buildx create --driver remote <endpoint>`; putting it in
|
|
||||||
# driver-opts instead yields "ERROR: no remote endpoint provided".
|
|
||||||
driver: remote
|
|
||||||
endpoint: tcp://buildkit.gitea-runner.svc:1234
|
|
||||||
|
|
||||||
- name: Login to Docker Registry
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -81,142 +38,56 @@ jobs:
|
|||||||
username: armistace
|
username: armistace
|
||||||
password: ${{ secrets.REG_PASSWORD }}
|
password: ${{ secrets.REG_PASSWORD }}
|
||||||
|
|
||||||
- name: Build Base Image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
file: base.Dockerfile
|
|
||||||
# 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.
|
|
||||||
platforms: linux/amd64
|
|
||||||
tags: |
|
|
||||||
git.aridgwayweb.com/armistace/beer_base_image:latest
|
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
file: flask.Dockerfile
|
file: flask.Dockerfile
|
||||||
|
# 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
|
platforms: linux/amd64
|
||||||
tags: |
|
tags: |
|
||||||
git.aridgwayweb.com/armistace/beer-data:latest
|
git.aridgwayweb.com/armistace/beer-data:latest
|
||||||
git.aridgwayweb.com/armistace/beer-data:${{ github.sha }}
|
|
||||||
|
|
||||||
# The deploy job is separate from the build job on purpose. The
|
- name: Deploy
|
||||||
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build
|
|
||||||
# step that never talks to the cluster should not be able to read it - a
|
|
||||||
# compromised dependency in the build would otherwise have a direct path to
|
|
||||||
# the cluster.
|
|
||||||
deploy:
|
|
||||||
name: Deploy to the cluster
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
if: gitea.ref == 'refs/heads/master'
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Create Kubeconfig
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p $HOME/.kube
|
echo "Installing Kubectl"
|
||||||
# Written with restrictive permissions: the kubeconfig is
|
|
||||||
# cluster-admin-equivalent, so it should not be readable by
|
|
||||||
# anything else on the runner.
|
|
||||||
install -m 600 /dev/null $HOME/.kube/config
|
|
||||||
echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
|
|
||||||
chmod 600 $HOME/.kube/config
|
|
||||||
|
|
||||||
- name: Verify the cluster is reachable
|
|
||||||
run: |
|
|
||||||
# The kubeconfig embeds the k3s server CA. If the cluster's CA has
|
|
||||||
# been rotated since the secret was created, kubectl cannot verify
|
|
||||||
# the server. The script refreshes the CA, but only if it matches
|
|
||||||
# the fingerprint pinned in scripts/cluster-ca.sha256 - see the
|
|
||||||
# script for why.
|
|
||||||
bash scripts/kube-ca-refresh.sh
|
|
||||||
|
|
||||||
- name: Install kubectl
|
|
||||||
run: |
|
|
||||||
# Skip the whole apt dance if the runner already has a kubectl.
|
|
||||||
if command -v kubectl >/dev/null 2>&1; then
|
|
||||||
kubectl version --client
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y apt-transport-https ca-certificates curl gnupg
|
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
|
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
|
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
|
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 update
|
||||||
apt-get install -y kubectl
|
apt-get install kubectl
|
||||||
|
|
||||||
- name: Deploy
|
# Do NOT delete the namespace - that would destroy the live
|
||||||
run: |
|
# Secrets (beer-db, beer-app), which are provisioned by hand.
|
||||||
# Apply the namespace and the non-secret manifests. The Secrets
|
# Apply the namespace (idempotent) and the manifests.
|
||||||
# (beer-db, beer-app) are hand-provisioned and are NOT in the repo,
|
|
||||||
# so they are deliberately not in the apply set.
|
|
||||||
kubectl apply -f kube/beer_namespace.yaml
|
kubectl apply -f kube/beer_namespace.yaml
|
||||||
kubectl apply -f kube/beer-service.yaml
|
|
||||||
# The deployment manifest carries a sentinel image tag; the real
|
|
||||||
# SHA-pinned image is set immediately below. Applying it here
|
|
||||||
# keeps the rest of the spec (probes, resources, security context)
|
|
||||||
# in the repo and under review.
|
|
||||||
kubectl apply -f kube/beer-deployment.yaml
|
|
||||||
|
|
||||||
# The image is pinned to the commit SHA rather than `:latest`.
|
# The image-pull credentials. Created idempotently so a deploy
|
||||||
# `kubectl set image` writes the SHA into the pod template, which
|
# does not fail when they already exist.
|
||||||
# makes the change a real manifest change - the Deployment rolls
|
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 -
|
||||||
# because its pod template changed, not because it was told to.
|
|
||||||
# That also makes `kubectl rollout undo` meaningful: it can go back
|
kubectl apply -f kube/beer-deployment.yaml
|
||||||
# to the previous SHA.
|
kubectl apply -f kube/beer-service.yaml
|
||||||
kubectl -n beer set image deployment/beer-deployment \
|
|
||||||
beer-data=git.aridgwayweb.com/armistace/beer-data:${{ github.sha }}
|
# `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
|
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||||
|
|
||||||
- name: Verify the new image is actually running
|
- name: Verify the deployed app
|
||||||
run: |
|
run: |
|
||||||
# CI green and pods Ready do NOT prove the new code is live. Compare
|
# Pods Running is not proof the app works. Exercise the real
|
||||||
# the running digest against the registry's :latest.
|
# endpoints from inside the pod.
|
||||||
RUNNING=$(kubectl -n beer get pod -l app=beer-data \
|
kubectl -n beer exec deploy/beer-deployment -- \
|
||||||
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}')
|
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)"
|
||||||
REGISTRY=$(curl -s -u "armistace:${{ secrets.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 | awk '{print $2}' | tr -d '\r')
|
|
||||||
echo "running: $RUNNING"
|
|
||||||
echo "registry: $REGISTRY"
|
|
||||||
if [ "$RUNNING" != "$REGISTRY" ]; then
|
|
||||||
echo "ERROR: the running pod is not the image that was just pushed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Remove the kubeconfig
|
|
||||||
if: always()
|
|
||||||
run: rm -f $HOME/.kube/config
|
|
||||||
|
|
||||||
- name: Smoke test the deployed app
|
|
||||||
run: |
|
|
||||||
# Check the app's own port from inside the pod, AND the Service in
|
|
||||||
# front of it. The pod check alone would pass even if the Service
|
|
||||||
# selector or the port mapping were wrong, which is exactly the
|
|
||||||
# kind of breakage a deploy can introduce silently.
|
|
||||||
for i in $(seq 1 12); do
|
|
||||||
if kubectl -n beer exec deploy/beer-deployment -- \
|
|
||||||
python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/healthz').status==200 else 1)"; then
|
|
||||||
echo "healthz OK"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "waiting for the app to come up ($i/12)"
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
kubectl -n beer exec deploy/beer-deployment -- \
|
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)"
|
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)"
|
||||||
|
|
||||||
# Through the Service - the path a real client takes.
|
|
||||||
kubectl -n beer run smoke --rm -i --restart=Never --image=python:3.14-slim -- \
|
|
||||||
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://beer-service.beer.svc.cluster.local/healthz'); print('service healthz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
|
|
||||||
|
|||||||
@@ -34,6 +34,56 @@ python -m pytest tests/ -q
|
|||||||
|
|
||||||
The tests use `mongomock`, so they need no database and no network.
|
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 |
|
||||||
|
|
||||||
|
### `/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.
|
||||||
|
|
||||||
|
### 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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -41,11 +91,13 @@ src/flask/
|
|||||||
beer_data.py entry point; exposes `app` and `create_app`
|
beer_data.py entry point; exposes `app` and `create_app`
|
||||||
app_factory.py application factory, request-scoped Mongo repository
|
app_factory.py application factory, request-scoped Mongo repository
|
||||||
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
||||||
views/ blueprints: auth, brews, (later) analysis
|
views/ blueprints: auth, brews, analysis
|
||||||
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
||||||
templates/ Jinja templates
|
templates/ Jinja templates
|
||||||
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
||||||
|
static/ CSS, JS, fonts, logo (all served locally - no CDN)
|
||||||
tests/ pytest suite
|
tests/ pytest suite
|
||||||
|
scripts/ operational scripts (the run-metadata backfill)
|
||||||
```
|
```
|
||||||
|
|
||||||
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
||||||
|
|||||||
+24
@@ -130,6 +130,30 @@ livenessProbe:
|
|||||||
periodSeconds: 20
|
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
|
## Data safety
|
||||||
|
|
||||||
The `beer_db.beer_data` collection holds the actual brewing records. There is
|
The `beer_db.beer_data` collection holds the actual brewing records. There is
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: beer-data
|
app: beer-data
|
||||||
spec:
|
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:
|
containers:
|
||||||
- name: beer-data
|
- name: beer-data
|
||||||
# The image tag is pinned to the commit SHA by CI (`kubectl set
|
image: git.aridgwayweb.com/armistace/beer-data:latest
|
||||||
# image`). The value here is a sentinel: applying the manifest
|
|
||||||
# as-is would leave the deployment on that sentinel, so CI always
|
|
||||||
# replaces it before rolling. It is deliberately NOT `:latest` —
|
|
||||||
# a mutable tag makes the rollout a no-op (the pod template does not
|
|
||||||
# change) and makes `rollout undo` meaningless.
|
|
||||||
image: git.aridgwayweb.com/armistace/beer-data:SET_BY_CI
|
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 5000
|
- containerPort: 5000
|
||||||
@@ -58,14 +59,11 @@ spec:
|
|||||||
cpu: 50m
|
cpu: 50m
|
||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
securityContext:
|
securityContext:
|
||||||
|
# Container-level: these two belong here, not on the pod.
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities:
|
capabilities:
|
||||||
drop:
|
drop:
|
||||||
- ALL
|
- ALL
|
||||||
# The app reads its own source and talks to Mongo over the
|
|
||||||
# network. It writes no files, so the root filesystem can be
|
|
||||||
# read-only. /tmp is still writable via the emptyDir below.
|
|
||||||
readOnlyRootFilesystem: true
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
@@ -84,14 +82,6 @@ spec:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
# The image creates a named user "beer". kubelet cannot verify that a
|
|
||||||
# *named* user is non-root (it can only check a numeric UID), so the
|
|
||||||
# UID is set explicitly here. It must match the UID in the image.
|
|
||||||
runAsUser: 999
|
|
||||||
runAsGroup: 999
|
|
||||||
fsGroup: 999
|
|
||||||
nodeSelector:
|
nodeSelector:
|
||||||
kubernetes.io/hostname: archlinux-k3s-3
|
kubernetes.io/hostname: archlinux-k3s-3
|
||||||
imagePullSecrets:
|
imagePullSecrets:
|
||||||
|
|||||||
@@ -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,17 +0,0 @@
|
|||||||
# SHA-256 fingerprint of the cluster's API server CA (the k3s server-ca.crt).
|
|
||||||
#
|
|
||||||
# This is a PUBLIC value, not a secret - it can be read straight off the
|
|
||||||
# certificate. It lives in the repo rather than in a Gitea secret so that
|
|
||||||
# changing it is a reviewable commit, and so nobody with secret-write access
|
|
||||||
# can silently repoint the CI runner at a different CA.
|
|
||||||
#
|
|
||||||
# The deploy job fetches the cluster's CA over an unverified connection and
|
|
||||||
# compares its fingerprint against this value. A CA that does not match is
|
|
||||||
# refused, so an attacker able to intercept the fetch cannot substitute
|
|
||||||
# their own.
|
|
||||||
#
|
|
||||||
# To refresh it after a legitimate CA rotation:
|
|
||||||
# openssl x509 -in /var/lib/rancher/k3s/server/tls/server-ca.crt # -noout -fingerprint -sha256
|
|
||||||
# and update the line below (colons are stripped before comparison, so either
|
|
||||||
# format works).
|
|
||||||
d23a17bb9c5f7c66d15e15635d6bb928e3a34f38142705e4e6516075a5f35907
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Make sure kubectl can talk to the cluster API server.
|
|
||||||
#
|
|
||||||
# The kubeconfig embeds the k3s server CA. If the cluster's CA has been
|
|
||||||
# rotated since the secret was created, kubectl fails with:
|
|
||||||
#
|
|
||||||
# tls: failed to verify certificate: x509: certificate signed by unknown authority
|
|
||||||
#
|
|
||||||
# which reads like a network problem rather than a stale secret. This script
|
|
||||||
# refreshes the CA in place.
|
|
||||||
#
|
|
||||||
# The refresh is PINNED: the server's CA is fetched over an unverified
|
|
||||||
# connection, but it is only used if its SHA-256 fingerprint matches the value
|
|
||||||
# pinned in scripts/cluster-ca.sha256. An attacker able to intercept the
|
|
||||||
# fetch cannot use it - their CA will not hash to the pinned fingerprint.
|
|
||||||
# The runner has cluster-admin credentials, so trusting an unverified CA here
|
|
||||||
# would be a privilege-escalation hole.
|
|
||||||
#
|
|
||||||
# The fingerprint is a PUBLIC value - it can be read straight off the
|
|
||||||
# certificate - so it lives in the repo rather than in a secret. That way a
|
|
||||||
# change to it is a reviewable commit, and nobody with secret-write access
|
|
||||||
# can silently repoint the CI runner at a different CA.
|
|
||||||
#
|
|
||||||
# The expected fingerprint is read from scripts/cluster-ca.sha256 next to
|
|
||||||
# this script. It can be overridden with the first argument.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# kube-ca-refresh.sh [expected-fingerprint]
|
|
||||||
#
|
|
||||||
# Exit codes:
|
|
||||||
# 0 the cluster is reachable
|
|
||||||
# 1 the cluster is not reachable and could not be fixed safely
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
EXPECTED="${1:-}"
|
|
||||||
if [ -z "$EXPECTED" ] && [ -f "$SCRIPT_DIR/cluster-ca.sha256" ]; then
|
|
||||||
# The file may carry comments; take the last non-comment line.
|
|
||||||
EXPECTED=$(grep -vE '^[[:space:]]*(#|$)' "$SCRIPT_DIR/cluster-ca.sha256" | tail -1 | tr -d '[:space:]')
|
|
||||||
fi
|
|
||||||
|
|
||||||
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
|
|
||||||
echo "cluster reachable"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$EXPECTED" ]; then
|
|
||||||
echo "ERROR: the kubeconfig cannot verify the API server, and no expected CA" >&2
|
|
||||||
echo " fingerprint was supplied, so the CA cannot be refreshed safely." >&2
|
|
||||||
echo " and no pinned CA fingerprint could be found." >&2
|
|
||||||
echo " Set one in scripts/cluster-ca.sha256:" >&2
|
|
||||||
echo " openssl x509 -in server-ca.crt -noout -fingerprint -sha256" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Guard against a kubeconfig with no clusters, or an empty one.
|
|
||||||
CLUSTER_COUNT=$(kubectl config view --raw -o jsonpath='{.clusters}' 2>/dev/null \
|
|
||||||
| grep -c '"name"' || true)
|
|
||||||
if [ "${CLUSTER_COUNT:-0}" -eq 0 ]; then
|
|
||||||
echo "ERROR: the kubeconfig has no cluster entries." >&2
|
|
||||||
echo " The KUBEC_CONFIG_BUILDX_NEW_2 secret looks wrong or empty." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVER=$(kubectl config view --raw -o jsonpath='{.clusters[0].cluster.server}')
|
|
||||||
# The cluster entry's NAME - patching the CA into a *new* entry named
|
|
||||||
# "default" would leave the original entry (and its stale CA) in use.
|
|
||||||
CLUSTER_NAME=$(kubectl config view --raw -o jsonpath='{.clusters[0].name}')
|
|
||||||
|
|
||||||
if [ -z "$SERVER" ] || [ -z "$CLUSTER_NAME" ]; then
|
|
||||||
echo "ERROR: could not read the server or cluster name from the kubeconfig." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "server: $SERVER cluster entry: $CLUSTER_NAME"
|
|
||||||
|
|
||||||
CA_FILE=$(mktemp)
|
|
||||||
trap 'rm -f "$CA_FILE"' EXIT
|
|
||||||
|
|
||||||
# Timeouts so an unreachable API server fails the job rather than hanging
|
|
||||||
# the runner indefinitely.
|
|
||||||
curl -sk --connect-timeout 10 --max-time 30 "$SERVER/cacerts" -o "$CA_FILE"
|
|
||||||
if [ ! -s "$CA_FILE" ]; then
|
|
||||||
echo "ERROR: could not fetch the cluster CA from $SERVER/cacerts" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Normalise both to lowercase hex without separators, so the comparison does
|
|
||||||
# not depend on the colon-separated format openssl prints.
|
|
||||||
normalise() {
|
|
||||||
tr -d ':' | tr '[:upper:]' '[:lower:]'
|
|
||||||
}
|
|
||||||
|
|
||||||
ACTUAL=$(openssl x509 -in "$CA_FILE" -noout -fingerprint -sha256 | cut -d= -f2 | normalise)
|
|
||||||
WANTED=$(printf '%s' "$EXPECTED" | normalise)
|
|
||||||
|
|
||||||
echo "expected CA fingerprint: $WANTED"
|
|
||||||
echo "actual CA fingerprint: $ACTUAL"
|
|
||||||
|
|
||||||
if [ "$ACTUAL" != "$WANTED" ]; then
|
|
||||||
echo "ERROR: the cluster CA does not match the pinned fingerprint." >&2
|
|
||||||
echo " expected: $WANTED" >&2
|
|
||||||
echo " actual: $ACTUAL" >&2
|
|
||||||
echo " Refusing to use it. Either the CA has been rotated and the" >&2
|
|
||||||
echo " pinned fingerprint needs updating, or the connection was" >&2
|
|
||||||
echo " intercepted." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# The fingerprint matches the pinned value, so this is the CA we expect.
|
|
||||||
kubectl config set-cluster "$CLUSTER_NAME" \
|
|
||||||
--embed-certs=true \
|
|
||||||
--certificate-authority="$CA_FILE" >/dev/null
|
|
||||||
|
|
||||||
if kubectl version --request-timeout=15s >/dev/null 2>&1; then
|
|
||||||
echo "cluster reachable after refreshing the CA"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "ERROR: cannot reach the cluster API server even with a refreshed CA." >&2
|
|
||||||
echo " The kubeconfig secret may be stale or invalid." >&2
|
|
||||||
exit 1
|
|
||||||
@@ -16,6 +16,26 @@ from flask_wtf import CSRFProtect
|
|||||||
__all__ = ["create_app"]
|
__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):
|
class ImproperlyConfigured(RuntimeError):
|
||||||
"""Raised when the app is missing configuration it cannot safely default.
|
"""Raised when the app is missing configuration it cannot safely default.
|
||||||
|
|
||||||
@@ -110,6 +130,20 @@ def create_app(config: dict | None = None) -> Flask:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Error handlers
|
# Error handlers
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
@app.after_request
|
||||||
|
def _set_security_headers(response):
|
||||||
|
"""Add the standard security headers.
|
||||||
|
|
||||||
|
The app serves its own vendored JavaScript and CSS and loads nothing
|
||||||
|
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
||||||
|
and is genuinely enforceable.
|
||||||
|
"""
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||||
|
return response
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def _not_found(_exc):
|
def _not_found(_exc):
|
||||||
return render_template("error.html", code=404,
|
return render_template("error.html", code=404,
|
||||||
@@ -123,11 +157,13 @@ def create_app(config: dict | None = None) -> Flask:
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Blueprints
|
# Blueprints
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
from views.analysis import bp as analysis_bp
|
||||||
from views.auth import bp as auth_bp
|
from views.auth import bp as auth_bp
|
||||||
from views.brews import bp as brews_bp
|
from views.brews import bp as brews_bp
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(brews_bp)
|
app.register_blueprint(brews_bp)
|
||||||
|
app.register_blueprint(analysis_bp)
|
||||||
|
|
||||||
@app.route("/healthz")
|
@app.route("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
|
|||||||
+55
-16
@@ -59,7 +59,9 @@ ABV_FACTOR = 0.13125
|
|||||||
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
||||||
ASSUMED_ORIGINAL_GRAVITY = 1012
|
ASSUMED_ORIGINAL_GRAVITY = 1012
|
||||||
|
|
||||||
#: Plausible range for a hydrometer reading, in points.
|
#: Plausible range for a hydrometer reading, in points. The upper bound is
|
||||||
|
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
|
||||||
|
#: still catching typos such as 900 or 1500.
|
||||||
_MIN_PLAUSIBLE_SG = 990
|
_MIN_PLAUSIBLE_SG = 990
|
||||||
_MAX_PLAUSIBLE_SG = 1200
|
_MAX_PLAUSIBLE_SG = 1200
|
||||||
|
|
||||||
@@ -228,7 +230,15 @@ class Run:
|
|||||||
return og - fg
|
return og - fg
|
||||||
|
|
||||||
def abv_percent(self) -> float | None:
|
def abv_percent(self) -> float | None:
|
||||||
"""Standard ABV for a finished run."""
|
"""Standard ABV for a **finished** run.
|
||||||
|
|
||||||
|
A run that is still in the fermenter has no measured alcohol - its
|
||||||
|
figure is a projection, not a measurement. Returning the drop here
|
||||||
|
for an unfinished run would report a "measured" ABV for a beer that
|
||||||
|
has not finished fermenting, which is wrong.
|
||||||
|
"""
|
||||||
|
if self.is_active():
|
||||||
|
return None
|
||||||
drop = self.gravity_drop_points()
|
drop = self.gravity_drop_points()
|
||||||
return abv_percent(drop) if drop is not None else None
|
return abv_percent(drop) if drop is not None else None
|
||||||
|
|
||||||
@@ -289,20 +299,32 @@ class Run:
|
|||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
"""Best available human label for the run.
|
"""Best available human label for the run.
|
||||||
|
|
||||||
The stored ``beer_type`` drifts between readings of the same run, so the
|
The stored ``beer_type`` drifts between readings of the same run, so
|
||||||
most frequently used non-empty value wins; ties break towards the most
|
the name is resolved by preferring, in order:
|
||||||
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
|
|
||||||
rather than "Necatron-Christmas Beer".
|
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".
|
||||||
"""
|
"""
|
||||||
counts: dict[str, int] = {}
|
named = [r for r in self.readings if r.beer_type]
|
||||||
for reading in self.readings:
|
if not named:
|
||||||
label = reading.beer_type
|
|
||||||
if label:
|
|
||||||
counts[label] = counts.get(label, 0) + 1
|
|
||||||
if not counts:
|
|
||||||
return f"Run {self.beer_run_id}"
|
return f"Run {self.beer_run_id}"
|
||||||
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
|
||||||
return best
|
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:
|
def _last_index_of(self, label: str) -> int:
|
||||||
for index in range(len(self.readings) - 1, -1, -1):
|
for index in range(len(self.readings) - 1, -1, -1):
|
||||||
@@ -391,12 +413,24 @@ def days_between(start: date, end: date) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _fmt_decimal(points: int | None) -> str:
|
def _fmt_decimal(points: int | None) -> str:
|
||||||
"""Format gravity points as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||||
if points is None:
|
if points is None:
|
||||||
return ""
|
return ""
|
||||||
return f"{points / 1000:.3f}"
|
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 run_summary(run: Run, today: date | None = None,
|
def run_summary(run: Run, today: date | None = None,
|
||||||
og_override: int | None = None) -> dict:
|
og_override: int | None = None) -> dict:
|
||||||
"""A flat dict describing a run, ready for a template or a JSON response."""
|
"""A flat dict describing a run, ready for a template or a JSON response."""
|
||||||
@@ -415,18 +449,23 @@ def run_summary(run: Run, today: date | None = None,
|
|||||||
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
||||||
"og_decimal_str": _fmt_decimal(og),
|
"og_decimal_str": _fmt_decimal(og),
|
||||||
"fg_decimal_str": _fmt_decimal(fg),
|
"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(),
|
"max_sg": run.max_sg(),
|
||||||
"min_sg": run.min_sg(),
|
"min_sg": run.min_sg(),
|
||||||
"min_sg_decimal": _fmt_decimal(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(),
|
"drop_points": run.gravity_drop_points(),
|
||||||
"abv": run.abv_percent(),
|
"abv": run.abv_percent(),
|
||||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||||
"legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
|
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg())
|
||||||
|
if not run.is_active() else None),
|
||||||
"reading_count": len(run.readings),
|
"reading_count": len(run.readings),
|
||||||
"first_date": first.date.isoformat() if (first and first.date) else None,
|
"first_date": first.date.isoformat() if (first and first.date) else None,
|
||||||
"final_date": final.date.isoformat() if (final and final.date) else None,
|
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||||
"days_in_fermenter": run.days_in_fermenter(today),
|
"days_in_fermenter": run.days_in_fermenter(today),
|
||||||
"comments": run.comments(),
|
"comments": run.comments(),
|
||||||
|
"last_comment": run.comments()[-1] if run.comments() else "",
|
||||||
"outliers": [r.sg_points for r in run.outlier_readings()],
|
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||||
"readings": [
|
"readings": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,6 +44,29 @@ class BeerRepository:
|
|||||||
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
||||||
# shadow the runs() method on the instance.
|
# shadow the runs() method on the instance.
|
||||||
self.run_meta = self.db["beer_runs"]
|
self.run_meta = self.db["beer_runs"]
|
||||||
|
self._ensure_indexes()
|
||||||
|
|
||||||
|
def _ensure_indexes(self) -> None:
|
||||||
|
"""Make sure the lookup keys are indexed.
|
||||||
|
|
||||||
|
Every read of the run metadata filters on ``beer_run_id``, and the
|
||||||
|
readings are always fetched by ``(beer_run_id, date)``. Without the
|
||||||
|
indexes those are collection scans.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
|
||||||
|
self.readings.create_index(
|
||||||
|
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
|
||||||
|
)
|
||||||
|
except PyMongoError:
|
||||||
|
# An index failure must not stop the app from serving, 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 -------------------------------------------------------------- #
|
# -- users -------------------------------------------------------------- #
|
||||||
|
|
||||||
@@ -196,9 +219,13 @@ class BeerRepository:
|
|||||||
"""Create or update the metadata document for a run.
|
"""Create or update the metadata document for a run.
|
||||||
|
|
||||||
Only the keys passed in are written; anything already stored is left
|
Only the keys passed in are written; anything already stored is left
|
||||||
alone.
|
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 = {k: v for k, v in fields.items() if v is not None}
|
payload = dict(fields)
|
||||||
payload["updated_at"] = _now_iso()
|
payload["updated_at"] = _now_iso()
|
||||||
self.run_meta.update_one(
|
self.run_meta.update_one(
|
||||||
{"beer_run_id": _as_id(beer_run_id)},
|
{"beer_run_id": _as_id(beer_run_id)},
|
||||||
@@ -207,6 +234,38 @@ class BeerRepository:
|
|||||||
)
|
)
|
||||||
return self.run_metadata(beer_run_id)
|
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:
|
def delete_run_metadata(self, beer_run_id: str) -> bool:
|
||||||
"""Remove the metadata document for a run (readings are untouched)."""
|
"""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
|
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
|
||||||
|
|||||||
@@ -172,7 +172,14 @@ p { margin: 0 0 .75rem; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
|
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
|
||||||
.topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); }
|
.topbar__user {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ page */
|
/* ------------------------------------------------------------------ page */
|
||||||
|
|
||||||
@@ -285,7 +292,7 @@ p { margin: 0 0 .75rem; }
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: var(--amber-bright);
|
color: var(--amber-bright);
|
||||||
}
|
}
|
||||||
.stat__meta { color: var(--text-muted); font-size: .82rem; }
|
.stat__meta { color: var(--text-muted); font-size: .83rem; }
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- forms */
|
/* ----------------------------------------------------------------- forms */
|
||||||
|
|
||||||
@@ -299,7 +306,7 @@ p { margin: 0 0 .75rem; }
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: .3rem;
|
margin-bottom: .3rem;
|
||||||
}
|
}
|
||||||
.field__hint { color: var(--text-faint); font-size: .78rem; margin-top: .25rem; }
|
.field__hint { color: var(--text-muted); font-size: .8rem; margin-top: .25rem; }
|
||||||
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
|
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
|
||||||
|
|
||||||
input[type="text"], input[type="password"], input[type="number"],
|
input[type="text"], input[type="password"], input[type="number"],
|
||||||
@@ -579,11 +586,45 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
}
|
}
|
||||||
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||||
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
||||||
.brew-card__meta { color: var(--text-faint); font-size: .8rem; font-family: var(--mono); }
|
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
|
||||||
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||||
.brew-card__stat .stat__label { font-size: .66rem; }
|
.brew-card__stat .stat__label { font-size: .68rem; }
|
||||||
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
||||||
.brew-card__spark { height: 46px; }
|
.brew-card__spark { height: 46px; }
|
||||||
|
|
||||||
|
/* The "start a new beer" tile — fills the last grid slot and gives the
|
||||||
|
page a way to begin a brew without hunting for the form. */
|
||||||
|
.brew-card--new {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .6rem;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
.brew-card--new:hover {
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brew-card--new::before { display: none; }
|
||||||
|
.brew-card__new-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.brew-card__new-text { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.brew-card__new-text strong { font-size: .95rem; }
|
||||||
.brew-card__spark--empty {
|
.brew-card__spark--empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -608,6 +649,12 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
|||||||
.row--between { justify-content: space-between; }
|
.row--between { justify-content: space-between; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Spacing utilities. These exist so the templates carry no inline
|
||||||
|
`style=` attributes, which a strict Content-Security-Policy
|
||||||
|
(`style-src 'self'`) would block. */
|
||||||
|
.mb-lg { margin-bottom: 1.25rem; }
|
||||||
|
.mt-lg { margin-top: 2rem; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
padding: 2.5rem 1rem;
|
padding: 2.5rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,289 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
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: {
|
||||||
|
y: {
|
||||||
|
reverse: true,
|
||||||
|
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; }
|
||||||
|
|
||||||
|
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.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: { ticks: { maxRotation: 45, minRotation: 0 } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (err) { console.warn(err); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMonthsChart() {
|
||||||
|
var canvas = document.getElementById("months-chart");
|
||||||
|
if (!canvas) { return; }
|
||||||
|
|
||||||
|
fetch("/api/runs", { headers: { "Accept": "application/json" } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.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); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
drawSparklines();
|
||||||
|
drawRunChart();
|
||||||
|
drawAbvChart();
|
||||||
|
drawMonthsChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{% 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 %}
|
||||||
|
|
||||||
|
<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 %}
|
||||||
@@ -19,11 +19,15 @@
|
|||||||
<nav class="nav" aria-label="Main">
|
<nav class="nav" aria-label="Main">
|
||||||
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
||||||
href="{{ url_for('brews.index') }}">Brew Process</a>
|
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>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<span class="topbar__user">{{ session.get('username', '') }}</span>
|
<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>
|
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +48,9 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<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/app.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -93,15 +93,15 @@
|
|||||||
<td>{{ run.canonical_name }}</td>
|
<td>{{ run.canonical_name }}</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{% if run.min_sg is not none %}
|
{% if run.min_sg is not none %}
|
||||||
<span class="mono strong">{{ run.min_sg }}</span>
|
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||||
<div class="faint tiny">{{ run.min_sg_decimal }}</div>
|
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="num">
|
<td class="num">
|
||||||
{% if run.projected_abv is not none %}
|
{% if run.projected_abv is not none %}
|
||||||
{{ '%.2f' % run.projected_abv }}%
|
{{ '%.2f' % run.projected_abv }}%
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="faint" title="Record the original gravity for this run to get an estimate">—</span>
|
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="comment">
|
<td class="comment">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card" style="margin-top:2rem">
|
<div class="card mt-lg">
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||||
<h1>{{ code }}</h1>
|
<h1>{{ code }}</h1>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|
||||||
@@ -22,14 +22,57 @@
|
|||||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid--4" style="margin-bottom:1.25rem">
|
<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">
|
||||||
<div class="stat__label">Original gravity</div>
|
<div class="stat__label">Original gravity</div>
|
||||||
<div class="stat__value">
|
<div class="stat__value">
|
||||||
{% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %}
|
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if summary.og_decimal %}
|
{% if summary.og_points is not none %}
|
||||||
<div class="stat__meta">{{ summary.og_decimal_str }}</div>
|
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -37,10 +80,10 @@
|
|||||||
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat__value">
|
<div class="stat__value">
|
||||||
{% if summary.fg_points is not none %}{{ summary.fg_points }}{% else %}—{% endif %}
|
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if summary.fg_decimal %}
|
{% if summary.fg_points is not none %}
|
||||||
<div class="stat__meta">{{ summary.fg_decimal_str }}</div>
|
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -53,7 +96,13 @@
|
|||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat__meta">
|
<div class="stat__meta">
|
||||||
{% if summary.abv is not none %}measured{% else %}projected{% endif %}
|
{% if summary.abv is not none %}
|
||||||
|
measured
|
||||||
|
{% elif summary.projected_abv is not none %}
|
||||||
|
projected
|
||||||
|
{% else %}
|
||||||
|
needs 2nd reading
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -63,8 +112,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="card mb-lg">
|
||||||
|
<div class="card__head">
|
||||||
|
<h2 class="card__title">Gravity over time</h2>
|
||||||
|
<span class="card__hint">Standard ABV: (OG − FG) × 131.25</span>
|
||||||
|
</div>
|
||||||
|
<div class="card__body">
|
||||||
|
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__head">
|
<div class="card__head">
|
||||||
<h2 class="card__title">Readings</h2>
|
<h2 class="card__title">Readings</h2>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""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, 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
|
||||||
|
|
||||||
|
|
||||||
|
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"]]
|
||||||
|
|
||||||
|
# --- 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"],
|
||||||
|
})
|
||||||
|
|
||||||
|
avg_abv = round(sum(r["abv"] for r in abv_rows) / len(abv_rows), 2) if abv_rows else 0
|
||||||
|
best = max(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
|
||||||
|
worst = min(abv_rows, key=lambda r: r["abv"]) if 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,
|
||||||
|
},
|
||||||
|
)
|
||||||
+94
-10
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from flask import (Blueprint, flash, redirect, render_template, request,
|
from flask import (Blueprint, flash, jsonify, redirect, render_template,
|
||||||
session, url_for)
|
request, session, url_for)
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||||
SubmitField, TextAreaField)
|
SubmitField, TextAreaField)
|
||||||
@@ -50,12 +50,29 @@ class ReadingForm(FlaskForm):
|
|||||||
validators=[Optional(), Length(max=120)])
|
validators=[Optional(), Length(max=120)])
|
||||||
Date = DateField("Reading date", validators=[DataRequired()])
|
Date = DateField("Reading date", validators=[DataRequired()])
|
||||||
sg = IntegerField("Gravity reading (points)",
|
sg = IntegerField("Gravity reading (points)",
|
||||||
validators=[DataRequired(), NumberRange(min=900, max=1300)])
|
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||||
final_reading = BooleanField("This is the final reading")
|
final_reading = BooleanField("This is the final reading")
|
||||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||||
submit = SubmitField("Save reading")
|
submit = SubmitField("Save reading")
|
||||||
|
|
||||||
|
|
||||||
|
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"])
|
@bp.route("/brews", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
@@ -82,11 +99,15 @@ def index():
|
|||||||
return redirect(url_for("brews.index"))
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
runs = repo().runs()
|
runs = repo().runs()
|
||||||
|
metadata = repo().all_run_metadata()
|
||||||
|
|
||||||
summaries = []
|
summaries = []
|
||||||
for run in runs:
|
for run in runs:
|
||||||
summary = run_summary(run)
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
summary["canonical_name"] = run.display_name()
|
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)
|
summary["last_date"] = _last_reading_date(run)
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
|
|
||||||
@@ -103,22 +124,85 @@ def index():
|
|||||||
summaries=summaries,
|
summaries=summaries,
|
||||||
active_runs=active,
|
active_runs=active,
|
||||||
completed_runs=completed,
|
completed_runs=completed,
|
||||||
|
next_tap=repo().next_free_tap_number(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/runs/<run_id>")
|
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def run_detail(run_id: str):
|
def run_detail(run_id: str):
|
||||||
"""One run in detail, with all of its readings."""
|
"""One run in detail, with its readings and its editable metadata."""
|
||||||
run = repo().run(run_id)
|
run = repo().run(run_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
flash(f"No run numbered {run_id}.", "error")
|
flash(f"No run numbered {run_id}.", "error")
|
||||||
return redirect(url_for("brews.index"))
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
summary = run_summary(run)
|
meta = repo().run_metadata(run_id)
|
||||||
summary["canonical_name"] = run.display_name()
|
meta_form = RunMetaForm()
|
||||||
|
|
||||||
return render_template("run_detail.html", run=run, summary=summary)
|
if meta_form.validate_on_submit():
|
||||||
|
repo().upsert_run_metadata(
|
||||||
|
run_id,
|
||||||
|
name=(meta_form.name.data or "").strip() or None,
|
||||||
|
tap_number=meta_form.tap_number.data,
|
||||||
|
og_points=meta_form.og_points.data,
|
||||||
|
notes=(meta_form.notes.data or "").strip() or None,
|
||||||
|
)
|
||||||
|
flash("Run details saved.", "success")
|
||||||
|
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||||
|
|
||||||
|
if not meta_form.is_submitted():
|
||||||
|
meta_form.name.data = meta.get("name") or run.display_name()
|
||||||
|
meta_form.tap_number.data = meta.get("tap_number")
|
||||||
|
meta_form.og_points.data = meta.get("og_points")
|
||||||
|
meta_form.notes.data = meta.get("notes", "")
|
||||||
|
|
||||||
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||||
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||||
|
summary["tap_number"] = meta.get("tap_number")
|
||||||
|
summary["notes"] = meta.get("notes", "")
|
||||||
|
|
||||||
|
return render_template("run_detail.html", run=run, summary=summary,
|
||||||
|
meta_form=meta_form, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
def api_runs():
|
||||||
|
"""JSON feed of the run summaries, used by the charts on the analysis
|
||||||
|
pages.
|
||||||
|
|
||||||
|
The charts fetch this rather than having the data inlined into the page,
|
||||||
|
so one fetch serves every chart on a page.
|
||||||
|
"""
|
||||||
|
runs = repo().runs()
|
||||||
|
metadata = repo().all_run_metadata()
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for run in runs:
|
||||||
|
meta = metadata.get(run.beer_run_id, {})
|
||||||
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||||
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||||
|
summary["tap_number"] = meta.get("tap_number")
|
||||||
|
summary["last_date"] = _last_reading_date(run)
|
||||||
|
payload.append(summary)
|
||||||
|
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/runs/<run_id>")
|
||||||
|
@login_required
|
||||||
|
def api_run(run_id: str):
|
||||||
|
"""JSON detail of a single run."""
|
||||||
|
run = repo().run(run_id)
|
||||||
|
if run is None:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
meta = repo().run_metadata(run_id)
|
||||||
|
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||||
|
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||||
|
summary["tap_number"] = meta.get("tap_number")
|
||||||
|
return jsonify(summary)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
|||||||
# ----------------------------------------------------------------- misc #
|
# ----------------------------------------------------------------- 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):
|
def test_static_assets_are_served(client):
|
||||||
for path in ("/static/js/app.js", "/static/css/app.css",
|
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||||
"/static/img/logo.svg"):
|
"/static/img/logo.svg"):
|
||||||
@@ -303,3 +312,330 @@ def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||||
importlib.reload(app_factory)
|
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"]
|
||||||
|
|||||||
+68
-3
@@ -184,17 +184,36 @@ def test_run_is_active_until_a_final_reading_exists():
|
|||||||
assert final_reading(finished.readings) is not None
|
assert final_reading(finished.readings) is not None
|
||||||
|
|
||||||
|
|
||||||
def test_run_display_name_prefers_the_most_used_label():
|
def test_run_display_name_prefers_the_name_on_the_final_reading():
|
||||||
"""The stored beer_type drifts across readings; the most-used one wins."""
|
"""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", [
|
run = Run.from_docs("4", [
|
||||||
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
_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-28", beer_type="Nocturna the Christmas beer"),
|
||||||
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
||||||
])
|
])
|
||||||
# All three are unique, so the most recent wins.
|
|
||||||
assert run.display_name() == "Nocturna Christmas beer"
|
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():
|
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")])
|
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||||
assert run.display_name() == "Run 7"
|
assert run.display_name() == "Run 7"
|
||||||
@@ -305,3 +324,49 @@ def test_run_summary_handles_an_empty_run():
|
|||||||
assert summary["abv"] is None
|
assert summary["abv"] is None
|
||||||
assert summary["reading_count"] == 0
|
assert summary["reading_count"] == 0
|
||||||
assert summary["name"] == "Run 99"
|
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