Compare commits
27
Commits
dev
..
2a9e0ad15e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9e0ad15e | ||
|
|
bf0f88dd70 | ||
|
|
1fa9f1dcbe | ||
|
|
081014b8dc | ||
|
|
3e20b558a5 | ||
|
|
a4b3d40e2d | ||
|
|
ce6f5b7962 | ||
|
|
687339bb9e | ||
|
|
c28553a1b8 | ||
|
|
958ae9b365 | ||
|
|
55d5e84150 | ||
|
|
b73df93f20 | ||
|
|
55c5de9d61 | ||
|
|
0f93cbea70 | ||
|
|
c0c6b4c1fc | ||
|
|
9d3ce1d2c4 | ||
|
|
78b3b10131 | ||
|
|
a48fb1c566 | ||
|
|
e9ce0dcf9b | ||
|
|
757774168d | ||
|
|
ba65ec5a8f | ||
|
|
6e1189e319 | ||
|
|
1828dcb81d | ||
|
|
24cdd452c5 | ||
|
|
fb65ba4ee0 | ||
|
|
718c8e87b3 | ||
|
|
4baf08813c |
+192
-12
@@ -2,28 +2,88 @@ name: Build and Push Image
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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.
|
||||
#
|
||||
# The version is checked against the container image's Python so the
|
||||
# tests run on the same interpreter the app ships in.
|
||||
- name: Show the Python version
|
||||
run: |
|
||||
python3 --version
|
||||
python3 -c "import sys; assert sys.version_info[:2] >= (3, 11), \
|
||||
'the runner python is too old for the pinned requirements'"
|
||||
|
||||
- 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:
|
||||
name: Build and push image
|
||||
runs-on: ubuntu-latest
|
||||
container: catthehacker/ubuntu:act-latest
|
||||
needs: test
|
||||
if: gitea.ref == 'refs/heads/master'
|
||||
# A build that has not finished in 15 minutes is wedged — the qemu
|
||||
# multi-arch crash used to hang the buildkit pod for exactly this long.
|
||||
# A timeout stops it occupying a runner indefinitely.
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Kubeconfig
|
||||
run: |
|
||||
mkdir $HOME/.kube
|
||||
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: kubernetes
|
||||
driver-opts: |
|
||||
namespace=gitea-runner
|
||||
qemu.install=true
|
||||
# The `kubernetes` driver talks to the cluster API, which needs the
|
||||
# k3s CA certificate. The job container does not have it, so the
|
||||
# bootstrap failed with "x509: certificate signed by unknown
|
||||
# authority".
|
||||
#
|
||||
# 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
|
||||
uses: docker/login-action@v3
|
||||
@@ -38,7 +98,10 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
file: base.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# amd64 only. A multi-arch build runs qemu emulation, which crashes
|
||||
# the buildkit pod on this NUC (exit 255, SandboxChanged) and the
|
||||
# build then hangs for many minutes. The cluster is amd64.
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
git.aridgwayweb.com/armistace/beer_base_image:latest
|
||||
|
||||
@@ -48,6 +111,123 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
file: flask.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
tags: |
|
||||
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
|
||||
# `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: |
|
||||
mkdir -p $HOME/.kube
|
||||
# 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 install -y apt-transport-https ca-certificates curl gnupg
|
||||
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
|
||||
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
|
||||
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
|
||||
apt-get update
|
||||
apt-get install -y kubectl
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
# Apply the namespace and the non-secret manifests. The Secrets
|
||||
# (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-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`.
|
||||
# `kubectl set image` writes the SHA into the pod template, which
|
||||
# makes the change a real manifest change - the Deployment rolls
|
||||
# because its pod template changed, not because it was told to.
|
||||
# That also makes `kubectl rollout undo` meaningful: it can go back
|
||||
# to the previous SHA.
|
||||
kubectl -n beer set image deployment/beer-deployment \
|
||||
beer-data=git.aridgwayweb.com/armistace/beer-data:${{ github.sha }}
|
||||
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||
|
||||
- name: Verify the new image is actually running
|
||||
run: |
|
||||
# CI green and pods Ready do NOT prove the new code is live. Compare
|
||||
# the running digest against the registry's :latest.
|
||||
RUNNING=$(kubectl -n beer get pod -l app=beer-data \
|
||||
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}')
|
||||
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 -- \
|
||||
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)"
|
||||
|
||||
@@ -1,7 +1,156 @@
|
||||
# BEER SG data storer
|
||||
|
||||
This container will store SG data for beer runs in Mongo and then use that data to calculate Alchohol Content of each beer
|
||||
|
||||
It's pretty loose and requires the Data Entry operator to know whats happening
|
||||
|
||||
Luck for me... That's me!
|
||||
# Brew Log
|
||||
|
||||
Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into
|
||||
useful figures — alcohol by volume, days in the fermenter, gravity trends.
|
||||
|
||||
Flask app, Mongo backend, no build step for the front end.
|
||||
|
||||
## Running it locally
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
export MONGO_HOST=192.168.178.229
|
||||
export MONGO_USER=root
|
||||
export MONGO_PASS=...
|
||||
export SECRET_KEY=anything-for-local-use
|
||||
|
||||
cd src/flask
|
||||
python -m beer_data # dev server on :5000
|
||||
# or, production-style:
|
||||
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app
|
||||
```
|
||||
|
||||
Then open <http://localhost:5000/> and sign in.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
The tests use `mongomock`, so they need no database and no network.
|
||||
|
||||
## The pages
|
||||
|
||||
| Page | What it is for |
|
||||
|---|---|
|
||||
| `/login` | Sign in |
|
||||
| `/brews` | The brew process: record a reading, review every run |
|
||||
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
|
||||
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
|
||||
|
||||
### `/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
|
||||
|
||||
```
|
||||
src/flask/
|
||||
beer_data.py entry point; exposes `app` and `create_app`
|
||||
app_factory.py application factory, request-scoped Mongo repository
|
||||
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
||||
views/ blueprints: auth, brews, analysis
|
||||
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
||||
templates/ Jinja templates
|
||||
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
||||
static/ CSS, JS, fonts, logo (all served locally - no CDN)
|
||||
tests/ pytest suite
|
||||
scripts/ operational scripts (the run-metadata backfill)
|
||||
```
|
||||
|
||||
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
||||
dependency, so the maths is unit-testable on its own.
|
||||
|
||||
## Data model
|
||||
|
||||
### `beer_data` — one document per gravity reading
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `_id` | string | 24 random characters |
|
||||
| `beer_run_id` | string | The run this reading belongs to, e.g. `"20"` |
|
||||
| `beer_type` | string | Beer/cider name. **Free text, per reading** — it drifts across a run |
|
||||
| `sg` | string | Specific gravity in *points*: `1039` means 1.039 |
|
||||
| `date` | string | `YYYY-MM-DD` |
|
||||
| `final_reading` | string | `"True"` / `"False"` — marks the last reading of a run |
|
||||
| `comment` | string | Free text |
|
||||
|
||||
The unique key is `(date, beer_run_id)`: re-submitting a reading for the same
|
||||
run and day updates it in place rather than creating a duplicate.
|
||||
|
||||
A run is **active** while it has no reading with `final_reading == "True"`.
|
||||
|
||||
### `beer_runs` — one document per run (metadata)
|
||||
|
||||
Added alongside the readings to hold what cannot be derived from them: the
|
||||
canonical beer name, the tap number, the recorded original gravity, and brew
|
||||
notes. This collection is only ever inserted into, never written over by the
|
||||
backfill.
|
||||
|
||||
## Alcohol by volume
|
||||
|
||||
Standard homebrew formula:
|
||||
|
||||
```
|
||||
ABV% = (OG - FG) × 131.25
|
||||
```
|
||||
|
||||
With gravity stored in points, the drop is already in points, so
|
||||
`ABV% = points_drop × 0.13125`.
|
||||
|
||||
> The previous version of this app used `(max - min) / 7.36 + 0.5`, which is not
|
||||
> a recognised brewing formula and overstated every result. The old figure is
|
||||
> still computed by `brewing.legacy_abv_percent()` so the history page can show
|
||||
> the difference between the two.
|
||||
|
||||
For a run still in the fermenter the figure is an **estimate**: it uses the
|
||||
run's first reading as the original gravity. A run with a single reading and no
|
||||
recorded OG has no reliable estimate, and the UI shows a dash rather than a
|
||||
misleading number.
|
||||
|
||||
## Deployment
|
||||
|
||||
The image is built and pushed by `.gitea/workflows/build_push.yml` on push to
|
||||
`master`. See [RUNBOOK.md](RUNBOOK.md) for the cluster side.
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# RUNBOOK — Brew Log (beer_data)
|
||||
|
||||
Operational notes for the `beer` namespace deployment.
|
||||
|
||||
## What runs where
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Namespace | `beer` |
|
||||
| Deployment | `beer-deployment` (3 replicas) |
|
||||
| Image | `git.aridgwayweb.com/armistace/beer-data:latest` |
|
||||
| Service | `beer-service`, NodePort **30015** → container port 5000 |
|
||||
| Database | MongoDB at `192.168.178.229`, database `beer_db` |
|
||||
| Secret | `beercred` (registry pull credentials) |
|
||||
|
||||
Access: `http://<any-cluster-node>:30015/`
|
||||
|
||||
The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Value | Source |
|
||||
|---|---|---|
|
||||
| `MONGO_HOST` | `192.168.178.229` | Deployment manifest |
|
||||
| `MONGO_USER` | `root` | Deployment manifest |
|
||||
| `MONGO_PASS` | *(see below)* | Deployment manifest |
|
||||
| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key. **Required** |
|
||||
| `FLASK_ENV` | `production` | Dockerfile |
|
||||
| `FLASK_DEBUG` | `0` | Dockerfile |
|
||||
|
||||
`SECRET_KEY` and `MONGO_PASS` must be set in the live Deployment. They are
|
||||
**not** committed to this repo — read them from the running cluster:
|
||||
|
||||
```bash
|
||||
kubectl -n beer get deploy beer-deployment -o jsonpath='{.spec.template.spec.containers[0].env}'
|
||||
```
|
||||
|
||||
> **The app refuses to start without a real `SECRET_KEY`.** There is no default
|
||||
> and no placeholder: a missing or placeholder value raises
|
||||
> `ImproperlyConfigured` and the container exits. A known session-signing key
|
||||
> would let anyone forge a login cookie, so a misconfigured deployment must
|
||||
> fail loudly rather than run insecurely.
|
||||
>
|
||||
> Generate one with:
|
||||
> ```bash
|
||||
> python -c 'import secrets; print(secrets.token_hex(32))'
|
||||
> ```
|
||||
|
||||
### Moving the credentials into a Kubernetes Secret
|
||||
|
||||
The live Deployment currently carries `MONGO_PASS` as a plain environment
|
||||
value. Moving it to a Secret is the better shape and is **not** done here
|
||||
because it would mean replacing the live hand-provisioned values — a change
|
||||
that needs the operator's sign-off. When it is done:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: MONGO_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-db
|
||||
key: mongo_pass
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-app
|
||||
key: secret_key
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the
|
||||
test suite and then builds and pushes the image. **The workflow does not
|
||||
currently deploy to the cluster** — apply the manifests yourself:
|
||||
|
||||
```bash
|
||||
kubectl -n beer rollout restart deployment/beer-deployment
|
||||
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||
```
|
||||
|
||||
### Verify the deploy actually took
|
||||
|
||||
CI green and pods `Ready` do **not** prove the new code is running. Check the
|
||||
running digest against the registry:
|
||||
|
||||
```bash
|
||||
# what the pod is running
|
||||
kubectl -n beer get pod -l app=beer-data \
|
||||
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}'
|
||||
|
||||
# what the registry has as :latest
|
||||
curl -s -u "armistace:$REG_PASSWORD" -I \
|
||||
"https://git.aridgwayweb.com/v2/armistace/beer-data/manifests/latest" \
|
||||
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
|
||||
| grep -i docker-content-digest
|
||||
```
|
||||
|
||||
If they differ, the registry `:latest` is stale — re-run the build rather than
|
||||
re-applying the manifest.
|
||||
|
||||
### Check the app is alive
|
||||
|
||||
```bash
|
||||
kubectl -n beer get pods
|
||||
kubectl -n beer logs deploy/beer-deployment --tail=50
|
||||
curl -s http://<node>:30015/healthz # -> {"status":"ok"}
|
||||
curl -s http://<node>:30015/readyz # -> {"status":"ready"}
|
||||
```
|
||||
|
||||
`/healthz` is the **liveness** probe and never touches Mongo — it answers "is
|
||||
the process up". A database outage must not make the kubelet restart a healthy
|
||||
process.
|
||||
|
||||
`/readyz` is the **readiness** probe and pings Mongo, returning 503 when the
|
||||
database is unreachable. Wire it to the Service so a pod with no database is
|
||||
pulled out of the endpoints rather than serving errors:
|
||||
|
||||
```yaml
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: 5000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 5000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
```
|
||||
|
||||
## The run metadata collection
|
||||
|
||||
The `beer_runs` collection holds one document per brew run with the details
|
||||
that cannot be derived from the readings: the canonical name, the tap number,
|
||||
the recorded original gravity and the brew notes.
|
||||
|
||||
It starts empty on a fresh install. Populate it from the existing readings:
|
||||
|
||||
```bash
|
||||
# from the app's source tree, with the usual env vars set
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
```
|
||||
|
||||
The script is idempotent and only ever inserts into `beer_runs`. It does not
|
||||
touch `beer_data`.
|
||||
|
||||
To see what is in it:
|
||||
|
||||
```bash
|
||||
mongosh "mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
|
||||
--eval 'db.beer_runs.find().pretty()'
|
||||
```
|
||||
|
||||
## Data safety
|
||||
|
||||
The `beer_db.beer_data` collection holds the actual brewing records. There is
|
||||
no migration step and no schema version — documents are read as they are
|
||||
written, and every field is treated as optional.
|
||||
|
||||
To take a backup before any risky change:
|
||||
|
||||
```bash
|
||||
mongodump --uri="mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
|
||||
--out=/tmp/beer_db_backup_$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
## Common problems
|
||||
|
||||
### The login page loads but nothing else does
|
||||
|
||||
The Mongo connection failed. Check the app logs for an auth error and confirm
|
||||
the credentials:
|
||||
|
||||
```bash
|
||||
kubectl -n beer logs deploy/beer-deployment --tail=100 | grep -i mongo
|
||||
```
|
||||
|
||||
### A run shows a negative alcohol figure
|
||||
|
||||
That was the old formula (`(max - min) / 7.36 + 0.5`) applied to a run whose
|
||||
current gravity is above the assumed original gravity of 1012. Fixed in the
|
||||
current version: the standard `(OG - FG) × 131.25` formula is used, and a run
|
||||
with no known OG shows a dash instead of a figure.
|
||||
|
||||
### The pod will not start after a deploy
|
||||
|
||||
The most likely cause is a missing `SECRET_KEY` — the app exits rather than
|
||||
run with a default. Check the logs:
|
||||
|
||||
```bash
|
||||
kubectl -n beer logs deploy/beer-deployment --tail=20
|
||||
# ImproperlyConfigured: SECRET_KEY is not set. ...
|
||||
```
|
||||
|
||||
### The chart on the run page is blank
|
||||
|
||||
The chart library is served from `/static/js/chart.umd.js` — it is vendored in
|
||||
the repo, not loaded from a CDN, so the container works with no outbound
|
||||
network. Check the browser console for a 404 on that path.
|
||||
+19
-4
@@ -1,11 +1,26 @@
|
||||
FROM python:3.10 AS beer_base_image
|
||||
FROM python:3.14.10-slim AS beer_base_image
|
||||
|
||||
WORKDIR /beer_data
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y libsasl2-dev python-dev-is-python3 libldap2-dev libssl-dev
|
||||
# The runtime dependencies (flask, flask-wtf, waitress, pymongo, requests) all
|
||||
# ship as wheels for 3.14, so no compiler toolchain is needed. The old image
|
||||
# installed libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev
|
||||
# for packages that are no longer in the dependency list.
|
||||
#
|
||||
# The Python version matters:
|
||||
# - pymongo 4.19 requires Python >= 3.11, so the base image could not stay
|
||||
# on 3.10 once the requirements were pinned.
|
||||
# - 3.14 is a stable release (first released 2025-10-07, in its bugfix
|
||||
# phase until 2030-10), so the image is not carrying the CVEs fixed in
|
||||
# 3.11, 3.12 and 3.13.
|
||||
# - The patch version is pinned rather than the floating `3.14-slim` tag so
|
||||
# a rebuild is reproducible and does not silently pick up a base image
|
||||
# change.
|
||||
RUN pip install --upgrade pip --no-cache-dir
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip --default-timeout=1000 install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN pip --default-timeout=1000 install -r requirements.txt
|
||||
# Fail the build if the interpreter is not the one the image claims.
|
||||
RUN python -c "import sys; assert sys.version_info[:2] == (3, 14), sys.version"
|
||||
|
||||
+25
-5
@@ -1,14 +1,34 @@
|
||||
FROM git.aridgwayweb.com/armistace/beer_base_image AS flask
|
||||
#FROM beer_base_image AS flask
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
ADD src /beer_data/src
|
||||
|
||||
ENV FLASK_ENV production
|
||||
ENV FLASK_DEBUG 1
|
||||
# The app lives in src/flask and imports its siblings by bare name
|
||||
# (``import brewing``), so that directory is the working directory.
|
||||
WORKDIR /beer_data/src/flask
|
||||
|
||||
# Run as an unprivileged user. The app only needs to read its own source
|
||||
# and talk to Mongo over the network — it writes no files — so it does not
|
||||
# need to be root.
|
||||
RUN useradd --system --create-home --home-dir /home/beer --shell /usr/sbin/nologin beer \
|
||||
&& chown -R beer:beer /beer_data
|
||||
|
||||
ENTRYPOINT ["flask", "--app", "/beer_data/src/flask/beer_data", "run", "--host=0.0.0.0"]
|
||||
# The app is served by waitress, not the Flask development server.
|
||||
# FLASK_DEBUG stays OFF: the old entrypoint ran the Werkzeug debugger in
|
||||
# production, which exposes an interactive console on any unhandled error.
|
||||
ENV FLASK_ENV=production
|
||||
ENV FLASK_DEBUG=0
|
||||
ENV PORT=5000
|
||||
|
||||
#ENTRYPOINT ["python", "/pool_data/src/flask/pool_data.py"]
|
||||
# SECRET_KEY MUST be supplied by the deployment (see RUNBOOK.md). There is
|
||||
# deliberately no default: the app refuses to start without a real one, so a
|
||||
# misconfigured deployment fails loudly instead of running with a known
|
||||
# session-signing key.
|
||||
# ENV SECRET_KEY=...
|
||||
|
||||
USER beer
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENTRYPOINT ["python", "-m", "waitress", "--listen=0.0.0.0:5000", "--call", "beer_data:create_app"]
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: beer-deployment
|
||||
namespace: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: beer-data
|
||||
strategy:
|
||||
rollingUpdate:
|
||||
maxSurge: 25%
|
||||
maxUnavailable: 25%
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
containers:
|
||||
- name: beer-data
|
||||
# The image tag is pinned to the commit SHA by CI (`kubectl set
|
||||
# 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
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: MONGO_HOST
|
||||
value: "192.168.178.229"
|
||||
- name: MONGO_USER
|
||||
value: "root"
|
||||
# MONGO_PASS and SECRET_KEY come from the live Secret - they are
|
||||
# deliberately NOT in this file. See RUNBOOK.md.
|
||||
- name: MONGO_PASS
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-db
|
||||
key: mongo_pass
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: beer-app
|
||||
key: secret_key
|
||||
resources:
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- 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:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: 5000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 20
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: 5000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
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:
|
||||
kubernetes.io/hostname: archlinux-k3s-3
|
||||
imagePullSecrets:
|
||||
- name: beercred
|
||||
terminationGracePeriodSeconds: 30
|
||||
restartPolicy: Always
|
||||
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: beer-service
|
||||
namespace: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
spec:
|
||||
type: NodePort
|
||||
selector:
|
||||
app: beer-data
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 5000
|
||||
nodePort: 30015
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: beer
|
||||
labels:
|
||||
app: beer-data
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==9.2.0
|
||||
mongomock==4.3.0
|
||||
+8
-11
@@ -1,11 +1,8 @@
|
||||
pymongo[srv]
|
||||
flask
|
||||
requests_html
|
||||
beautifulsoup4
|
||||
click
|
||||
Flask-WTF
|
||||
bootstrap-flask
|
||||
waitress
|
||||
bokeh
|
||||
pandas
|
||||
duckdb
|
||||
# Pinned to the versions the test suite runs against. The image is rebuilt
|
||||
# on every merge, so an unpinned dependency can change behaviour underneath a
|
||||
# deploy with no commit to show for it.
|
||||
Flask==3.1.3
|
||||
Flask-WTF==1.3.0
|
||||
waitress==3.0.2
|
||||
pymongo==4.19.0
|
||||
requests==2.32.5
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Backfill the ``beer_runs`` metadata collection from the existing readings.
|
||||
|
||||
The ``beer_data`` collection stores a beer name on *each reading*, and that
|
||||
name drifts across a run — run 4 was entered as "Necatron-Christmas Beer",
|
||||
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". The
|
||||
``beer_runs`` collection holds one canonical name per run, which is what the
|
||||
active-beers page and the analysis pages use.
|
||||
|
||||
This script populates that collection from the readings. It is **idempotent**
|
||||
and **non-destructive**:
|
||||
|
||||
- It only ever inserts into ``beer_runs``. It never touches ``beer_data``.
|
||||
- It never overwrites a name you have already set by hand. A run whose
|
||||
metadata already carries a ``name`` is left alone unless ``--force`` is
|
||||
given.
|
||||
- Re-running it produces the same result.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python scripts/backfill_run_metadata.py # dry run
|
||||
python scripts/backfill_run_metadata.py --apply # write it
|
||||
python scripts/backfill_run_metadata.py --apply --force # overwrite names
|
||||
|
||||
The connection is taken from the same environment variables the app uses:
|
||||
``MONGO_HOST``, ``MONGO_USER``, ``MONGO_PASS`` (or ``MONGO_URI``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask"),
|
||||
)
|
||||
|
||||
from brewing import Run # noqa: E402
|
||||
from mongo.repository import BeerRepository # noqa: E402
|
||||
|
||||
|
||||
def build_plan(repo: BeerRepository) -> list[dict]:
|
||||
"""Work out what should be in ``beer_runs``, one entry per run."""
|
||||
existing = repo.all_run_metadata()
|
||||
plan = []
|
||||
|
||||
for run in repo.runs():
|
||||
metadata = existing.get(run.beer_run_id, {})
|
||||
canonical = metadata.get("name") or run.display_name()
|
||||
og = metadata.get("og_points") or run.original_gravity()
|
||||
|
||||
plan.append({
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": canonical,
|
||||
"og_points": og,
|
||||
"tap_number": metadata.get("tap_number"),
|
||||
"notes": metadata.get("notes", ""),
|
||||
"readings": len(run.readings),
|
||||
"active": run.is_active(),
|
||||
"existing_name": metadata.get("name"),
|
||||
"would_change": not metadata.get("name"),
|
||||
})
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true",
|
||||
help="write the changes (default is a dry run)")
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="overwrite names that have already been set")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
plan = build_plan(repo)
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
if not plan:
|
||||
print("No runs found in the readings collection — nothing to do.")
|
||||
return 0
|
||||
|
||||
print(f"{'run':>5} {'name':<38} {'OG':>5} {'readings':>8} state")
|
||||
print("-" * 78)
|
||||
for entry in plan:
|
||||
state = "active" if entry["active"] else "finished"
|
||||
marker = " " if entry["would_change"] else "*"
|
||||
print(f"{entry['beer_run_id']:>5} {entry['name'][:38]:<38} "
|
||||
f"{entry['og_points'] or '-':>5} {entry['readings']:>8} {state}{marker}")
|
||||
|
||||
changed = [e for e in plan if e["would_change"] or args.force]
|
||||
kept = [e for e in plan if not e["would_change"] and not args.force]
|
||||
|
||||
print()
|
||||
print(f"{len(changed)} run(s) would be written, {len(kept)} kept as they are.")
|
||||
if kept:
|
||||
print("(* already has a name set by hand — use --force to overwrite)")
|
||||
print(f"{'APPLYING' if args.apply else 'DRY RUN — nothing written'}")
|
||||
print()
|
||||
|
||||
if not args.apply:
|
||||
print("Re-run with --apply to write these.")
|
||||
return 0
|
||||
|
||||
repo = BeerRepository()
|
||||
try:
|
||||
for entry in changed:
|
||||
repo.upsert_run_metadata_partial(
|
||||
entry["beer_run_id"],
|
||||
name=entry["name"],
|
||||
og_points=entry["og_points"],
|
||||
tap_number=entry["tap_number"],
|
||||
notes=entry["notes"] or None,
|
||||
)
|
||||
print(f" wrote run {entry['beer_run_id']}: {entry['name']}")
|
||||
finally:
|
||||
repo.client.close()
|
||||
|
||||
print(f"\nDone — {len(changed)} run(s) written to the beer_runs collection.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
#!/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
|
||||
@@ -1,29 +0,0 @@
|
||||
import os
|
||||
import click
|
||||
|
||||
import mongo.user_db as user_db
|
||||
|
||||
@click.command()
|
||||
@click.option('--username', type=str, help="Username to be added or updated")
|
||||
@click.option('--password', type=str, help="Password to be added or updated")
|
||||
|
||||
def main(username, password):
|
||||
"""
|
||||
little cli program to update the
|
||||
user table in mongo
|
||||
this rightly should eventually be
|
||||
and admin tool but right now this will dow
|
||||
"""
|
||||
user_collection = user_db.user_data()
|
||||
new_record = {
|
||||
"username" : f"{username}",
|
||||
"password" : f"{password}"
|
||||
}
|
||||
if user_collection.user_exists(new_record["username"]):
|
||||
user_collection.update_user(user_collection.existing_record["_id"], new_record["password"])
|
||||
else:
|
||||
user_collection.add_user(new_record["username"], new_record["password"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Application factory for the beer_data web app.
|
||||
|
||||
The application is deliberately small: a handful of pages, one Mongo database,
|
||||
no build step for the front end. Everything server-side lives in blueprints
|
||||
under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (Flask, g, redirect, request, session, url_for)
|
||||
from flask_wtf import CSRFProtect
|
||||
|
||||
__all__ = ["create_app"]
|
||||
|
||||
|
||||
#: The Content-Security-Policy the app sends with every response.
|
||||
#:
|
||||
#: The app serves only its own vendored assets — CSS, JavaScript, fonts and
|
||||
#: the logo are all in ``static/`` — so a strict ``'self'`` policy is
|
||||
#: enforceable. There is no ``'unsafe-inline'`` anywhere, which is why the
|
||||
#: templates carry no inline scripts or style attributes.
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"object-src 'none'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'"
|
||||
)
|
||||
|
||||
|
||||
class ImproperlyConfigured(RuntimeError):
|
||||
"""Raised when the app is missing configuration it cannot safely default.
|
||||
|
||||
Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never
|
||||
exported one, so the app defines its own. It is a ``RuntimeError`` so it
|
||||
is not mistaken for an HTTP error the app should try to render a page for.
|
||||
"""
|
||||
|
||||
|
||||
def _require_secret_key() -> str:
|
||||
"""Return the Flask secret key, refusing to start without a real one.
|
||||
|
||||
A known default secret key means anyone can forge a session cookie and
|
||||
log in as the operator, so a missing or placeholder value must stop the
|
||||
app rather than silently fall back. The one exception is the test
|
||||
suite, which supplies its own key through the config.
|
||||
"""
|
||||
secret = os.getenv("SECRET_KEY", "").strip()
|
||||
if not secret:
|
||||
raise ImproperlyConfigured(
|
||||
"SECRET_KEY is not set. Export a random value, e.g. "
|
||||
"`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`."
|
||||
)
|
||||
if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}:
|
||||
raise ImproperlyConfigured(
|
||||
"SECRET_KEY is still the placeholder value. Set a real secret key."
|
||||
)
|
||||
return secret
|
||||
|
||||
|
||||
def create_app(config: dict | None = None) -> Flask:
|
||||
"""Build and configure the Flask application."""
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static",
|
||||
)
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=_require_secret_key(),
|
||||
MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"),
|
||||
MONGO_USER=os.getenv("MONGO_USER", "root"),
|
||||
MONGO_PASS=os.getenv("MONGO_PASS", ""),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
# Flask-WTF needs this for the login form to render and validate.
|
||||
WTF_CSRF_ENABLED=True,
|
||||
)
|
||||
if config:
|
||||
app.config.update(config)
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Request-scoped repository
|
||||
# ------------------------------------------------------------------ #
|
||||
from mongo.repository import BeerRepository
|
||||
|
||||
def get_repo() -> BeerRepository:
|
||||
"""One Mongo client per request, closed when the request ends."""
|
||||
if "repo" not in g:
|
||||
# A client can be injected via config (used by the test suite);
|
||||
# otherwise build one from the environment.
|
||||
injected = app.config.get("MONGO_CLIENT")
|
||||
g.repo = BeerRepository(client=injected) if injected else BeerRepository()
|
||||
return g.repo
|
||||
|
||||
app.extensions["beer_repo"] = get_repo
|
||||
|
||||
@app.teardown_appcontext
|
||||
def _close_repo(_exc=None):
|
||||
repo = g.pop("repo", None)
|
||||
# An injected client (the test suite) is owned by the caller — do not
|
||||
# close it, or the next request in the same test gets a dead client.
|
||||
if repo is not None and app.config.get("MONGO_CLIENT") is None:
|
||||
repo.client.close()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Auth helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
def logged_in() -> bool:
|
||||
return bool(session.get("logged_in"))
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {
|
||||
"logged_in": logged_in(),
|
||||
"current_path": request.path,
|
||||
"app_name": "Brew Log",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Error handlers
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.after_request
|
||||
def _set_security_headers(response):
|
||||
"""Add the standard security headers.
|
||||
|
||||
The app serves its own vendored JavaScript and CSS and loads nothing
|
||||
from a CDN, so a strict Content-Security-Policy of 'self' is enough
|
||||
and is genuinely enforceable.
|
||||
"""
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault("Content-Security-Policy", CSP)
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
def _not_found(_exc):
|
||||
return render_template("error.html", code=404,
|
||||
message="That page does not exist."), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def _server_error(_exc): # pragma: no cover - defensive
|
||||
return render_template("error.html", code=500,
|
||||
message="Something broke on our side."), 500
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Blueprints
|
||||
# ------------------------------------------------------------------ #
|
||||
from views.analysis import bp as analysis_bp
|
||||
from views.auth import bp as auth_bp
|
||||
from views.brews import bp as brews_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(brews_bp)
|
||||
app.register_blueprint(analysis_bp)
|
||||
|
||||
@app.route("/healthz")
|
||||
def healthz():
|
||||
"""Liveness probe. Never touches Mongo — it answers "is the process
|
||||
up", not "is the app working". A database outage must not cause the
|
||||
kubelet to restart a perfectly healthy process."""
|
||||
return {"status": "ok"}, 200
|
||||
|
||||
@app.route("/readyz")
|
||||
def readyz():
|
||||
"""Readiness probe. Checks that Mongo is actually reachable, so a
|
||||
pod with no database is taken out of the Service's endpoints instead
|
||||
of serving errors."""
|
||||
try:
|
||||
repo = get_repo()
|
||||
repo.client.admin.command("ping")
|
||||
except Exception as exc: # noqa: BLE001 - any failure means not ready
|
||||
return {"status": "not ready", "error": str(exc)}, 503
|
||||
return {"status": "ready"}, 200
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
if logged_in():
|
||||
return redirect(url_for("brews.index"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def render_template(*args, **kwargs): # noqa: D401 - re-export for views
|
||||
"""Small shim so view modules can import ``render_template`` from one place."""
|
||||
from flask import render_template as _rt
|
||||
|
||||
return _rt(*args, **kwargs)
|
||||
+28
-135
@@ -1,140 +1,33 @@
|
||||
from bokeh.core.enums import SpatialUnitsType
|
||||
import mongo.build_db as beer_database
|
||||
import mongo.query_db as beer_database_query
|
||||
from table import table_builder
|
||||
"""Entry point for the beer_data web application.
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, redirect, session
|
||||
from flask_wtf import FlaskForm, CSRFProtect
|
||||
from flask_bootstrap import Bootstrap5
|
||||
from wtforms import StringField, SubmitField, DateField, IntegerField, PasswordField, DecimalField, RadioField, TextAreaField, BooleanField
|
||||
from wtforms.validators import DataRequired, Length, Optional
|
||||
from waitress import serve
|
||||
from bokeh.models.layouts import HBox
|
||||
from bokeh.plotting import column
|
||||
from charts import BeerCharts
|
||||
from bokeh.io import output_file, show
|
||||
from bokeh.layouts import row
|
||||
Run in development:
|
||||
|
||||
python -m beer_data
|
||||
|
||||
Run in production (what the container does):
|
||||
|
||||
waitress-serve --listen=0.0.0.0:5000 beer_data:app
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app_factory import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
# The WSGI callable used by waitress / gunicorn.
|
||||
application = app
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'testsecret' #this value will change
|
||||
|
||||
bootstrap = Bootstrap5(app)
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
# used to configure the bokeh plot for graphs
|
||||
output_file("/beer_data/src/flask/static/data_plot.html")
|
||||
|
||||
def create_graphs():
|
||||
chart = BeerCharts.BeerCharts()
|
||||
sg = chart.line_chart("SG", "sg", 30)
|
||||
show(column(sg))
|
||||
|
||||
def data_frame_to_table(df) -> str:
|
||||
|
||||
tr_replace_string = '<tr align="center" style="border-bottom:1pt solid black;">'
|
||||
return_html = df.to_html(col_space='75px', index=False,
|
||||
justify='center', border=3).replace('<tr>', tr_replace_string)
|
||||
return return_html
|
||||
|
||||
class userForm(FlaskForm):
|
||||
username = StringField("User Name?", validators=[DataRequired()])
|
||||
password = PasswordField("Password?")
|
||||
submit = SubmitField("Letsa GO!")
|
||||
|
||||
class dataForm(FlaskForm):
|
||||
beer_run_id = IntegerField("Beer Run ID")
|
||||
beer_run_type = TextAreaField("Beer Type")
|
||||
Date = DateField("Date:")
|
||||
sg = IntegerField("SG Reading")
|
||||
final_run = BooleanField("Final Reading?")
|
||||
comment = TextAreaField("Any Comments?", validators=[Optional()])
|
||||
submit = SubmitField("Write it, Write it REAAAAAAL GOOOD")
|
||||
|
||||
@app.route("/", methods=["GET","POST"])
|
||||
def index():
|
||||
form = userForm()
|
||||
if form.validate_on_submit():
|
||||
username = form.username.data
|
||||
password = form.password.data
|
||||
db = beer_database_query.pool_query()
|
||||
if db.user_check(username, password):
|
||||
session['logged_in'] = True
|
||||
return redirect("/updater")
|
||||
else:
|
||||
return render_template("index.html", try_again=True, form=form)
|
||||
else:
|
||||
return render_template("index.html", try_again=False, form=form)
|
||||
|
||||
@app.route("/updater", methods=["GET", "POST"])
|
||||
def updater():
|
||||
if 'logged_in' not in session:
|
||||
return redirect("/")
|
||||
table_Data = table_builder.TableBuilder()
|
||||
predicted_alc_table = table_Data.done_runs_build()
|
||||
current_runs_table = table_Data.current_runs_build()
|
||||
|
||||
beer_html = data_frame_to_table(predicted_alc_table)
|
||||
current_html = data_frame_to_table(current_runs_table)
|
||||
|
||||
query_db = beer_database_query.pool_query()
|
||||
query = query_db.get_top(10, "sg")
|
||||
form = dataForm()
|
||||
def main() -> None:
|
||||
"""Run the development server."""
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", "5000"))
|
||||
debug = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
|
||||
if form.validate_on_submit():
|
||||
database = beer_database.beer_data()
|
||||
new_record = {
|
||||
"date": f'{form.Date.data}',
|
||||
"beer_run_id": f'{form.beer_run_id.data}',
|
||||
"beer_type": f'{form.beer_run_type.data}',
|
||||
"sg" : f'{form.sg.data}',
|
||||
"final_reading" : F'{form.final_run.data}',
|
||||
"comment": f'{form.comment.data}'
|
||||
}
|
||||
if database.record_exists(new_record["date"], new_record["beer_run_id"]):
|
||||
for field in database.existing_record:
|
||||
for new_field in new_record:
|
||||
if field == new_field:
|
||||
if database.existing_record[field] != new_record[field]:
|
||||
database.update_re_record(database.existing_record["_id"], field, new_record[field])
|
||||
else:
|
||||
if new_record["final_reading"] == "True":
|
||||
final_run_value = True
|
||||
else:
|
||||
final_run_value = False
|
||||
database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"],
|
||||
new_record["date"], final_run_value, new_record["comment"])
|
||||
|
||||
return render_template("updater.html", beer_data = beer_html, current_data = current_html
|
||||
, list=query, form=form, success=True, updater_name = "Saucy Beer Maker")
|
||||
else:
|
||||
return render_template("updater.html", beer_data = beer_html, current_data = current_html
|
||||
, list=query, form=form, sucess=False)
|
||||
|
||||
# @app.route("/update_db", methods=["POST"])
|
||||
# def beer_data_update():
|
||||
# database = beer_database.beer_data()
|
||||
# new_record = request.json
|
||||
# if database.record_exists(new_record["date"], new_record["test_user"]):
|
||||
# for field in database.existing_record:
|
||||
# for new_field in new_record:
|
||||
# if field == new_field:
|
||||
# if database.existing_record[field] != new_record[field]:
|
||||
# database.update_re_record(database.existing_record["_id"], field, new_record[field])
|
||||
# else:
|
||||
# database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"],
|
||||
# new_record["date"], new_record["comment"])
|
||||
|
||||
|
||||
|
||||
# @app.route("/pool_top/<int:return_number>/<string:field>")
|
||||
# def user_detail(id):
|
||||
# query_db = beer_database_query.pool_query()
|
||||
# query = query_db.get_top(return_number, field)
|
||||
# return jsonify([row.to_json() for row in query])
|
||||
|
||||
if __name__ == '__main__':
|
||||
#app.run(host='0.0.0.0')
|
||||
serve(app, host='0.0.0.0', port=5000, url_scheme='https')
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Pure brewing logic for the beer_data app.
|
||||
|
||||
Everything in this module is a pure function over plain data (no Flask, no Mongo,
|
||||
no pandas) so the maths can be unit-tested without a database.
|
||||
|
||||
Gravity convention
|
||||
------------------
|
||||
The Mongo collection stores specific gravity as integer *points*: the digits after
|
||||
the decimal point of the usual 1.0xx reading. ``1039`` means 1.039. This matches
|
||||
how the readings were entered and keeps the stored data unchanged.
|
||||
|
||||
Alcohol by volume
|
||||
-----------------
|
||||
Standard homebrew formula (Charlie Papazian / "The Joy of Homebrewing"):
|
||||
|
||||
ABV% = (OG - FG) * 131.25
|
||||
|
||||
With gravities expressed in points the difference is directly in points, so:
|
||||
|
||||
ABV% = points_drop * 0.13125
|
||||
|
||||
The legacy application used ``(max - min) / 7.36 + 0.5`` which is not a recognised
|
||||
formula and overstates the result (it also returns 0.5% for a run with no drop at
|
||||
all). :func:`legacy_abv_percent` keeps the old maths available so the history page
|
||||
can show the difference between the two, but new code uses :func:`abv_percent`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
__all__ = [
|
||||
"ABV_FACTOR",
|
||||
"ASSUMED_ORIGINAL_GRAVITY",
|
||||
"Reading",
|
||||
"Run",
|
||||
"abv_percent",
|
||||
"assumed_og_abv_percent",
|
||||
"days_between",
|
||||
"final_reading",
|
||||
"first_reading",
|
||||
"gravity_drop_points",
|
||||
"is_active",
|
||||
"is_plausible_sg",
|
||||
"last_reading",
|
||||
"legacy_abv_percent",
|
||||
"parse_date",
|
||||
"parse_sg_points",
|
||||
"run_summary",
|
||||
"sg_points_to_decimal",
|
||||
]
|
||||
|
||||
#: Conversion factor for points-drop -> percent alcohol (131.25 / 1000).
|
||||
ABV_FACTOR = 0.13125
|
||||
|
||||
#: Original gravity assumed for an in-progress run when no OG was recorded.
|
||||
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
||||
ASSUMED_ORIGINAL_GRAVITY = 1012
|
||||
|
||||
#: Plausible range for a hydrometer reading, in points. The upper bound is
|
||||
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
|
||||
#: still catching typos such as 900 or 1500.
|
||||
_MIN_PLAUSIBLE_SG = 990
|
||||
_MAX_PLAUSIBLE_SG = 1200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parsing helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def parse_sg_points(raw: object) -> int | None:
|
||||
"""Return the gravity in points for *raw*, or ``None`` if it is not usable.
|
||||
|
||||
Accepts ints, floats and strings. ``"1039"``, ``1039`` and ``1039.0`` all
|
||||
become ``1039``. Values like ``"1.039"`` are also accepted and converted, and
|
||||
anything unparseable yields ``None`` rather than raising.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, bool): # bool is an int subclass; never a gravity
|
||||
return None
|
||||
if isinstance(raw, (int, float)):
|
||||
value = float(raw)
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
value = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if value > 100: # already stored as points (1039)
|
||||
return int(round(value))
|
||||
if value >= 0.9: # a decimal reading such as 1.039
|
||||
return int(round(value * 1000))
|
||||
return None
|
||||
|
||||
|
||||
def sg_points_to_decimal(points: int | None) -> float | None:
|
||||
"""Convert points (``1039``) to the conventional decimal reading (``1.039``)."""
|
||||
if points is None:
|
||||
return None
|
||||
return round(points / 1000.0, 4)
|
||||
|
||||
|
||||
def parse_date(raw: object) -> date | None:
|
||||
"""Parse a ``YYYY-MM-DD`` date, tolerating the odd alternative layout."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw.date()
|
||||
if isinstance(raw, date):
|
||||
return raw
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value: object) -> bool:
|
||||
"""Interpret the stored ``final_reading`` flag.
|
||||
|
||||
The collection stores it as the *string* ``"True"``/``"False"`` for historic
|
||||
rows and as a real boolean for newer ones, so both are accepted.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in {"true", "1", "yes", "y", "t"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Domain objects
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Reading:
|
||||
"""A single gravity reading taken on a given day."""
|
||||
|
||||
date: date | None
|
||||
sg_points: int | None
|
||||
final_reading: bool = False
|
||||
comment: str = ""
|
||||
beer_type: str = ""
|
||||
_id: str = ""
|
||||
|
||||
@property
|
||||
def sg_decimal(self) -> float | None:
|
||||
return sg_points_to_decimal(self.sg_points)
|
||||
|
||||
@property
|
||||
def is_plausible(self) -> bool:
|
||||
return is_plausible_sg(self.sg_points)
|
||||
|
||||
@classmethod
|
||||
def from_doc(cls, doc: dict) -> "Reading":
|
||||
"""Build a :class:`Reading` from a raw Mongo document."""
|
||||
return cls(
|
||||
date=parse_date(doc.get("date")),
|
||||
sg_points=parse_sg_points(doc.get("sg")),
|
||||
final_reading=_as_bool(doc.get("final_reading")),
|
||||
comment=(doc.get("comment") or "").strip(),
|
||||
beer_type=(doc.get("beer_type") or "").strip(),
|
||||
_id=str(doc.get("_id") or ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Run:
|
||||
"""All readings that belong to one brew run."""
|
||||
|
||||
beer_run_id: str
|
||||
readings: list[Reading] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_docs(cls, beer_run_id: str, docs: Iterable[dict]) -> "Run":
|
||||
readings = [Reading.from_doc(d) for d in docs]
|
||||
readings.sort(key=lambda r: (r.date or date.min, r.sg_points or 0))
|
||||
return cls(beer_run_id=str(beer_run_id), readings=readings)
|
||||
|
||||
# -- aggregate accessors ------------------------------------------------ #
|
||||
|
||||
def sg_values(self) -> list[int]:
|
||||
"""Gravity points of every *usable* reading, in chronological order."""
|
||||
return [r.sg_points for r in self.readings if r.sg_points is not None]
|
||||
|
||||
def original_gravity(self) -> int | None:
|
||||
"""OG = the first usable reading of the run."""
|
||||
values = self.sg_values()
|
||||
return values[0] if values else None
|
||||
|
||||
def final_gravity(self) -> int | None:
|
||||
"""FG = the last usable reading of the run (if the run is finished)."""
|
||||
values = self.sg_values()
|
||||
return values[-1] if values else None
|
||||
|
||||
def max_sg(self) -> int | None:
|
||||
values = self.sg_values()
|
||||
return max(values) if values else None
|
||||
|
||||
def min_sg(self) -> int | None:
|
||||
values = self.sg_values()
|
||||
return min(values) if values else None
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return is_active(self.readings)
|
||||
|
||||
def final_reading(self) -> Reading | None:
|
||||
return final_reading(self.readings)
|
||||
|
||||
def first_reading(self) -> Reading | None:
|
||||
return first_reading(self.readings)
|
||||
|
||||
def last_reading(self) -> Reading | None:
|
||||
return last_reading(self.readings)
|
||||
|
||||
def gravity_drop_points(self) -> int | None:
|
||||
og, fg = self.original_gravity(), self.final_gravity()
|
||||
if og is None or fg is None:
|
||||
return None
|
||||
return og - fg
|
||||
|
||||
def abv_percent(self) -> float | None:
|
||||
"""Standard ABV for a **finished** run.
|
||||
|
||||
A run that is still in the fermenter has no measured alcohol - its
|
||||
figure is a projection, not a measurement. Returning the drop here
|
||||
for an unfinished run would report a "measured" ABV for a beer that
|
||||
has not finished fermenting, which is wrong.
|
||||
"""
|
||||
if self.is_active():
|
||||
return None
|
||||
drop = self.gravity_drop_points()
|
||||
return abv_percent(drop) if drop is not None else None
|
||||
|
||||
def projected_abv_percent(self, assumed_og: int = ASSUMED_ORIGINAL_GRAVITY,
|
||||
og_override: int | None = None) -> float | None:
|
||||
# `assumed_og` is retained for API compatibility but is deliberately
|
||||
# NOT used as a fallback — see the docstring.
|
||||
"""ABV estimate for an *unfinished* run, or ``None`` if unknowable.
|
||||
|
||||
The original gravity is taken from, in order of preference:
|
||||
|
||||
1. ``og_override`` — the OG recorded against the run by the user.
|
||||
2. The run's first reading, when there are two or more readings.
|
||||
The first reading of a run IS its original gravity — that is what
|
||||
a reading taken on brew day is for.
|
||||
With a single reading and no recorded OG there is NO reliable
|
||||
estimate: the reading may have been taken on brew day or mid-
|
||||
fermentation. The legacy app assumed 1012 anyway, which produced
|
||||
*negative* alcohol whenever the reading was above 1012 — a perfectly
|
||||
normal OG. Rather than show a misleading figure, ``None`` is
|
||||
returned and the UI shows a dash until an OG is known. Record the OG
|
||||
against the run and the estimate appears.
|
||||
"""
|
||||
values = self.sg_values()
|
||||
if not values:
|
||||
return None
|
||||
|
||||
if og_override is not None:
|
||||
og = og_override
|
||||
elif len(values) >= 2:
|
||||
og = values[0]
|
||||
else:
|
||||
# A single reading and no recorded OG: there is no reliable
|
||||
# original gravity to measure against, so there is no estimate.
|
||||
return None
|
||||
|
||||
fg = self.min_sg()
|
||||
if fg is None:
|
||||
return None
|
||||
|
||||
drop = og - fg
|
||||
if drop <= 0:
|
||||
# Gravity has risen during fermentation — an anomaly, not alcohol.
|
||||
return None
|
||||
|
||||
return abv_percent(drop)
|
||||
|
||||
def days_in_fermenter(self, today: date | None = None) -> int | None:
|
||||
first = self.first_reading()
|
||||
if first is None or first.date is None:
|
||||
return None
|
||||
final = self.final_reading()
|
||||
end = final.date if (final is not None and final.date is not None) else (today or date.today())
|
||||
if end is None:
|
||||
return None
|
||||
return days_between(first.date, end)
|
||||
|
||||
def display_name(self) -> str:
|
||||
"""Best available human label for the run.
|
||||
|
||||
The stored ``beer_type`` drifts between readings of the same run, so
|
||||
the name is resolved by preferring, in order:
|
||||
|
||||
1. The name on the **final** reading. That is the one the operator
|
||||
wrote when the brew was finished, so it is the name they settled
|
||||
on. Run 4 resolves to "Nocturna Christmas beer" rather than
|
||||
"Necatron-Christmas Beer" from the first reading.
|
||||
2. The name used on the most readings, breaking ties towards the most
|
||||
recent. This catches runs whose name changed exactly once.
|
||||
3. The name on the most recent reading, if it appears only once.
|
||||
|
||||
A run whose readings carry no name at all falls back to "Run N".
|
||||
"""
|
||||
named = [r for r in self.readings if r.beer_type]
|
||||
if not named:
|
||||
return f"Run {self.beer_run_id}"
|
||||
|
||||
final = self.final_reading()
|
||||
if final is not None and final.beer_type:
|
||||
return final.beer_type
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for reading in named:
|
||||
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1
|
||||
|
||||
return max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
||||
|
||||
def _last_index_of(self, label: str) -> int:
|
||||
for index in range(len(self.readings) - 1, -1, -1):
|
||||
if self.readings[index].beer_type == label:
|
||||
return index
|
||||
return -1
|
||||
|
||||
def comments(self) -> list[str]:
|
||||
"""Every non-empty comment, oldest first."""
|
||||
return [r.comment for r in self.readings if r.comment]
|
||||
|
||||
def outlier_readings(self) -> list[Reading]:
|
||||
"""Readings whose gravity is outside the plausible hydrometer range."""
|
||||
return [r for r in self.readings if r.sg_points is not None and not r.is_plausible]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Module level helpers (usable without a Run instance)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def abv_percent(points_drop: int | float | None) -> float | None:
|
||||
"""Standard ABW/ABV from a gravity drop expressed in points."""
|
||||
if points_drop is None:
|
||||
return None
|
||||
return round(float(points_drop) * ABV_FACTOR, 2)
|
||||
|
||||
|
||||
def legacy_abv_percent(max_points: int | float | None,
|
||||
min_points: int | float | None) -> float | None:
|
||||
"""The legacy app's formula, kept for the legacy-vs-standard comparison.
|
||||
|
||||
The old code computed ``((max - min) / 7.36) + 0.5``. It is not a recognised
|
||||
brewing formula and returns 0.5% even when nothing has fermented.
|
||||
"""
|
||||
if max_points is None or min_points is None:
|
||||
return None
|
||||
return round(((float(max_points) - float(min_points)) / 7.36) + 0.5, 2)
|
||||
|
||||
|
||||
def assumed_og_abv_percent(current_points: int | None,
|
||||
assumed_og: int = ASSUMED_ORIGINAL_GRAVITY) -> float | None:
|
||||
"""ABV for an in-progress run measured against an assumed original gravity."""
|
||||
if current_points is None:
|
||||
return None
|
||||
return abv_percent(assumed_og - current_points)
|
||||
|
||||
|
||||
def is_plausible_sg(points: int | None) -> bool:
|
||||
"""True when a gravity reading sits inside a believable hydrometer range."""
|
||||
if points is None:
|
||||
return False
|
||||
return _MIN_PLAUSIBLE_SG <= points <= _MAX_PLAUSIBLE_SG
|
||||
|
||||
|
||||
def is_active(readings: Sequence[Reading]) -> bool:
|
||||
"""A run is active while it has no reading marked as the final one."""
|
||||
return not any(r.final_reading for r in readings)
|
||||
|
||||
|
||||
def final_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||
"""The reading flagged as final, or ``None``."""
|
||||
for reading in readings:
|
||||
if reading.final_reading:
|
||||
return reading
|
||||
return None
|
||||
|
||||
|
||||
def first_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||
"""The chronologically first reading."""
|
||||
dated = [r for r in readings if r.date is not None]
|
||||
if dated:
|
||||
return min(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
|
||||
return readings[0] if readings else None
|
||||
|
||||
|
||||
def last_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||
"""The chronologically last reading."""
|
||||
dated = [r for r in readings if r.date is not None]
|
||||
if dated:
|
||||
return max(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
|
||||
return readings[-1] if readings else None
|
||||
|
||||
|
||||
def days_between(start: date, end: date) -> int:
|
||||
return (end - start).days
|
||||
|
||||
|
||||
def _fmt_decimal(points: int | None) -> str:
|
||||
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points / 1000:.3f}"
|
||||
|
||||
|
||||
def _fmt_point_scale(points: int | None) -> str:
|
||||
"""Format the gravity in brewing "points".
|
||||
|
||||
A reading of 1.039 is 39 points in the homebrew sense - the digits after
|
||||
the decimal point. The stored value ``1039`` is the full reading, not a
|
||||
point count, so it must not be labelled "points".
|
||||
"""
|
||||
if points is None:
|
||||
return ""
|
||||
return f"{points % 1000}"
|
||||
|
||||
|
||||
def run_summary(run: Run, today: date | None = None,
|
||||
og_override: int | None = None) -> dict:
|
||||
"""A flat dict describing a run, ready for a template or a JSON response."""
|
||||
final = run.final_reading()
|
||||
first = run.first_reading()
|
||||
og = run.original_gravity()
|
||||
fg = run.final_gravity()
|
||||
return {
|
||||
"beer_run_id": run.beer_run_id,
|
||||
"name": run.display_name(),
|
||||
"active": run.is_active(),
|
||||
"og_points": og,
|
||||
"fg_points": fg,
|
||||
"og_decimal": sg_points_to_decimal(og),
|
||||
"fg_decimal": sg_points_to_decimal(fg),
|
||||
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
||||
"og_decimal_str": _fmt_decimal(og),
|
||||
"fg_decimal_str": _fmt_decimal(fg),
|
||||
"og_point_scale": _fmt_point_scale(og),
|
||||
"fg_point_scale": _fmt_point_scale(fg),
|
||||
"max_sg": run.max_sg(),
|
||||
"min_sg": run.min_sg(),
|
||||
"min_sg_decimal": _fmt_decimal(run.min_sg()),
|
||||
"min_sg_point_scale": _fmt_point_scale(run.min_sg()),
|
||||
"drop_points": run.gravity_drop_points(),
|
||||
"abv": run.abv_percent(),
|
||||
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg())
|
||||
if not run.is_active() else None),
|
||||
"reading_count": len(run.readings),
|
||||
"first_date": first.date.isoformat() if (first and first.date) else None,
|
||||
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||
"days_in_fermenter": run.days_in_fermenter(today),
|
||||
"comments": run.comments(),
|
||||
"last_comment": run.comments()[-1] if run.comments() else "",
|
||||
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||
"readings": [
|
||||
{
|
||||
"date": r.date.isoformat() if r.date else None,
|
||||
"sg": r.sg_points,
|
||||
"sg_decimal": r.sg_decimal,
|
||||
"final_reading": r.final_reading,
|
||||
"comment": r.comment,
|
||||
"beer_type": r.beer_type,
|
||||
}
|
||||
for r in run.readings
|
||||
],
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import sys
|
||||
|
||||
from bokeh.models.widgets import DataCube
|
||||
from bokeh.plotting import figure
|
||||
|
||||
from mongo.get_conn import db_conn
|
||||
|
||||
class BeerCharts():
|
||||
def __init__(self):
|
||||
self.db = db_conn()
|
||||
|
||||
def bar_chart(self, items, data):
|
||||
return 0
|
||||
|
||||
|
||||
def line_chart(self, title, field, limit):
|
||||
data = self.db.beer_db
|
||||
data = data.find({}).sort("date", -1).limit(limit)
|
||||
dates = []
|
||||
data_list = []
|
||||
date_count = {}
|
||||
for record in data:
|
||||
if field in record and record[field] != "None":
|
||||
if record["date"] not in dates:
|
||||
print("new date record")
|
||||
dates.append(record["date"])
|
||||
data_list.append(float(record[field]))
|
||||
print (dates)
|
||||
print (data_list)
|
||||
else:
|
||||
if record["date"] in date_count:
|
||||
date_count[record["date"]] += 1
|
||||
else:
|
||||
date_count[record["date"]] = 2
|
||||
print(len(data_list))
|
||||
current_data = data_list[len(data_list) - 1]
|
||||
data_list[len(data_list) - 1] = (float(record[field]) + current_data) / date_count[record["date"]]
|
||||
|
||||
print(dates)
|
||||
print(data_list)
|
||||
|
||||
p = figure(x_range=dates, height=250, title=title,
|
||||
toolbar_location=None, tools="")
|
||||
|
||||
p.line(x=dates, y=data_list)
|
||||
|
||||
p.xgrid.grid_line_color = None
|
||||
if not data_list:
|
||||
p.y_range.start = 0
|
||||
else:
|
||||
p.y_range.start = round(min(data_list) - 1)
|
||||
return p
|
||||
@@ -1,76 +0,0 @@
|
||||
import string
|
||||
import secrets
|
||||
|
||||
from mongo.get_conn import db_conn
|
||||
|
||||
class beer_data:
|
||||
"""
|
||||
This class will allow us to
|
||||
interact with our data to interact
|
||||
just create a pool_data var
|
||||
"""
|
||||
def __init__(self):
|
||||
#db_conn has all the the things
|
||||
#already created from here
|
||||
#we can get self.db.real_db etc
|
||||
self.db = db_conn()
|
||||
|
||||
def record_exists(self, date, beer_run_id):
|
||||
"""
|
||||
This function will accept an address
|
||||
if it find that address in the database it will return True
|
||||
and set set the existing_record variable of the class to the
|
||||
queried record
|
||||
"""
|
||||
query = { "date" : f"{date}", "beer_run_id": f"{beer_run_id}"}
|
||||
record = self.db.beer_db.find_one(query)
|
||||
if record:
|
||||
self.existing_record = record
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def create_re_record(self, beer_run_id, beer_type, sg, date, final_reading=False, comment=""):
|
||||
"""
|
||||
create_re_record creates a whole new record
|
||||
takes the required 7 inputs
|
||||
1. beer_run_id
|
||||
2. beer_type
|
||||
3. sg
|
||||
4. date
|
||||
5. comment(optional)
|
||||
It will autogenerate the id string
|
||||
this string will be automatically selected
|
||||
on view in future
|
||||
"""
|
||||
key = self.create_id()
|
||||
insert_record = {
|
||||
"_id" : f"{key}",
|
||||
"beer_run_id": f"{beer_run_id}",
|
||||
"beer_type": f"{beer_type}",
|
||||
"sg": f"{sg}",
|
||||
"date": f"{date}",
|
||||
"final_reading": f"{final_reading}",
|
||||
"comment": f"{comment}"
|
||||
|
||||
}
|
||||
self.db.beer_db.insert_one(insert_record)
|
||||
|
||||
def update_re_record(self, id, field, value):
|
||||
"""
|
||||
update_re_record
|
||||
will update the requested field to value for the
|
||||
selected field
|
||||
"""
|
||||
#TODO: do we need to make this take more than 1 id?
|
||||
|
||||
query = { "_id": f"{id}"}
|
||||
update_val = {"$set": {f"{field}": f"{value}" }}
|
||||
self.db.beer_db.update_one(query, update_val)
|
||||
|
||||
def create_id(self):
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
key = ''.join(secrets.choice(alphabet) for _ in range(24))
|
||||
return key
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from pymongo import MongoClient
|
||||
import os
|
||||
|
||||
class db_conn:
|
||||
def __init__(self):
|
||||
self.db_user = os.getenv('MONGO_USER')
|
||||
self.db_pass = os.getenv('MONGO_PASS')
|
||||
self.db_host = os.getenv('MONGO_HOST')
|
||||
self.client = self.get_client()
|
||||
self.db = self.client['beer_db']
|
||||
self.beer_db = self.db['beer_data']
|
||||
self.users = self.db['users']
|
||||
self.inspections = self.db['inspections_db']
|
||||
|
||||
def get_client(self):
|
||||
CONNECTION_STRING = f"mongodb://{self.db_user}:{self.db_pass}@{self.db_host}/beer_db?authSource=admin"
|
||||
return MongoClient(CONNECTION_STRING)
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import string
|
||||
import secrets
|
||||
|
||||
from mongo.get_conn import db_conn
|
||||
|
||||
class pool_query:
|
||||
"""
|
||||
This class will allow us to
|
||||
interact with our data to interact
|
||||
just create a realestate_data var
|
||||
"""
|
||||
def __init__(self):
|
||||
#db_conn has all the the things
|
||||
#already created from here
|
||||
#we can get self.db.real_db etc
|
||||
self.db = db_conn()
|
||||
|
||||
def record_exists(self, beer_run_id, date):
|
||||
"""
|
||||
This function will accept an address
|
||||
if it find that address in the database it will return True
|
||||
and set set the existing_record variable of the class to the
|
||||
queried record
|
||||
"""
|
||||
query = {"beer_run_id": f"{beer_run_id}", "date": f"{date}"}
|
||||
record = self.db.beer_db.find_one(query)
|
||||
if record:
|
||||
self.existing_record = record
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_top(self, num_limit, value_field):
|
||||
"""
|
||||
This function will return the
|
||||
last n records of the chosen value field
|
||||
It will take the number of records you want to
|
||||
return as a parameter
|
||||
"""
|
||||
records = self.db.beer_db.find({}, {"beer_run_id": 1, "_id": 0, "date": 1, f"{value_field}": 1}).sort("date", -1).limit(num_limit)
|
||||
return records
|
||||
|
||||
def user_check(self, username, password):
|
||||
"""
|
||||
function to check username and password
|
||||
back in db
|
||||
"""
|
||||
#TODO: this ueses my own quick hack it likley needs to be rewrittent to follow best practice
|
||||
|
||||
query = { "username" : f"{username}", "password" : f"{password}" }
|
||||
record = self.db.users.find_one(query)
|
||||
if record:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Mongo access layer for the beer_data app.
|
||||
|
||||
Two collections are used:
|
||||
|
||||
``beer_data``
|
||||
The historic readings. One document per gravity reading. **Never modified or
|
||||
removed by the code in this module** apart from the explicit "add or update a
|
||||
reading" path used by the brew-process form.
|
||||
|
||||
``beer_runs``
|
||||
New in this version. One document per brew run holding the *canonical*
|
||||
metadata that could not be derived from the readings themselves: the beer
|
||||
name, the tap number it is on, the original gravity, and whether the run is
|
||||
still in the fermenter. The backfill script only ever inserts into this
|
||||
collection, so it is safe to run repeatedly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pymongo import MongoClient
|
||||
from pymongo.errors import PyMongoError
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from brewing import Run
|
||||
|
||||
__all__ = ["BeerRepository", "get_repository"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BeerRepository:
|
||||
"""Thin, testable wrapper around the two Mongo collections."""
|
||||
|
||||
def __init__(self, client: MongoClient | None = None,
|
||||
db_name: str = "beer_db") -> None:
|
||||
self.client = client or _build_client()
|
||||
self.db = self.client[db_name]
|
||||
self.readings = self.db["beer_data"]
|
||||
self.users = self.db["users"]
|
||||
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
||||
# shadow the runs() method on the instance.
|
||||
self.run_meta = self.db["beer_runs"]
|
||||
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 -------------------------------------------------------------- #
|
||||
|
||||
def user_check(self, username: str, password: str) -> bool:
|
||||
"""Check the login credentials.
|
||||
|
||||
Passwords are stored as a hash produced by
|
||||
``werkzeug.security.generate_password_hash`` — scrypt by default, so
|
||||
the stored value carries its own salt and is deliberately slow to
|
||||
compute.
|
||||
|
||||
Legacy rows stored the password in plain text. Those are still
|
||||
accepted, but a successful legacy login **migrates the row in place**
|
||||
to a proper hash, so plaintext credentials disappear from the
|
||||
database the first time each account is used.
|
||||
"""
|
||||
if not username or not password:
|
||||
return False
|
||||
|
||||
doc = self.users.find_one({"username": str(username)})
|
||||
if not doc:
|
||||
return False
|
||||
|
||||
stored = str(doc.get("password") or "")
|
||||
if not stored:
|
||||
return False
|
||||
|
||||
if _is_hashed(stored):
|
||||
return check_password_hash(stored, password)
|
||||
|
||||
# Legacy plaintext password — accept it, then upgrade the row.
|
||||
if stored == password:
|
||||
self._upgrade_password(doc, password)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _upgrade_password(self, doc: dict, password: str) -> None:
|
||||
"""Replace a plaintext password with a proper hash."""
|
||||
try:
|
||||
self.users.update_one(
|
||||
{"_id": doc["_id"]},
|
||||
{"$set": {
|
||||
"password": generate_password_hash(password),
|
||||
"password_migrated_at": _now_iso(),
|
||||
}},
|
||||
)
|
||||
except PyMongoError:
|
||||
# A failed upgrade must not stop the login succeeding — but the
|
||||
# operator needs to know the password is still stored in plain
|
||||
# text, so log it rather than swallowing the error silently.
|
||||
logger.warning(
|
||||
"Could not upgrade the password for user %r to a hashed value; "
|
||||
"it is still stored in plain text",
|
||||
doc.get("username"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# -- readings ----------------------------------------------------------- #
|
||||
|
||||
def all_reading_docs(self) -> list[dict]:
|
||||
"""Every reading document, oldest first."""
|
||||
return list(self.readings.find().sort([("date", 1), ("beer_run_id", 1)]))
|
||||
|
||||
def reading_docs_for_run(self, beer_run_id: str) -> list[dict]:
|
||||
return list(
|
||||
self.readings.find({"beer_run_id": _as_id(beer_run_id)}).sort("date", 1)
|
||||
)
|
||||
|
||||
def runs(self) -> list[Run]:
|
||||
"""Every run, built from the reading documents, oldest run first."""
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for doc in self.all_reading_docs():
|
||||
grouped.setdefault(str(doc.get("beer_run_id")), []).append(doc)
|
||||
|
||||
def run_sort_key(run: Run) -> tuple:
|
||||
first = run.first_reading()
|
||||
return (first.date if (first and first.date) else date_min(), run.beer_run_id)
|
||||
|
||||
runs = [Run.from_docs(run_id, docs) for run_id, docs in grouped.items()]
|
||||
runs.sort(key=run_sort_key)
|
||||
return runs
|
||||
|
||||
def run(self, beer_run_id: str) -> Run | None:
|
||||
docs = self.reading_docs_for_run(beer_run_id)
|
||||
if not docs:
|
||||
return None
|
||||
return Run.from_docs(str(beer_run_id), docs)
|
||||
|
||||
def active_runs(self) -> list[Run]:
|
||||
"""Runs with no final reading yet."""
|
||||
return [r for r in self.runs() if r.is_active()]
|
||||
|
||||
def completed_runs(self) -> list[Run]:
|
||||
return [r for r in self.runs() if not r.is_active()]
|
||||
|
||||
def upsert_reading(self, *, beer_run_id: str, beer_type: str, sg: int,
|
||||
reading_date: str, final_reading: bool, comment: str) -> str:
|
||||
"""Add a reading, or update the matching one if it already exists.
|
||||
|
||||
The unique key is ``(date, beer_run_id)`` which is what the original
|
||||
application used, so re-submitting the same day updates in place
|
||||
instead of creating a duplicate. Returns the ``_id`` of the document.
|
||||
"""
|
||||
query = {"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)}
|
||||
update = {
|
||||
"$set": {
|
||||
"beer_run_id": str(beer_run_id),
|
||||
"beer_type": beer_type,
|
||||
"sg": str(sg),
|
||||
"date": _as_date(reading_date),
|
||||
"final_reading": "True" if final_reading else "False",
|
||||
"comment": str(comment),
|
||||
}
|
||||
}
|
||||
result = self.readings.update_one(query, update, upsert=True)
|
||||
if result.upserted_id is not None:
|
||||
return str(result.upserted_id)
|
||||
existing = self.readings.find_one(query)
|
||||
return str(existing.get("_id")) if existing else ""
|
||||
|
||||
def set_final_reading(self, beer_run_id: str, reading_date: str,
|
||||
final: bool) -> int:
|
||||
"""Flag (or unflag) a reading as the final reading of its run."""
|
||||
result = self.readings.update_many(
|
||||
{"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)},
|
||||
{"$set": {"final_reading": "True" if final else "False"}},
|
||||
)
|
||||
return result.modified_count
|
||||
|
||||
# -- run metadata ------------------------------------------------------- #
|
||||
|
||||
def run_metadata(self, beer_run_id: str) -> dict:
|
||||
"""The stored metadata for a run, or an empty dict."""
|
||||
doc = self.run_meta.find_one({"beer_run_id": _as_id(beer_run_id)})
|
||||
if not doc:
|
||||
return {}
|
||||
doc.pop("_id", None)
|
||||
return doc
|
||||
|
||||
def all_run_metadata(self) -> dict[str, dict]:
|
||||
"""All stored run metadata, keyed by ``beer_run_id``."""
|
||||
result: dict[str, dict] = {}
|
||||
for doc in self.run_meta.find():
|
||||
doc.pop("_id", None)
|
||||
result[_as_id(doc.get("beer_run_id"))] = doc
|
||||
return result
|
||||
|
||||
def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict:
|
||||
"""Create or update the metadata document for a run.
|
||||
|
||||
Only the keys passed in are written; anything already stored is left
|
||||
alone. A field passed as ``None`` is **cleared** — that is how the
|
||||
form removes a name or a tap number the operator has deleted.
|
||||
|
||||
Use ``upsert_run_metadata_partial`` to update some fields while
|
||||
leaving the rest untouched.
|
||||
"""
|
||||
payload = dict(fields)
|
||||
payload["updated_at"] = _now_iso()
|
||||
self.run_meta.update_one(
|
||||
{"beer_run_id": _as_id(beer_run_id)},
|
||||
{"$set": payload, "$setOnInsert": {"created_at": _now_iso()}},
|
||||
upsert=True,
|
||||
)
|
||||
return self.run_metadata(beer_run_id)
|
||||
|
||||
def upsert_run_metadata_partial(self, beer_run_id: str, **fields: Any) -> dict:
|
||||
"""Update only the given fields, leaving every other field alone.
|
||||
|
||||
Fields passed as ``None`` are ignored rather than cleared.
|
||||
"""
|
||||
payload = {k: v for k, v in fields.items() if v is not None}
|
||||
if not payload:
|
||||
return self.run_metadata(beer_run_id)
|
||||
payload["updated_at"] = _now_iso()
|
||||
self.run_meta.update_one(
|
||||
{"beer_run_id": _as_id(beer_run_id)},
|
||||
{"$set": payload},
|
||||
upsert=True,
|
||||
)
|
||||
return self.run_metadata(beer_run_id)
|
||||
|
||||
def next_free_tap_number(self) -> int:
|
||||
"""The lowest tap number not already in use.
|
||||
|
||||
Taps are numbered from 1. A run with no tap number recorded does not
|
||||
occupy one.
|
||||
"""
|
||||
used = {
|
||||
meta.get("tap_number")
|
||||
for meta in self.all_run_metadata().values()
|
||||
if meta.get("tap_number") is not None
|
||||
}
|
||||
candidate = 1
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
return candidate
|
||||
|
||||
def delete_run_metadata(self, beer_run_id: str) -> bool:
|
||||
"""Remove the metadata document for a run (readings are untouched)."""
|
||||
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
|
||||
|
||||
# -- helpers ------------------------------------------------------------ #
|
||||
|
||||
def next_run_id(self) -> str:
|
||||
"""The next free ``beer_run_id``.
|
||||
|
||||
Run ids are plain integers in the existing data, so the next id is one
|
||||
past the highest numeric id currently in use.
|
||||
"""
|
||||
highest = 0
|
||||
for run in self.runs():
|
||||
try:
|
||||
highest = max(highest, int(run.beer_run_id))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return str(highest + 1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Connection helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _build_client() -> MongoClient:
|
||||
user = os.getenv("MONGO_USER", "root")
|
||||
password = os.getenv("MONGO_PASS", "")
|
||||
host = os.getenv("MONGO_HOST", "127.0.0.1")
|
||||
uri = os.getenv("MONGO_URI") or f"mongodb://{user}:{password}@{host}/beer_db?authSource=admin"
|
||||
return MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||
|
||||
|
||||
def _as_id(value: object) -> str:
|
||||
"""Coerce a run id to a plain string.
|
||||
|
||||
Every value that reaches a Mongo query is coerced first. Without this, a
|
||||
caller passing a dict (e.g. ``{"$ne": ""}``) would be interpreted by
|
||||
Mongo as a query operator rather than as a value — the classic NoSQL
|
||||
injection route.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _as_date(value: object) -> str:
|
||||
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _is_hashed(stored: str) -> bool:
|
||||
"""True when *stored* looks like a werkzeug password hash.
|
||||
|
||||
Werkzeug hashes are of the form ``method$salt$hash``, e.g.
|
||||
``scrypt:32768:8:1$abcdef...$0123...``. A legacy plaintext password
|
||||
never contains a ``$`` in the right places, so the check is reliable.
|
||||
"""
|
||||
return "$" in stored and len(stored.split("$")) >= 3
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storage (scrypt via werkzeug)."""
|
||||
return generate_password_hash(password)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def date_min():
|
||||
from datetime import date
|
||||
|
||||
return date.min
|
||||
|
||||
|
||||
def get_repository() -> BeerRepository:
|
||||
"""Build a repository against the configured Mongo."""
|
||||
return BeerRepository()
|
||||
|
||||
|
||||
def is_alive(repo: BeerRepository) -> bool:
|
||||
"""Cheap health check used by the status page."""
|
||||
try:
|
||||
repo.client.admin.command("ping")
|
||||
return True
|
||||
except PyMongoError:
|
||||
return False
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import string
|
||||
import secrets
|
||||
|
||||
from mongo.get_conn import db_conn
|
||||
|
||||
|
||||
class user_data:
|
||||
"""
|
||||
This class will allow us to
|
||||
interact with our data to interact
|
||||
just create a user_data var
|
||||
"""
|
||||
def __init__(self):
|
||||
self.db = db_conn()
|
||||
|
||||
def create_id(self):
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
key = ''.join(secrets.choice(alphabet) for _ in range(24))
|
||||
return key
|
||||
|
||||
def user_exists(self, username):
|
||||
"""
|
||||
This function will accept a username
|
||||
if it find that user in the database it will return True
|
||||
and set set the existing_record variable of the class to the
|
||||
queried record
|
||||
"""
|
||||
query = { "username" : f"{username}" }
|
||||
record = self.db.users.find_one(query)
|
||||
if record:
|
||||
self.existing_record = record
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
def add_user(self, username, password):
|
||||
"""
|
||||
add to user table
|
||||
function accepts the username and password
|
||||
it will generate a uniqe key
|
||||
"""
|
||||
|
||||
key = self.create_id()
|
||||
insert_record = {
|
||||
"_id" : f"{key}",
|
||||
"username" : f"{username}",
|
||||
"password" : f"{password}"
|
||||
}
|
||||
self.db.users.insert_one(insert_record)
|
||||
|
||||
def update_user(self, id, password):
|
||||
"""
|
||||
update the user record password
|
||||
requires the id to be updated
|
||||
"""
|
||||
|
||||
query = { "_id": f"{id}"}
|
||||
update_val = {"$set": {"password": f"{password}" }}
|
||||
self.db.users.update_one(query, update_val)
|
||||
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
/* ==========================================================================
|
||||
Brew Log — design system
|
||||
Beer-house palette: warm amber/copper on near-black, with a condensed
|
||||
display face (Anton) for headings and Inter for everything else.
|
||||
|
||||
Palette taken from the brewery-128 template:
|
||||
--primary #be7f39 (amber / copper)
|
||||
--primary-dark #0e0e0e
|
||||
--black-primary #212024
|
||||
--black-secondary #161616
|
||||
--gray #e9e9e9
|
||||
--dark-gray #afafaf
|
||||
--paragraph-gray #535353
|
||||
========================================================================== */
|
||||
|
||||
/* ------------------------------------------------------------------ fonts */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Anton';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('../fonts/anton-latin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('../fonts/inter-latin-400.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- tokens */
|
||||
|
||||
:root {
|
||||
/* surfaces */
|
||||
--bg: #121110;
|
||||
--bg-raised: #1c1a19;
|
||||
--bg-raised-2: #242120;
|
||||
--bg-input: #161514;
|
||||
|
||||
/* lines */
|
||||
--border: #332e2a;
|
||||
--border-strong: #4a4239;
|
||||
|
||||
/* text */
|
||||
--text: #f2ede8;
|
||||
--text-muted: #afafaf;
|
||||
--text-faint: #7d746c;
|
||||
|
||||
/* brand */
|
||||
--amber: #be7f39;
|
||||
--amber-bright: #dda05c;
|
||||
--amber-deep: #8f5f28;
|
||||
--amber-dim: rgba(190, 127, 57, 0.14);
|
||||
--amber-text: #e2b47c;
|
||||
|
||||
/* foam / highlights */
|
||||
--foam: #f7f3ee;
|
||||
--cream: #eef4f8;
|
||||
|
||||
/* status */
|
||||
--warn: #e8a33d;
|
||||
--warn-dim: rgba(232, 163, 61, 0.14);
|
||||
--danger: #d9534f;
|
||||
--danger-dim: rgba(217, 83, 79, 0.14);
|
||||
--success: #7db902;
|
||||
--success-dim: rgba(125, 185, 2, 0.14);
|
||||
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 10px 30px rgba(0, 0, 0, .35);
|
||||
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--display: 'Anton', 'Arial Narrow', Impact, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--amber-text); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.15; font-weight: 650; }
|
||||
h1 { font-size: 1.65rem; }
|
||||
h2 { font-size: 1.25rem; }
|
||||
h3 { font-size: 1.02rem; }
|
||||
|
||||
p { margin: 0 0 .75rem; }
|
||||
|
||||
/* Display type: the condensed face, used sparingly for headings and figures. */
|
||||
.display {
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
letter-spacing: .01em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- topbar */
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(18, 17, 16, .94);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar__inner {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: .6rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
|
||||
.brand__mark {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 0 10px rgba(190, 127, 57, .35));
|
||||
}
|
||||
|
||||
.brand__text {
|
||||
font-family: var(--display);
|
||||
font-size: 1.22rem;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber-bright);
|
||||
}
|
||||
|
||||
.nav { display: flex; gap: .25rem; flex: 1; }
|
||||
|
||||
.nav__link {
|
||||
padding: .4rem .8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: .92rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav__link:hover { background: var(--bg-raised-2); color: var(--text); text-decoration: none; }
|
||||
.nav__link.is-active {
|
||||
background: var(--amber-dim);
|
||||
color: var(--amber-text);
|
||||
box-shadow: inset 0 -2px 0 var(--amber);
|
||||
}
|
||||
|
||||
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
|
||||
.topbar__user {
|
||||
color: var(--text-muted);
|
||||
font-size: .85rem;
|
||||
font-family: var(--mono);
|
||||
padding: .15rem .5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ page */
|
||||
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 4rem;
|
||||
}
|
||||
.page--narrow { max-width: 460px; }
|
||||
|
||||
.page__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.page__title {
|
||||
margin: 0;
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
}
|
||||
.page__sub { color: var(--text-muted); font-size: .9rem; margin: .15rem 0 0; }
|
||||
|
||||
/* ----------------------------------------------------------------- cards */
|
||||
|
||||
.card {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* A thin amber seam along the top of each card — the "label on the barrel" touch. */
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 40%, transparent 85%);
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.card__head {
|
||||
padding: .9rem 1.1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.card__hint { color: var(--text-faint); font-size: .82rem; }
|
||||
.card__body { padding: 1.1rem; }
|
||||
.card__body--flush { padding: 0; }
|
||||
|
||||
.grid { display: grid; gap: 1rem; }
|
||||
.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
/* The active-beer cards: fill the row whatever the number of brews. */
|
||||
.grid--cards { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.grid--sidebar { grid-template-columns: minmax(0, 380px) minmax(0, 1fr); align-items: start; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid--sidebar { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid--2, .grid--3, .grid--4 { grid-template-columns: minmax(0, 1fr); }
|
||||
.topbar__inner { flex-wrap: wrap; gap: .75rem; }
|
||||
.nav { order: 3; width: 100%; overflow-x: auto; }
|
||||
.page__title { font-size: 1.5rem; }
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- stats */
|
||||
|
||||
.stat {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: .85rem 1rem;
|
||||
}
|
||||
.stat__label {
|
||||
color: var(--text-faint);
|
||||
font-size: .72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .09em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat__value {
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 1.75rem;
|
||||
letter-spacing: .02em;
|
||||
line-height: 1.2;
|
||||
color: var(--amber-bright);
|
||||
}
|
||||
.stat__meta { color: var(--text-muted); font-size: .83rem; }
|
||||
|
||||
/* ----------------------------------------------------------------- forms */
|
||||
|
||||
.field { margin-bottom: .9rem; }
|
||||
.field__label {
|
||||
display: block;
|
||||
font-size: .74rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .09em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: .3rem;
|
||||
}
|
||||
.field__hint { color: var(--text-muted); 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="date"], input[type="email"], textarea, select {
|
||||
width: 100%;
|
||||
padding: .5rem .65rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: .92rem;
|
||||
transition: border-color .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--amber);
|
||||
box-shadow: 0 0 0 3px var(--amber-dim);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--text-faint); }
|
||||
textarea { resize: vertical; min-height: 5rem; }
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The native checkbox is a white square on a near-black page — replace it. */
|
||||
.checkbox input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-input);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .12s ease, border-color .12s ease;
|
||||
}
|
||||
.checkbox input[type="checkbox"]:hover { border-color: var(--amber); }
|
||||
.checkbox input[type="checkbox"]:checked {
|
||||
background: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.checkbox input[type="checkbox"]:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: .32rem;
|
||||
top: .07rem;
|
||||
width: .28rem;
|
||||
height: .55rem;
|
||||
border: solid #17110a;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(43deg);
|
||||
}
|
||||
.checkbox input[type="checkbox"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--amber-dim);
|
||||
}
|
||||
|
||||
/* Hide the native number spinners — the light spinner widgets jar badly
|
||||
against the dark theme. */
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] { -moz-appearance: textfield; }
|
||||
|
||||
/* The native date picker's calendar icon is near-black on near-black. */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.75) sepia(1) saturate(3) hue-rotate(350deg) brightness(1.1);
|
||||
cursor: pointer;
|
||||
opacity: .7;
|
||||
}
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
|
||||
|
||||
/* Dark scrollbars to match the page. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 6px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--amber-deep); }
|
||||
|
||||
/* A figure that should read as a number, not as body text. */
|
||||
.strong { font-weight: 650; color: var(--text); }
|
||||
|
||||
.field-row { display: flex; gap: .75rem; }
|
||||
.field-row > * { flex: 1; }
|
||||
|
||||
/* --------------------------------------------------------------- buttons */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4rem;
|
||||
padding: .5rem .95rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--amber);
|
||||
color: #17110a;
|
||||
font-family: inherit;
|
||||
font-size: .85rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: .01em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: filter .12s ease, background .12s ease, border-color .12s ease;
|
||||
}
|
||||
.btn:hover { filter: brightness(1.12); text-decoration: none; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn--ghost:hover { background: var(--bg-raised-2); color: var(--text); }
|
||||
|
||||
.btn--danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||
.btn--danger:hover { background: var(--danger-dim); }
|
||||
|
||||
.btn--sm { padding: .3rem .6rem; font-size: .78rem; }
|
||||
.btn--block { width: 100%; }
|
||||
|
||||
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* ---------------------------------------------------------------- tables */
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
table.data {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .88rem;
|
||||
}
|
||||
table.data th {
|
||||
text-align: left;
|
||||
padding: .55rem .8rem;
|
||||
color: var(--text-faint);
|
||||
font-size: .7rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-raised-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data td {
|
||||
padding: .6rem .8rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
table.data tr:last-child td { border-bottom: none; }
|
||||
table.data tbody tr:hover { background: var(--bg-raised-2); }
|
||||
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
|
||||
table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||
|
||||
/* ----------------------------------------------------------------- chips */
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
padding: .12rem .5rem;
|
||||
border-radius: 999px;
|
||||
font-size: .74rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip--active { background: var(--amber-dim); color: var(--amber-text); }
|
||||
.chip--done { background: rgba(175, 175, 175, .13); color: var(--text-muted); }
|
||||
.chip--warn { background: var(--warn-dim); color: var(--warn); }
|
||||
.chip--danger { background: var(--danger-dim); color: var(--danger); }
|
||||
.chip--good { background: var(--success-dim); color: var(--success); }
|
||||
.chip--tap {
|
||||
background: var(--bg-raised-2);
|
||||
border: 1px solid var(--amber-deep);
|
||||
color: var(--amber-text);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- flashes */
|
||||
|
||||
.flashes { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||
|
||||
.flash {
|
||||
padding: .6rem .9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
font-size: .9rem;
|
||||
}
|
||||
.flash--success { border-color: rgba(125, 185, 2, .45); background: var(--success-dim); color: #c3e08a; }
|
||||
.flash--error { border-color: rgba(217, 83, 79, .45); background: var(--danger-dim); color: #f0a8a6; }
|
||||
.flash--warning { border-color: rgba(232, 163, 61, .45); background: var(--warn-dim); color: #f2c880; }
|
||||
.flash--info { border-color: rgba(190, 127, 57, .45); background: var(--amber-dim); color: var(--amber-text); }
|
||||
|
||||
/* ---------------------------------------------------------------- login */
|
||||
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background:
|
||||
radial-gradient(900px 480px at 50% -10%, rgba(190, 127, 57, .13), transparent 62%),
|
||||
var(--bg);
|
||||
}
|
||||
.login__card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
position: relative;
|
||||
}
|
||||
.login__card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: linear-gradient(90deg, var(--amber-deep), var(--amber-bright), var(--amber-deep));
|
||||
}
|
||||
.login__mark { font-size: 2.4rem; line-height: 1; }
|
||||
.login__title {
|
||||
margin: .6rem 0 .15rem;
|
||||
font-family: var(--display);
|
||||
font-weight: 400;
|
||||
font-size: 1.9rem;
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber-bright);
|
||||
}
|
||||
.login__sub { color: var(--text-muted); font-size: .88rem; margin-bottom: 1.4rem; }
|
||||
|
||||
/* ----------------------------------------------------------- active card */
|
||||
|
||||
.brew-card {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.brew-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 45%, transparent 90%);
|
||||
opacity: .75;
|
||||
}
|
||||
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
||||
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
|
||||
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||
.brew-card__stat .stat__label { font-size: .68rem; }
|
||||
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-style: italic;
|
||||
padding: .35rem 0;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- helpers */
|
||||
|
||||
.muted { color: var(--text-muted); }
|
||||
.faint { color: var(--text-faint); }
|
||||
.mono { font-family: var(--mono); }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.small { font-size: .84rem; }
|
||||
.tiny { font-size: .76rem; }
|
||||
.center { text-align: center; }
|
||||
.right { text-align: right; }
|
||||
.stack > * + * { margin-top: .75rem; }
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||
.row--between { justify-content: space-between; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* 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 {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.empty__icon { font-size: 2rem; display: block; margin-bottom: .5rem; opacity: .7; }
|
||||
|
||||
/* --------------------------------------------------------------- reading */
|
||||
|
||||
.reading-list { list-style: none; margin: 0; padding: 0; }
|
||||
.reading-list__item {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: flex-start;
|
||||
padding: .6rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.reading-list__item:last-child { border-bottom: none; }
|
||||
.reading-list__date {
|
||||
font-family: var(--mono);
|
||||
font-size: .82rem;
|
||||
color: var(--text-muted);
|
||||
flex: 0 0 5.5rem;
|
||||
}
|
||||
.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
|
||||
.reading-list__body { flex: 1; min-width: 0; }
|
||||
.reading-list__comment { color: var(--text-muted); font-size: .84rem; }
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="Brew Log">
|
||||
<!-- pint glass -->
|
||||
<path d="M6 5h16l-1.6 20.2a3 3 0 0 1-3 2.8h-6.8a3 3 0 0 1-3-2.8Z"
|
||||
fill="none" stroke="#be7f39" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- beer -->
|
||||
<path d="M7.6 11h12.9l-1.3 13.9a1.4 1.4 0 0 1-1.4 1.3h-7.5a1.4 1.4 0 0 1-1.4-1.3Z"
|
||||
fill="#be7f39" opacity="0.55"/>
|
||||
<!-- foam -->
|
||||
<path d="M6.6 11.5c1.6-1.7 3.2.7 4.8-.6s2.8 1.4 4.4-.2 2.6 1.1 3.6.3"
|
||||
fill="none" stroke="#f2ede8" stroke-width="1.9" stroke-linecap="round"/>
|
||||
<!-- bubbles -->
|
||||
<circle cx="12" cy="16" r="1.1" fill="#f2ede8" opacity="0.75"/>
|
||||
<circle cx="16.5" cy="20" r="0.9" fill="#f2ede8" opacity="0.6"/>
|
||||
<circle cx="11" cy="22.5" r="0.8" fill="#f2ede8" opacity="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 823 B |
@@ -0,0 +1,71 @@
|
||||
/* ==========================================================================
|
||||
Brew Log — page behaviour
|
||||
Small, dependency-free helpers. Charts live in charts.js.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/** Auto-focus the first empty field on a form. */
|
||||
function focusFirstEmpty() {
|
||||
var fields = document.querySelectorAll("input:not([type=hidden]), textarea, select");
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
if (!fields[i].value && !fields[i].readOnly) {
|
||||
fields[i].focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default the reading date to today when the field is left empty. */
|
||||
function defaultDateFields() {
|
||||
var today = new Date().toISOString().slice(0, 10);
|
||||
var dateFields = document.querySelectorAll('input[type="date"][data-default-today]');
|
||||
for (var i = 0; i < dateFields.length; i++) {
|
||||
if (!dateFields[i].value) {
|
||||
dateFields[i].value = today;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Show/hide blocks driven by a checkbox. */
|
||||
function wireToggles() {
|
||||
var toggles = document.querySelectorAll("[data-toggle-target]");
|
||||
for (var i = 0; i < toggles.length; i++) {
|
||||
(function (toggle) {
|
||||
var target = document.getElementById(toggle.getAttribute("data-toggle-target"));
|
||||
if (!target) { return; }
|
||||
function apply() {
|
||||
target.classList.toggle("hidden", !toggle.checked);
|
||||
}
|
||||
toggle.addEventListener("change", apply);
|
||||
apply();
|
||||
})(toggles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirm destructive actions. */
|
||||
function wireConfirms() {
|
||||
var forms = document.querySelectorAll("[data-confirm]");
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
forms[i].addEventListener("submit", function (event) {
|
||||
if (!window.confirm(this.getAttribute("data-confirm"))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
focusFirstEmpty();
|
||||
defaultDateFields();
|
||||
wireToggles();
|
||||
wireConfirms();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -1,90 +0,0 @@
|
||||
import sys
|
||||
|
||||
from mongo.get_conn import db_conn
|
||||
import pandas as pd
|
||||
import duckdb
|
||||
|
||||
class TableBuilder():
|
||||
def __init__(self) -> None:
|
||||
self.db = db_conn()
|
||||
data = self.db.beer_db
|
||||
data = data.find({}).sort("date", -1)
|
||||
df_dict = {}
|
||||
df_dict["beer_run_id"]=[]
|
||||
df_dict["sg"] = []
|
||||
df_dict["date"] = []
|
||||
df_dict["final_reading"] = []
|
||||
|
||||
for record in data:
|
||||
df_dict["beer_run_id"].append(record["beer_run_id"])
|
||||
df_dict["sg"].append(record["sg"])
|
||||
df_dict["date"].append(record["date"])
|
||||
df_dict["final_reading"].append(record["final_reading"])
|
||||
|
||||
self.df_dict = df_dict
|
||||
self.df = pd.DataFrame(data=self.df_dict)
|
||||
|
||||
def done_runs_build(self, limit=10) -> pd.DataFrame:
|
||||
|
||||
df = self.df
|
||||
sql = f"""
|
||||
SELECT x.beer_run_id as beer_run_id,
|
||||
max(sg) as max,
|
||||
min(sg) as min,
|
||||
y.date as final_reading_date
|
||||
FROM df x
|
||||
JOIN
|
||||
( SELECT DISTINCT beer_run_id, date
|
||||
FROM df
|
||||
WHERE final_reading = 'True'
|
||||
) y ON x.beer_run_id = y.beer_run_id
|
||||
GROUP BY x.beer_run_id, y.date
|
||||
ORDER BY x.beer_run_id desc
|
||||
LIMIT {limit}
|
||||
"""
|
||||
df_sum = duckdb.sql(sql).df()
|
||||
sql = f"""
|
||||
SELECT x.beer_run_id as "Beer Run",
|
||||
x.max as "Max",
|
||||
x.min as "Min",
|
||||
ROUND(((CAST (max AS INTEGER) - CAST(min AS INTEGER)) / 7.36) + 0.5, 2) AS "Alcohol Prediction +/- 0.5",
|
||||
cast(final_reading_date as DATE) + INTERVAL 14 DAY as "Ready Date"
|
||||
|
||||
FROM df_sum x
|
||||
"""
|
||||
df_calc = duckdb.sql(sql).df()
|
||||
return df_calc
|
||||
|
||||
def current_runs_build(self, limit=10) -> pd.DataFrame:
|
||||
df = self.df
|
||||
sql = f"""
|
||||
SELECT x.beer_run_id as beer_run_id,
|
||||
max(sg) as max,
|
||||
min(sg) as min,
|
||||
min(cast(x.date as DATE)) as first_reading_date
|
||||
FROM df x
|
||||
LEFT JOIN
|
||||
( SELECT DISTINCT beer_run_id
|
||||
FROM df
|
||||
WHERE final_reading = 'True'
|
||||
) y ON x.beer_run_id = y.beer_run_id
|
||||
WHERE y.beer_run_id is null
|
||||
GROUP BY x.beer_run_id
|
||||
ORDER BY x.beer_run_id desc
|
||||
LIMIT {limit}
|
||||
"""
|
||||
df_sum = duckdb.sql(sql).df()
|
||||
sql = f"""
|
||||
SELECT x.beer_run_id as "Beer Run",
|
||||
x.max as "Max",
|
||||
x.min as "Min",
|
||||
ROUND(((CAST (max AS INTEGER) - 1012) / 7.36) + 0.5, 2) AS "Alcohol Prediction (1012) +/- 0.5",
|
||||
first_reading_date + INTERVAL 7 DAY as "Earliest Bottling/Kegging Date"
|
||||
|
||||
FROM df_sum x
|
||||
"""
|
||||
df_calc = duckdb.sql(sql).df()
|
||||
return df_calc
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,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 %}
|
||||
@@ -1,26 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}Brew Log{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}" />
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/logo.svg') }}" />
|
||||
</head>
|
||||
<body>
|
||||
{% if logged_in %}
|
||||
<header class="topbar">
|
||||
<div class="topbar__inner">
|
||||
<a class="brand" href="{{ url_for('brews.index') }}">
|
||||
<img class="brand__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="26" height="26">
|
||||
<span class="brand__text">Brew Log</span>
|
||||
</a>
|
||||
|
||||
<title>{% block title %} Let there Be Pool Data {% endblock %}</title>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<a class="nav__link {% if current_path == '/brews' %}is-active{% endif %}"
|
||||
href="{{ url_for('brews.index') }}">Brew Process</a>
|
||||
<a class="nav__link {% if current_path == '/active' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.active') }}">Active Beers</a>
|
||||
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
|
||||
href="{{ url_for('analysis.history') }}">History</a>
|
||||
</nav>
|
||||
|
||||
{{ bootstrap.load_css() }}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<span class="topbar__user" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span>
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: #e8f1f9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- this is a base template using Bootstrap-Flask
|
||||
https://bootstrap-flask.readthedocs.io/ -->
|
||||
<main class="{% block main_class %}page{% endblock %}">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash--{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %} {% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- you can delete the next line if you're not using any Bootstrap JS -->
|
||||
{{ bootstrap.load_js() }}
|
||||
</body>
|
||||
<script src="{{ url_for('static', filename='js/chart.umd.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Brew Process — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">Brew Process</h1>
|
||||
<p class="page__sub">Record a gravity reading, then review where each brew is up to.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--sidebar">
|
||||
<!-- ------------------------------------------------ record a reading -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">New reading</h2>
|
||||
<span class="card__hint">Gravity in points (1039 = 1.039)</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="beer_run_id">Run number</label>
|
||||
<input type="number" id="beer_run_id" name="beer_run_id"
|
||||
min="1" step="1" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="Date">Reading date</label>
|
||||
<input type="date" id="Date" name="Date" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="beer_run_type">Beer / cider name</label>
|
||||
<input type="text" id="beer_run_type" name="beer_run_type"
|
||||
maxlength="120" placeholder="e.g. Necatron">
|
||||
<div class="field__hint">The name is shown on the run summary.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="sg">Gravity reading (points)</label>
|
||||
<input type="number" id="sg" name="sg" min="900" max="1300"
|
||||
step="1" required placeholder="1045">
|
||||
<div class="field__hint">Drop the leading "1." — 1045 means 1.045.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="final_reading" value="y">
|
||||
<span>This is the final reading (closes the run)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="comment">Comment</label>
|
||||
<textarea id="comment" name="comment" maxlength="2000"
|
||||
placeholder="Hops in, bottling, kegging…"></textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Save reading</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- --------------------------------------------------- run summaries -->
|
||||
<div class="stack">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">In the fermenter</h2>
|
||||
<span class="card__hint">{{ active_runs | length }} active</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
{% if active_runs %}
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">Gravity</th>
|
||||
<th class="num">Est. ABV</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in active_runs %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td class="num">
|
||||
{% if run.min_sg is not none %}
|
||||
<span class="mono strong">{{ run.min_sg_decimal }}</span>
|
||||
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.projected_abv is not none %}
|
||||
{{ '%.2f' % run.projected_abv }}%
|
||||
{% else %}
|
||||
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}
|
||||
{{ run.comments[-1] }}
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<span class="empty__icon" aria-hidden="true">🍺</span>
|
||||
Nothing in the fermenter right now.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Finished brews</h2>
|
||||
<span class="card__hint">{{ completed_runs | length }} completed</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
{% if completed_runs %}
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">ABV</th>
|
||||
<th>Finished</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in completed_runs %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td class="num">
|
||||
{% if run.og_points is not none %}{{ run.og_points }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.fg_points is not none %}{{ run.fg_points }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num">
|
||||
{% if run.abv is not none %}
|
||||
<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}
|
||||
{{ run.comments[-1] }}
|
||||
{% else %}
|
||||
<span class="faint">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">No finished brews recorded yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ code }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mt-lg">
|
||||
<div class="empty">
|
||||
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
|
||||
<h1>{{ code }}</h1>
|
||||
<p>{{ message }}</p>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Back to the brew log</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,207 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}History — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">History</h1>
|
||||
<p class="page__sub">How every brew has gone, and what it turned out like.</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Brew process</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--4 mb-lg">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Total brews</div>
|
||||
<div class="stat__value">{{ stats.total_runs }}</div>
|
||||
<div class="stat__meta">{{ stats.completed }} finished · {{ stats.active }} active</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Average ABV</div>
|
||||
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
|
||||
<div class="stat__meta">across finished brews</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Strongest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.best %}{{ '%.2f' % stats.best.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.best %}{{ stats.best.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Lightest</div>
|
||||
<div class="stat__value">
|
||||
{% if stats.worst %}{{ '%.2f' % stats.worst.abv }}%{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if stats.worst %}{{ stats.worst.name }}{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2 mb-lg">
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Alcohol by brew</h2>
|
||||
<span class="card__hint">Standard formula (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="abv-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Brews over time</h2>
|
||||
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="months-chart" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if outliers %}
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings worth a second look</h2>
|
||||
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Date</th>
|
||||
<th class="num">Reading</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in outliers %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="mono nowrap">{{ row.date or '—' }}</td>
|
||||
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
|
||||
<td class="comment">{{ row.comment or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Every brew</h2>
|
||||
<span class="card__hint">{{ summaries | length }} runs</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th class="num">OG</th>
|
||||
<th class="num">FG</th>
|
||||
<th class="num">Drop</th>
|
||||
<th class="num">ABV</th>
|
||||
<th class="num">Legacy</th>
|
||||
<th>Finished</th>
|
||||
<th>Comment</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in summaries %}
|
||||
<tr>
|
||||
<td class="mono">{{ run.beer_run_id }}</td>
|
||||
<td>{{ run.canonical_name }}</td>
|
||||
<td>
|
||||
{% if run.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ run.og_points or '—' }}</td>
|
||||
<td class="num">{{ run.fg_points or '—' }}</td>
|
||||
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
|
||||
<td class="num">
|
||||
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
|
||||
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
|
||||
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
|
||||
<td class="comment">
|
||||
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
<a class="btn btn--ghost btn--sm"
|
||||
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Standard vs legacy alcohol figures</h2>
|
||||
<span class="card__hint">The old app used (max − min) ÷ 7.36 + 0.5, which is not a recognised formula</span>
|
||||
</div>
|
||||
<div class="card__body card__body--flush">
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Name</th>
|
||||
<th class="num">Standard ABV</th>
|
||||
<th class="num">Legacy ABV</th>
|
||||
<th class="num">Overstated by</th>
|
||||
<th>Finished</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in abv_rows %}
|
||||
<tr>
|
||||
<td class="mono">{{ row.beer_run_id }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="num">{{ '%.2f' % row.abv }}%</td>
|
||||
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
|
||||
<td class="num">
|
||||
{% if row.delta > 0 %}
|
||||
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
|
||||
{% else %}
|
||||
<span class="faint">{{ '%.2f' % row.delta }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Login to Pool Data Tracker
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="log-form">
|
||||
{% if try_again %}
|
||||
<h4>Login Failed Please try Again</h4>
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">WHOOOOOOO ARE YOU!</h1>
|
||||
|
||||
<p class="lead">If you know you know</p>
|
||||
|
||||
{{ render_form(form) }}
|
||||
|
||||
<p class="pt-5"><strong>{{ message }}</strong></p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!--end log form -->
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Sign in — Brew Log{% endblock %}
|
||||
{% block main_class %}page page--narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login">
|
||||
<div class="login__card">
|
||||
<img class="login__mark" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="46" height="46">
|
||||
<h1 class="login__title">Brew Log</h1>
|
||||
<p class="login__sub">Track the gravity of every brew.</p>
|
||||
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="username">Username</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username"
|
||||
autofocus required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input type="password" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,167 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<h1 class="page__title">
|
||||
{{ summary.canonical_name }}
|
||||
{% if summary.active %}
|
||||
<span class="chip chip--active">Active</span>
|
||||
{% else %}
|
||||
<span class="chip chip--done">Finished</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="page__sub">
|
||||
Run <span class="mono">{{ summary.beer_run_id }}</span>
|
||||
{% if summary.first_date %} · started {{ summary.first_date }}{% endif %}
|
||||
{% if summary.final_date %} · finished {{ summary.final_date }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">← Back</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--sidebar mb-lg">
|
||||
<!-- ------------------------------------------------- run metadata -->
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Run details</h2>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<form method="post" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Beer / cider name</label>
|
||||
<input type="text" id="name" name="name" maxlength="120"
|
||||
value="{{ meta_form.name.data or '' }}">
|
||||
<div class="field__hint">Overrides the drifting name on each reading.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field__label" for="tap_number">Tap number</label>
|
||||
<input type="number" id="tap_number" name="tap_number"
|
||||
min="1" max="99" step="1"
|
||||
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="og_points">Original gravity</label>
|
||||
<input type="number" id="og_points" name="og_points"
|
||||
min="900" max="1300" step="1"
|
||||
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
|
||||
<div class="field__hint">Points, e.g. 1045.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="notes">Brew notes</label>
|
||||
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--block" type="submit">Save run details</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid grid--4">
|
||||
<div class="stat">
|
||||
<div class="stat__label">Original gravity</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.og_points is not none %}
|
||||
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">
|
||||
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
|
||||
</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
|
||||
</div>
|
||||
{% if summary.fg_points is not none %}
|
||||
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Alcohol</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.abv is not none %}
|
||||
{{ '%.2f' % summary.abv }}%
|
||||
{% elif summary.projected_abv is not none %}
|
||||
{{ '%.2f' % summary.projected_abv }}%
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
{% if summary.abv is not none %}
|
||||
measured
|
||||
{% elif summary.projected_abv is not none %}
|
||||
projected
|
||||
{% else %}
|
||||
needs 2nd reading
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__label">Days brewing</div>
|
||||
<div class="stat__value">
|
||||
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
|
||||
</div>
|
||||
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card mb-lg">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Gravity over time</h2>
|
||||
<span class="card__hint">Standard ABV: (OG − FG) × 131.25</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__head">
|
||||
<h2 class="card__title">Readings</h2>
|
||||
<span class="card__hint">{{ summary.reading_count }} total</span>
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="reading-list">
|
||||
{% for reading in summary.readings %}
|
||||
<li class="reading-list__item">
|
||||
<span class="reading-list__date">{{ reading.date or '—' }}</span>
|
||||
<span class="reading-list__sg">
|
||||
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
|
||||
</span>
|
||||
<div class="reading-list__body">
|
||||
{% if reading.comment %}
|
||||
<div class="reading-list__comment">{{ reading.comment }}</div>
|
||||
{% else %}
|
||||
<div class="faint tiny">No comment</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if reading.final_reading %}
|
||||
<span class="chip chip--done">Final</span>
|
||||
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="date" value="{{ reading.date }}">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% from 'bootstrap5/form.html' import render_form %}
|
||||
|
||||
{% block title %}
|
||||
Data Input
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<!--
|
||||
TIPS about using Bootstrap-Flask:
|
||||
https://github.com/helloflask/bootstrap-flask
|
||||
https://bootstrap-flask.readthedocs.io/
|
||||
-->
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<h1 class="pt-5 pb-2">Input Data you want to store</h1>
|
||||
{% if success %}
|
||||
<h3 class="pt-3 pb02">Thank you {{ updater_name | safe }}, Data Updated Successfully</h1>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table border=0>
|
||||
<tr>
|
||||
<td>
|
||||
{{ render_form(form) }}
|
||||
</td>
|
||||
<!-- <td>
|
||||
<div class="container">
|
||||
<div class="col-md-10 col-lg-8 mx-lg-auto mx-md-auto">
|
||||
|
||||
<table border = 0>
|
||||
{% for row in list %}
|
||||
<table border=0>
|
||||
{% for key, value in row.items() %}
|
||||
<tr>
|
||||
<th> {{ key }} </th>
|
||||
<td> {{ value }} </td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td> -->
|
||||
<!--<iframe style="width: 100vw; height: 40vh;" src="static/data_plot.html" frameborder="0" allowfullscreen>
|
||||
<!--<iframe style="width: 100vw;height: 80vh;position: relative;" src="static/data_plot.html" frameborder="0" allowfullscreen>-->
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
||||
<h3>Current Runs</h3>
|
||||
{{ current_data | safe }}
|
||||
|
||||
<h3>Previous Runs</h3>
|
||||
{{ beer_data | safe }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +0,0 @@
|
||||
import table.table_builder as table_builder
|
||||
|
||||
test = table_builder.TableBuilder()
|
||||
|
||||
print(test.table_build())
|
||||
@@ -0,0 +1 @@
|
||||
"""Blueprints for the beer_data app."""
|
||||
@@ -0,0 +1,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,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Login and logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import PasswordField, StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
bp = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField("Username", validators=[DataRequired(), Length(max=64)])
|
||||
password = PasswordField("Password", validators=[DataRequired(), Length(max=128)])
|
||||
submit = SubmitField("Sign in")
|
||||
|
||||
|
||||
def repo():
|
||||
"""The request-scoped repository (one Mongo client per request)."""
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["beer_repo"]()
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
ok = repo().user_check(form.username.data.strip(), form.password.data)
|
||||
if ok:
|
||||
session.clear()
|
||||
session["logged_in"] = True
|
||||
session["username"] = form.username.data.strip()
|
||||
return redirect(request.args.get("next") or url_for("brews.index"))
|
||||
flash("Those credentials were not recognised.", "error")
|
||||
|
||||
return render_template("login.html", form=form)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Signed out.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
@@ -0,0 +1,236 @@
|
||||
"""The brew-process pages: record readings and review the runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import (Blueprint, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for)
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||
SubmitField, TextAreaField)
|
||||
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||
|
||||
from brewing import is_plausible_sg, run_summary
|
||||
|
||||
bp = Blueprint("brews", __name__)
|
||||
|
||||
|
||||
def repo():
|
||||
"""The request-scoped repository (one Mongo client per request)."""
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["beer_repo"]()
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""Redirect to the login page when there is no session."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not session.get("logged_in"):
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _last_reading_date(run) -> str | None:
|
||||
"""The ISO date of the most recent reading in a run."""
|
||||
last = run.last_reading()
|
||||
if last is None or last.date is None:
|
||||
return None
|
||||
return last.date.isoformat()
|
||||
|
||||
|
||||
class ReadingForm(FlaskForm):
|
||||
"""Add or update a gravity reading."""
|
||||
|
||||
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
|
||||
beer_run_type = StringField("Beer / cider name",
|
||||
validators=[Optional(), Length(max=120)])
|
||||
Date = DateField("Reading date", validators=[DataRequired()])
|
||||
sg = IntegerField("Gravity reading (points)",
|
||||
validators=[DataRequired(), NumberRange(min=900, max=1500)])
|
||||
final_reading = BooleanField("This is the final reading")
|
||||
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||
submit = SubmitField("Save reading")
|
||||
|
||||
|
||||
class RunMetaForm(FlaskForm):
|
||||
"""Edit the canonical metadata of a run.
|
||||
|
||||
These are the details that cannot be derived from the readings: the name
|
||||
the brew is actually known by, which tap it is on, and its original
|
||||
gravity.
|
||||
"""
|
||||
|
||||
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
|
||||
tap_number = IntegerField("Tap number",
|
||||
validators=[Optional(), NumberRange(min=1, max=99)])
|
||||
og_points = IntegerField("Original gravity (points)",
|
||||
validators=[Optional(), NumberRange(min=900, max=1500)])
|
||||
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
|
||||
submit = SubmitField("Save run")
|
||||
|
||||
|
||||
@bp.route("/brews", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
"""The brew-process page: record a reading, then review the runs."""
|
||||
form = ReadingForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
sg_points = int(form.sg.data)
|
||||
if not is_plausible_sg(sg_points):
|
||||
flash(
|
||||
f"{sg_points} is outside the usual hydrometer range "
|
||||
"(990-1200). It was saved anyway — double-check the reading.",
|
||||
"warning",
|
||||
)
|
||||
repo().upsert_reading(
|
||||
beer_run_id=str(form.beer_run_id.data),
|
||||
beer_type=(form.beer_run_type.data or "").strip(),
|
||||
sg=sg_points,
|
||||
reading_date=form.Date.data.isoformat(),
|
||||
final_reading=bool(form.final_reading.data),
|
||||
comment=(form.comment.data or "").strip(),
|
||||
)
|
||||
flash("Reading saved.", "success")
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
summaries = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
summaries.append(summary)
|
||||
|
||||
# Active runs: most recent activity first — that is what is fermenting now.
|
||||
active = sorted([s for s in summaries if s["active"]],
|
||||
key=lambda s: s["last_date"] or "", reverse=True)
|
||||
# Finished runs: most recently finished first.
|
||||
completed = sorted([s for s in summaries if not s["active"]],
|
||||
key=lambda s: s["final_date"] or "", reverse=True)
|
||||
|
||||
return render_template(
|
||||
"brews.html",
|
||||
form=form,
|
||||
summaries=summaries,
|
||||
active_runs=active,
|
||||
completed_runs=completed,
|
||||
next_tap=repo().next_free_tap_number(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def run_detail(run_id: str):
|
||||
"""One run in detail, with its readings and its editable metadata."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
flash(f"No run numbered {run_id}.", "error")
|
||||
return redirect(url_for("brews.index"))
|
||||
|
||||
meta = repo().run_metadata(run_id)
|
||||
meta_form = RunMetaForm()
|
||||
|
||||
if meta_form.validate_on_submit():
|
||||
repo().upsert_run_metadata(
|
||||
run_id,
|
||||
name=(meta_form.name.data or "").strip() or None,
|
||||
tap_number=meta_form.tap_number.data,
|
||||
og_points=meta_form.og_points.data,
|
||||
notes=(meta_form.notes.data or "").strip() or None,
|
||||
)
|
||||
flash("Run details saved.", "success")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
if not meta_form.is_submitted():
|
||||
meta_form.name.data = meta.get("name") or run.display_name()
|
||||
meta_form.tap_number.data = meta.get("tap_number")
|
||||
meta_form.og_points.data = meta.get("og_points")
|
||||
meta_form.notes.data = meta.get("notes", "")
|
||||
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["notes"] = meta.get("notes", "")
|
||||
|
||||
return render_template("run_detail.html", run=run, summary=summary,
|
||||
meta_form=meta_form, meta=meta)
|
||||
|
||||
|
||||
@bp.route("/api/runs")
|
||||
@login_required
|
||||
def api_runs():
|
||||
"""JSON feed of the run summaries, used by the charts on the analysis
|
||||
pages.
|
||||
|
||||
The charts fetch this rather than having the data inlined into the page,
|
||||
so one fetch serves every chart on a page.
|
||||
"""
|
||||
runs = repo().runs()
|
||||
metadata = repo().all_run_metadata()
|
||||
|
||||
payload = []
|
||||
for run in runs:
|
||||
meta = metadata.get(run.beer_run_id, {})
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
summary["last_date"] = _last_reading_date(run)
|
||||
payload.append(summary)
|
||||
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.route("/api/runs/<run_id>")
|
||||
@login_required
|
||||
def api_run(run_id: str):
|
||||
"""JSON detail of a single run."""
|
||||
run = repo().run(run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
meta = repo().run_metadata(run_id)
|
||||
summary = run_summary(run, og_override=meta.get("og_points"))
|
||||
summary["canonical_name"] = meta.get("name") or run.display_name()
|
||||
summary["tap_number"] = meta.get("tap_number")
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/final", methods=["POST"])
|
||||
@login_required
|
||||
def mark_final(run_id: str):
|
||||
"""Flag a reading as the final one for its run."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
changed = repo().set_final_reading(run_id, reading_date, True)
|
||||
if changed:
|
||||
flash("Final reading recorded.", "success")
|
||||
else:
|
||||
flash("That reading could not be updated.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
|
||||
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
|
||||
@login_required
|
||||
def unfinal(run_id: str):
|
||||
"""Un-flag a final reading, putting the run back into the active list."""
|
||||
reading_date = request.form.get("date", "")
|
||||
if not reading_date:
|
||||
flash("No reading date supplied.", "error")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
|
||||
repo().set_final_reading(run_id, reading_date, False)
|
||||
flash("Reading is no longer marked final.", "info")
|
||||
return redirect(url_for("brews.run_detail", run_id=run_id))
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Shared pytest fixtures for the beer_data test suite."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# The Flask app lives in src/flask and imports its siblings by bare name
|
||||
# (``import brewing``), so that directory has to be on the path.
|
||||
APP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"src", "flask")
|
||||
if APP_DIR not in sys.path:
|
||||
sys.path.insert(0, APP_DIR)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def secret_key_env(monkeypatch):
|
||||
"""The app refuses to start without SECRET_KEY — give the tests one."""
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mongo_client():
|
||||
"""An in-memory Mongo, shared by the repository the app builds."""
|
||||
import mongomock
|
||||
|
||||
return mongomock.MongoClient()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(mongo_client):
|
||||
"""A Flask test client backed by an in-memory Mongo (mongomock)."""
|
||||
from app_factory import create_app
|
||||
|
||||
app = create_app({
|
||||
"TESTING": True,
|
||||
"WTF_CSRF_ENABLED": False,
|
||||
"SECRET_KEY": "test-secret",
|
||||
"MONGO_CLIENT": mongo_client,
|
||||
})
|
||||
|
||||
with app.test_client() as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(mongo_client):
|
||||
"""A repository backed by an in-memory Mongo."""
|
||||
from mongo.repository import BeerRepository
|
||||
|
||||
return BeerRepository(client=mongo_client)
|
||||
|
||||
|
||||
def seed_readings(repository, rows):
|
||||
"""Insert reading documents directly, bypassing the app.
|
||||
|
||||
Each row is ``(beer_run_id, beer_type, sg, date, final_reading, comment)``.
|
||||
"""
|
||||
docs = []
|
||||
for run_id, beer_type, sg, date, final, comment in rows:
|
||||
docs.append({
|
||||
"beer_run_id": str(run_id),
|
||||
"beer_type": beer_type,
|
||||
"sg": str(sg),
|
||||
"date": date,
|
||||
"final_reading": "True" if final else "False",
|
||||
"comment": comment,
|
||||
})
|
||||
if docs:
|
||||
repository.readings.insert_many(docs)
|
||||
return docs
|
||||
@@ -0,0 +1,641 @@
|
||||
"""Tests for the web layer: login, the brew-process page and the API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import seed_readings
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def logged_in(client, repo):
|
||||
"""A client with an authenticated session and a user in the database."""
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
return client
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ public #
|
||||
|
||||
def test_healthz_is_public(client):
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"status": "ok"}
|
||||
|
||||
|
||||
def test_readyz_checks_the_database(client):
|
||||
response = client.get("/readyz")
|
||||
assert response.status_code == 200
|
||||
assert response.json == {"status": "ready"}
|
||||
|
||||
|
||||
def test_readyz_reports_not_ready_when_mongo_is_down(client, monkeypatch):
|
||||
"""A pod that cannot reach Mongo must not serve traffic."""
|
||||
from pymongo.errors import ServerSelectionTimeoutError
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise ServerSelectionTimeoutError("no mongo")
|
||||
|
||||
monkeypatch.setattr("mongo.repository.BeerRepository.__init__",
|
||||
lambda self, **kw: (_ for _ in ()).throw(ServerSelectionTimeoutError("no mongo")))
|
||||
response = client.get("/readyz")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_root_redirects_to_login(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 302
|
||||
assert "/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_login_page_renders(client):
|
||||
response = client.get("/login")
|
||||
assert response.status_code == 200
|
||||
assert b"Brew Log" in response.data
|
||||
|
||||
|
||||
def test_login_with_bad_credentials_is_rejected(client, repo):
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
response = client.post("/login", data={"username": "beer", "password": "wrong"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert b"not recognised" in response.data
|
||||
|
||||
|
||||
def test_protected_pages_redirect_when_logged_out(client):
|
||||
for path in ("/brews", "/runs/1"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 302, path
|
||||
assert "/login" in response.headers["Location"], path
|
||||
|
||||
|
||||
# ------------------------------------------------------------- brew pages #
|
||||
|
||||
def test_brews_page_lists_runs(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
||||
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
||||
])
|
||||
|
||||
response = logged_in.get("/brews")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "Ginger Beer" in body
|
||||
# The comment on the reading should be surfaced.
|
||||
assert "Keghing" in body
|
||||
|
||||
|
||||
def test_run_detail_page_renders(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
||||
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
||||
])
|
||||
|
||||
response = logged_in.get("/runs/1")
|
||||
assert response.status_code == 200
|
||||
body = response.data.decode()
|
||||
assert "Pale Ale" in body
|
||||
assert "Keghing" in body
|
||||
assert "5.25%" in body
|
||||
|
||||
|
||||
# ------------------------------------------------------------- write paths #
|
||||
|
||||
def test_recording_a_reading_stores_it(logged_in, repo):
|
||||
response = logged_in.post("/brews", data={
|
||||
"beer_run_id": "5",
|
||||
"beer_run_type": "Test Porter",
|
||||
"Date": "2026-03-04",
|
||||
"sg": "1045",
|
||||
"comment": "Hops in",
|
||||
}, follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
doc = repo.readings.find_one({"beer_run_id": "5"})
|
||||
assert doc is not None
|
||||
assert doc["sg"] == "1045"
|
||||
assert doc["beer_type"] == "Test Porter"
|
||||
assert doc["comment"] == "Hops in"
|
||||
assert doc["final_reading"] == "False"
|
||||
|
||||
|
||||
def test_recording_a_reading_twice_updates_in_place(logged_in, repo):
|
||||
"""The unique key is (date, beer_run_id) — no duplicate rows."""
|
||||
for sg in ("1045", "1035"):
|
||||
logged_in.post("/brews", data={
|
||||
"beer_run_id": "6",
|
||||
"beer_run_type": "Test",
|
||||
"Date": "2026-03-04",
|
||||
"sg": sg,
|
||||
})
|
||||
|
||||
count = repo.readings.count_documents({"beer_run_id": "6"})
|
||||
doc = repo.readings.find_one({"beer_run_id": "6"})
|
||||
assert count == 1
|
||||
assert doc["sg"] == "1035"
|
||||
|
||||
|
||||
def test_setting_a_final_reading_closes_the_run(logged_in, repo):
|
||||
seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")])
|
||||
|
||||
response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"},
|
||||
follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
|
||||
doc = repo.readings.find_one({"beer_run_id": "7"})
|
||||
assert doc["final_reading"] == "True"
|
||||
|
||||
|
||||
def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
||||
seed_readings(repo, [
|
||||
("7", "Ale", "1045", "2026-03-01", False, ""),
|
||||
("7", "Ale", "1010", "2026-03-20", True, ""),
|
||||
])
|
||||
|
||||
logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True)
|
||||
|
||||
doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"})
|
||||
assert doc["final_reading"] == "False"
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- API #
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- misc #
|
||||
|
||||
|
||||
def test_the_chart_library_is_loaded_exactly_once(logged_in):
|
||||
"""Regression: the chart script was included both in the base template
|
||||
and again by each page, so every chart was drawn twice."""
|
||||
for path in ("/brews", "/active", "/history"):
|
||||
body = logged_in.get(path).data.decode()
|
||||
assert body.count("js/charts.js") == 1, path
|
||||
assert body.count("chart.umd.js") == 1, path
|
||||
|
||||
|
||||
def test_static_assets_are_served(client):
|
||||
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||
"/static/img/logo.svg"):
|
||||
response = client.get(path)
|
||||
assert response.status_code == 200, path
|
||||
assert len(response.data) > 100, path
|
||||
|
||||
|
||||
def test_logout_clears_the_session(logged_in):
|
||||
response = logged_in.post("/logout", follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
# After logging out the protected page bounces back to the login page.
|
||||
response = logged_in.get("/brews")
|
||||
assert response.status_code == 302
|
||||
assert "/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
||||
response = logged_in.post("/brews", data={
|
||||
"beer_run_id": "12",
|
||||
"beer_run_type": "Odd",
|
||||
"Date": "2026-05-01",
|
||||
"sg": "950",
|
||||
}, follow_redirects=True)
|
||||
assert response.status_code == 200
|
||||
assert b"outside the usual hydrometer range" in response.data
|
||||
# It is still stored — the warning is advisory, not a rejection.
|
||||
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------- security: passwords #
|
||||
|
||||
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
|
||||
"""A legacy plaintext row still logs in, and is rewritten as a hash on
|
||||
the way in so plaintext credentials disappear from the database."""
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
assert repo.user_check("beer", "beer_pass") is True
|
||||
|
||||
doc = repo.users.find_one({"username": "beer"})
|
||||
assert doc["password"] != "beer_pass"
|
||||
assert "$" in doc["password"] # a werkzeug hash
|
||||
assert "password_migrated_at" in doc
|
||||
|
||||
|
||||
def test_a_failed_password_migration_still_lets_the_login_succeed(repo, caplog):
|
||||
"""If the upgrade write fails, the login must still work — but the
|
||||
failure is logged so the operator knows the password is still plaintext."""
|
||||
import logging
|
||||
|
||||
from pymongo.errors import PyMongoError
|
||||
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise PyMongoError("write failed")
|
||||
|
||||
original = repo.users.update_one
|
||||
repo.users.update_one = boom
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert repo.user_check("beer", "beer_pass") is True
|
||||
|
||||
repo.users.update_one = original
|
||||
|
||||
assert any("plain text" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_hashed_password_verifies(repo):
|
||||
from mongo.repository import hash_password
|
||||
|
||||
repo.users.insert_one({"username": "beer", "password": hash_password("beer_pass")})
|
||||
|
||||
assert repo.user_check("beer", "beer_pass") is True
|
||||
assert repo.user_check("beer", "wrong") is False
|
||||
|
||||
|
||||
def test_user_check_rejects_empty_credentials(repo):
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
assert repo.user_check("", "") is False
|
||||
assert repo.user_check("beer", "") is False
|
||||
assert repo.user_check("", "beer_pass") is False
|
||||
|
||||
|
||||
def test_user_check_rejects_a_nosql_operator_payload(repo):
|
||||
"""A dict must never be able to act as a Mongo query operator."""
|
||||
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||
|
||||
assert repo.user_check({"$ne": ""}, "anything") is False
|
||||
assert repo.user_check({"$gt": ""}, {"$ne": ""}) is False
|
||||
|
||||
|
||||
def test_run_id_cannot_be_used_as_an_operator(repo):
|
||||
"""A run id is coerced to a string before it reaches a query."""
|
||||
from mongo.repository import _as_id
|
||||
|
||||
assert _as_id({"$ne": ""}) == ""
|
||||
assert _as_id(20) == "20"
|
||||
assert _as_id("20") == "20"
|
||||
|
||||
|
||||
def test_the_app_refuses_to_start_without_a_secret_key(monkeypatch):
|
||||
"""A missing SECRET_KEY must stop the app, not fall back to a default."""
|
||||
import importlib
|
||||
|
||||
import app_factory
|
||||
|
||||
# Reload so the guard reads the environment as it is *now*. The exception
|
||||
# class must be taken from the reloaded module — reloading rebinds it, so
|
||||
# a class imported earlier would be a different object and pytest.raises
|
||||
# would not match it.
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
importlib.reload(app_factory)
|
||||
with pytest.raises(app_factory.ImproperlyConfigured):
|
||||
app_factory.create_app()
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||
importlib.reload(app_factory)
|
||||
|
||||
|
||||
def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
|
||||
import importlib
|
||||
|
||||
import app_factory
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "change-me-in-the-deployment")
|
||||
importlib.reload(app_factory)
|
||||
with pytest.raises(app_factory.ImproperlyConfigured):
|
||||
app_factory.create_app()
|
||||
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||
importlib.reload(app_factory)
|
||||
|
||||
|
||||
# ------------------------------------------------- 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"]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Unit tests for the pure brewing logic in ``brewing.py``.
|
||||
|
||||
These are the tests that matter most: they pin the gravity parsing, the ABV
|
||||
formula and the run-aggregation rules that the whole UI is built on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from brewing import (ABV_FACTOR, Reading, Run, abv_percent, assumed_og_abv_percent,
|
||||
days_between, final_reading, first_reading,
|
||||
is_plausible_sg, is_active, last_reading,
|
||||
legacy_abv_percent, parse_date, parse_sg_points,
|
||||
run_summary, sg_points_to_decimal)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- parsing #
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("1039", 1039),
|
||||
(1039, 1039),
|
||||
(1039.0, 1039),
|
||||
(" 1045 ", 1045),
|
||||
("1.039", 1039),
|
||||
(1.045, 1045),
|
||||
("None", None),
|
||||
("", None),
|
||||
(None, None),
|
||||
("abc", None),
|
||||
(True, None),
|
||||
])
|
||||
def test_parse_sg_points(raw, expected):
|
||||
assert parse_sg_points(raw) == expected
|
||||
|
||||
|
||||
def test_sg_points_to_decimal():
|
||||
assert sg_points_to_decimal(1039) == 1.039
|
||||
assert sg_points_to_decimal(1012) == 1.012
|
||||
assert sg_points_to_decimal(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("2026-11-08", date(2026, 11, 8)),
|
||||
(date(2026, 11, 8), date(2026, 11, 8)),
|
||||
("08/11/2026", date(2026, 11, 8)),
|
||||
("", None),
|
||||
(None, None),
|
||||
("not a date", None),
|
||||
])
|
||||
def test_parse_date(raw, expected):
|
||||
assert parse_date(raw) == expected
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- ABV #
|
||||
|
||||
def test_abv_uses_the_standard_formula():
|
||||
"""ABV% = (OG - FG) * 131.25, i.e. points drop * 0.13125."""
|
||||
# 1.050 -> 1.010 is a 40 point drop = 5.25% ABV.
|
||||
assert abv_percent(40) == 5.25
|
||||
assert abv_percent(40) == pytest.approx(40 * ABV_FACTOR)
|
||||
assert abv_percent(0) == 0.0
|
||||
assert abv_percent(None) is None
|
||||
|
||||
|
||||
def test_abv_matches_worked_examples():
|
||||
# Session IPA: 1.050 -> 1.010 = 5.25%
|
||||
assert abv_percent(50 - 10) == 5.25
|
||||
# 1.060 -> 1.012 = 6.29%
|
||||
assert abv_percent(60 - 12) == pytest.approx(6.29, abs=0.01)
|
||||
|
||||
|
||||
def test_legacy_formula_is_kept_for_comparison():
|
||||
"""The old formula is preserved verbatim so history can show the delta."""
|
||||
assert legacy_abv_percent(1050, 1010) == round(((1050 - 1010) / 7.36) + 0.5, 2)
|
||||
# The legacy formula claims 0.5% even with no drop at all.
|
||||
assert legacy_abv_percent(1010, 1010) == 0.5
|
||||
assert legacy_abv_percent(None, 1010) is None
|
||||
|
||||
|
||||
def test_legacy_formula_overstates_the_real_figure():
|
||||
"""The reason the history page compares the two at all."""
|
||||
standard = abv_percent(50 - 10)
|
||||
legacy = legacy_abv_percent(1050, 1010)
|
||||
assert legacy > standard
|
||||
|
||||
|
||||
def test_assumed_og_abv():
|
||||
assert assumed_og_abv_percent(1012) == pytest.approx(0.0)
|
||||
assert assumed_og_abv_percent(1010) == pytest.approx(0.26)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- plausibility #
|
||||
|
||||
@pytest.mark.parametrize("points,plausible", [
|
||||
(1010, True),
|
||||
(990, True),
|
||||
(1200, True),
|
||||
(989, False),
|
||||
(1201, False),
|
||||
(900, False),
|
||||
(None, False),
|
||||
])
|
||||
def test_is_plausible_sg(points, plausible):
|
||||
assert is_plausible_sg(points) is plausible
|
||||
|
||||
|
||||
# --------------------------------------------------------------- readings #
|
||||
|
||||
def _doc(run_id="1", beer_type="Pale Ale", sg="1045", date="2026-01-01",
|
||||
final="False", comment=""):
|
||||
return {
|
||||
"_id": f"id-{run_id}-{date}-{sg}",
|
||||
"beer_run_id": run_id,
|
||||
"beer_type": beer_type,
|
||||
"sg": sg,
|
||||
"date": date,
|
||||
"final_reading": final,
|
||||
"comment": comment,
|
||||
}
|
||||
|
||||
|
||||
def test_reading_from_doc_accepts_string_and_bool_flags():
|
||||
assert Reading.from_doc(_doc(final="True")).final_reading is True
|
||||
assert Reading.from_doc(_doc(final="False")).final_reading is False
|
||||
assert Reading.from_doc(_doc(final=True)).final_reading is True
|
||||
assert Reading.from_doc(_doc(final=False)).final_reading is False
|
||||
|
||||
|
||||
def test_reading_from_doc_cleans_whitespace():
|
||||
reading = Reading.from_doc(_doc(beer_type=" Pale Ale ", comment=" hops in "))
|
||||
assert reading.beer_type == "Pale Ale"
|
||||
assert reading.comment == "hops in"
|
||||
|
||||
|
||||
def test_first_and_last_reading():
|
||||
readings = [
|
||||
Reading.from_doc(_doc(date="2026-03-01", sg="1050")),
|
||||
Reading.from_doc(_doc(date="2026-01-01", sg="1060")),
|
||||
Reading.from_doc(_doc(date="2026-02-01", sg="1040")),
|
||||
]
|
||||
assert first_reading(readings).date == date(2026, 1, 1)
|
||||
assert last_reading(readings).date == date(2026, 3, 1)
|
||||
|
||||
|
||||
def test_days_between():
|
||||
assert days_between(date(2026, 1, 1), date(2026, 1, 8)) == 7
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- runs #
|
||||
|
||||
def test_run_orders_readings_chronologically():
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-03-01", sg="1010"),
|
||||
_doc(date="2026-01-01", sg="1060"),
|
||||
])
|
||||
assert [r.date for r in run.readings] == [date(2026, 1, 1), date(2026, 3, 1)]
|
||||
|
||||
|
||||
def test_run_original_and_final_gravity():
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1050"),
|
||||
_doc(date="2026-01-10", sg="1030"),
|
||||
_doc(date="2026-01-20", sg="1010", final="True"),
|
||||
])
|
||||
assert run.original_gravity() == 1050
|
||||
assert run.final_gravity() == 1010
|
||||
assert run.gravity_drop_points() == 40
|
||||
assert run.abv_percent() == 5.25
|
||||
|
||||
|
||||
def test_run_is_active_until_a_final_reading_exists():
|
||||
active = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
|
||||
assert active.is_active() is True
|
||||
assert is_active(active.readings) is True
|
||||
|
||||
finished = Run.from_docs("2", [
|
||||
_doc(run_id="2", date="2026-01-01", sg="1050"),
|
||||
_doc(run_id="2", date="2026-01-20", sg="1010", final="True"),
|
||||
])
|
||||
assert finished.is_active() is False
|
||||
assert final_reading(finished.readings) is not None
|
||||
|
||||
|
||||
def test_run_display_name_prefers_the_name_on_the_final_reading():
|
||||
"""The stored beer_type drifts across readings. The name on the final
|
||||
reading is the one the operator settled on."""
|
||||
run = Run.from_docs("4", [
|
||||
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
||||
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
|
||||
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
||||
])
|
||||
assert run.display_name() == "Nocturna Christmas beer"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_most_used_label():
|
||||
"""Without a final reading, the most-used name wins, breaking ties
|
||||
towards the most recent."""
|
||||
run = Run.from_docs("5", [
|
||||
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
|
||||
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
|
||||
])
|
||||
# The final reading's name wins.
|
||||
assert run.display_name() == "Going Nowhere fast"
|
||||
|
||||
run2 = Run.from_docs("6", [
|
||||
_doc(date="2024-12-03", beer_type="Balter"),
|
||||
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
|
||||
_doc(date="2024-12-10", beer_type="Balter"),
|
||||
])
|
||||
# No final reading; "Balter" was used twice.
|
||||
assert run2.display_name() == "Balter"
|
||||
|
||||
|
||||
def test_run_display_name_falls_back_to_the_run_number():
|
||||
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||
assert run.display_name() == "Run 7"
|
||||
|
||||
|
||||
def test_run_comments_are_collected_oldest_first():
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", comment="first"),
|
||||
_doc(date="2026-01-10", comment=""),
|
||||
_doc(date="2026-01-20", comment="last"),
|
||||
])
|
||||
assert run.comments() == ["first", "last"]
|
||||
|
||||
|
||||
def test_run_outliers_are_flagged():
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1045"),
|
||||
_doc(date="2026-01-10", sg="900"), # implausible
|
||||
_doc(date="2026-01-20", sg="1010"),
|
||||
])
|
||||
assert [r.sg_points for r in run.outlier_readings()] == [900]
|
||||
|
||||
|
||||
def test_run_days_in_fermenter_for_a_finished_run():
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1050"),
|
||||
_doc(date="2026-01-15", sg="1010", final="True"),
|
||||
])
|
||||
assert run.days_in_fermenter() == 14
|
||||
|
||||
|
||||
def test_run_days_in_fermenter_for_an_active_run_uses_today():
|
||||
run = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
|
||||
assert run.days_in_fermenter(today=date(2026, 1, 11)) == 10
|
||||
|
||||
|
||||
def test_projected_abv_is_never_negative():
|
||||
"""Regression: the legacy assumed-OG of 1012 produced negative alcohol
|
||||
whenever the current reading was above 1012 (a normal OG)."""
|
||||
run = Run.from_docs("20", [
|
||||
_doc(run_id="20", beer_type="Out on patio", date="2026-11-08", sg="1039"),
|
||||
])
|
||||
projected = run.projected_abv_percent()
|
||||
# No OG is known for a single-reading run, so there is no estimate —
|
||||
# and certainly no negative one.
|
||||
assert projected is None
|
||||
|
||||
run2 = Run.from_docs("20b", [
|
||||
_doc(run_id="20b", date="2026-11-08", sg="1039"),
|
||||
_doc(run_id="20b", date="2026-11-15", sg="1020"),
|
||||
])
|
||||
assert run2.projected_abv_percent() >= 0.0
|
||||
|
||||
|
||||
def test_projected_abv_uses_the_first_reading_as_the_og():
|
||||
run = Run.from_docs("21", [
|
||||
_doc(run_id="21", date="2026-11-08", sg="1045"),
|
||||
_doc(run_id="21", date="2026-11-15", sg="1030"),
|
||||
])
|
||||
# 15 point drop = 1.97%
|
||||
assert run.projected_abv_percent() == pytest.approx(1.97)
|
||||
|
||||
|
||||
def test_projected_abv_is_unknown_for_a_single_reading_with_no_og():
|
||||
"""One reading and no recorded OG: there is no reliable estimate, so the
|
||||
figure is None (the UI shows a dash) rather than a misleading number."""
|
||||
run = Run.from_docs("22", [_doc(run_id="22", date="2026-11-08", sg="1005")])
|
||||
assert run.projected_abv_percent() is None
|
||||
|
||||
|
||||
def test_projected_abv_uses_a_recorded_og_override():
|
||||
"""A run with a single reading can still be estimated once an OG has
|
||||
been recorded against it."""
|
||||
run = Run.from_docs("23", [_doc(run_id="23", date="2026-11-08", sg="1010")])
|
||||
assert run.projected_abv_percent(og_override=1045) == pytest.approx(4.59)
|
||||
|
||||
|
||||
def test_projected_abv_is_none_when_gravity_rises():
|
||||
"""Gravity rising during fermentation is an anomaly, not alcohol."""
|
||||
run = Run.from_docs("24", [
|
||||
_doc(run_id="24", date="2026-11-08", sg="1010"),
|
||||
_doc(run_id="24", date="2026-11-15", sg="1015"),
|
||||
])
|
||||
assert run.projected_abv_percent() is None
|
||||
|
||||
|
||||
def test_run_summary_shape():
|
||||
run = Run.from_docs("9", [
|
||||
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-01", sg="1050"),
|
||||
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-20", sg="1010",
|
||||
final="True", comment="Keghing"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
assert summary["beer_run_id"] == "9"
|
||||
assert summary["name"] == "Pale Ale"
|
||||
assert summary["active"] is False
|
||||
assert summary["abv"] == 5.25
|
||||
assert summary["legacy_abv"] == legacy_abv_percent(1050, 1010)
|
||||
assert summary["og_points"] == 1050
|
||||
assert summary["fg_points"] == 1010
|
||||
assert summary["comments"] == ["Keghing"]
|
||||
assert len(summary["readings"]) == 2
|
||||
assert summary["readings"][0]["date"] == "2026-01-01"
|
||||
|
||||
|
||||
def test_run_summary_handles_an_empty_run():
|
||||
summary = run_summary(Run.from_docs("99", []))
|
||||
assert summary["abv"] is None
|
||||
assert summary["reading_count"] == 0
|
||||
assert summary["name"] == "Run 99"
|
||||
|
||||
|
||||
def test_point_scale_is_the_digits_after_the_decimal():
|
||||
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
|
||||
reading and must not be labelled as a point count."""
|
||||
run = Run.from_docs("1", [
|
||||
_doc(date="2026-01-01", sg="1039"),
|
||||
])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_decimal"] == "1.039"
|
||||
assert summary["min_sg_point_scale"] == "39"
|
||||
|
||||
|
||||
def test_point_scale_for_a_round_reading():
|
||||
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
|
||||
summary = run_summary(run)
|
||||
assert summary["min_sg_point_scale"] == "10"
|
||||
assert summary["min_sg_decimal"] == "1.010"
|
||||
|
||||
|
||||
def test_an_unfinished_run_has_no_measured_abv():
|
||||
"""A run still in the fermenter has a projection, not a measurement.
|
||||
Reporting a "measured" ABV for it would be wrong."""
|
||||
run = Run.from_docs("20", [
|
||||
_doc(run_id="20", date="2026-11-01", sg="1045"),
|
||||
_doc(run_id="20", date="2026-11-08", sg="1039"),
|
||||
])
|
||||
assert run.is_active() is True
|
||||
assert run.abv_percent() is None
|
||||
assert run.projected_abv_percent() is not None
|
||||
|
||||
summary = run_summary(run)
|
||||
assert summary["abv"] is None
|
||||
assert summary["projected_abv"] is not None
|
||||
assert summary["legacy_abv"] is None
|
||||
|
||||
|
||||
def test_a_finished_run_still_has_a_measured_abv():
|
||||
run = Run.from_docs("21", [
|
||||
_doc(run_id="21", date="2026-11-01", sg="1050"),
|
||||
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
|
||||
])
|
||||
assert run.abv_percent() == 5.25
|
||||
summary = run_summary(run)
|
||||
assert summary["abv"] == 5.25
|
||||
assert summary["legacy_abv"] is not None
|
||||
Reference in New Issue
Block a user