Compare commits
15
Commits
dev
...
b73df93f20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73df93f20 | ||
|
|
55c5de9d61 | ||
|
|
c0c6b4c1fc | ||
|
|
9d3ce1d2c4 | ||
|
|
78b3b10131 | ||
|
|
a48fb1c566 | ||
|
|
e9ce0dcf9b | ||
|
|
757774168d | ||
|
|
ba65ec5a8f | ||
|
|
6e1189e319 | ||
|
|
1828dcb81d | ||
|
|
24cdd452c5 | ||
|
|
fb65ba4ee0 | ||
|
|
718c8e87b3 | ||
|
|
4baf08813c |
+134
-12
@@ -2,28 +2,67 @@ name: Build and Push Image
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# NOTE: no actions/setup-python here. That action downloads a Python
|
||||||
|
# build from GitHub, which this runner cannot reach reliably - the job
|
||||||
|
# hung on it. The runner image already carries a usable python3, so
|
||||||
|
# the tests use it directly.
|
||||||
|
- name: Show the Python version
|
||||||
|
run: python3 --version
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python3 -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python -m pytest tests/ -q
|
||||||
|
|
||||||
|
# NOTE: the build job deliberately does NOT have a kubeconfig. The
|
||||||
|
# `KUBEC_CONFIG_BUILDX` secret is cluster-admin-equivalent, and a build step
|
||||||
|
# that has no need of the cluster should not be able to read it. The deploy
|
||||||
|
# step, added in a later PR, gets its own job with the kubeconfig - that way
|
||||||
|
# a compromised dependency in the build cannot reach the cluster.
|
||||||
build:
|
build:
|
||||||
name: Build and push image
|
name: Build and push image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
|
needs: test
|
||||||
if: gitea.ref == 'refs/heads/master'
|
if: gitea.ref == 'refs/heads/master'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Create Kubeconfig
|
- name: Set up Docker Buildx (remote -> persistent buildkit daemon)
|
||||||
run: |
|
|
||||||
mkdir $HOME/.kube
|
|
||||||
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
with:
|
with:
|
||||||
driver: kubernetes
|
# The `kubernetes` driver talks to the cluster API, which needs the
|
||||||
driver-opts: |
|
# k3s CA certificate. The job container does not have it, so the
|
||||||
namespace=gitea-runner
|
# bootstrap failed with "x509: certificate signed by unknown
|
||||||
qemu.install=true
|
# 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
|
- name: Login to Docker Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -38,7 +77,10 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
file: base.Dockerfile
|
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: |
|
tags: |
|
||||||
git.aridgwayweb.com/armistace/beer_base_image:latest
|
git.aridgwayweb.com/armistace/beer_base_image:latest
|
||||||
|
|
||||||
@@ -48,6 +90,86 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
file: flask.Dockerfile
|
file: flask.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
tags: |
|
tags: |
|
||||||
git.aridgwayweb.com/armistace/beer-data:latest
|
git.aridgwayweb.com/armistace/beer-data:latest
|
||||||
|
|
||||||
|
# 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
|
||||||
|
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config
|
||||||
|
|
||||||
|
- name: Install kubectl
|
||||||
|
run: |
|
||||||
|
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-deployment.yaml
|
||||||
|
kubectl apply -f kube/beer-service.yaml
|
||||||
|
|
||||||
|
# The image tag is `:latest`, which is immutable in the manifest, so
|
||||||
|
# `kubectl apply` alone does NOT roll the pods. Without the restart
|
||||||
|
# the deployment reports success while the old pods keep serving
|
||||||
|
# the old image.
|
||||||
|
kubectl -n beer rollout restart deployment/beer-deployment
|
||||||
|
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
|
||||||
|
|
||||||
|
- name: Verify the 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: Smoke test the deployed app
|
||||||
|
run: |
|
||||||
|
# The pods are behind a NodePort. Hit the service directly from
|
||||||
|
# inside the cluster.
|
||||||
|
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)"
|
||||||
|
|||||||
@@ -1,7 +1,104 @@
|
|||||||
# BEER SG data storer
|
# Brew Log
|
||||||
|
|
||||||
This container will store SG data for beer runs in Mongo and then use that data to calculate Alchohol Content of each beer
|
Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into
|
||||||
|
useful figures — alcohol by volume, days in the fermenter, gravity trends.
|
||||||
|
|
||||||
It's pretty loose and requires the Data Entry operator to know whats happening
|
Flask app, Mongo backend, no build step for the front end.
|
||||||
|
|
||||||
Luck for me... That's me!
|
## 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.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/flask/
|
||||||
|
beer_data.py entry point; exposes `app` and `create_app`
|
||||||
|
app_factory.py application factory, request-scoped Mongo repository
|
||||||
|
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
|
||||||
|
views/ blueprints: auth, brews, (later) analysis
|
||||||
|
mongo/repository.py Mongo access for the beer_data and beer_runs collections
|
||||||
|
templates/ Jinja templates
|
||||||
|
static/ CSS, JS, fonts, logo (all served locally — no CDN)
|
||||||
|
tests/ pytest suite
|
||||||
|
```
|
||||||
|
|
||||||
|
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
|
||||||
|
dependency, so the maths is unit-testable on its own.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `beer_data` — one document per gravity reading
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `_id` | string | 24 random characters |
|
||||||
|
| `beer_run_id` | string | The run this reading belongs to, e.g. `"20"` |
|
||||||
|
| `beer_type` | string | Beer/cider name. **Free text, per reading** — it drifts across a run |
|
||||||
|
| `sg` | string | Specific gravity in *points*: `1039` means 1.039 |
|
||||||
|
| `date` | string | `YYYY-MM-DD` |
|
||||||
|
| `final_reading` | string | `"True"` / `"False"` — marks the last reading of a run |
|
||||||
|
| `comment` | string | Free text |
|
||||||
|
|
||||||
|
The unique key is `(date, beer_run_id)`: re-submitting a reading for the same
|
||||||
|
run and day updates it in place rather than creating a duplicate.
|
||||||
|
|
||||||
|
A run is **active** while it has no reading with `final_reading == "True"`.
|
||||||
|
|
||||||
|
### `beer_runs` — one document per run (metadata)
|
||||||
|
|
||||||
|
Added alongside the readings to hold what cannot be derived from them: the
|
||||||
|
canonical beer name, the tap number, the recorded original gravity, and brew
|
||||||
|
notes. This collection is only ever inserted into, never written over by the
|
||||||
|
backfill.
|
||||||
|
|
||||||
|
## Alcohol by volume
|
||||||
|
|
||||||
|
Standard homebrew formula:
|
||||||
|
|
||||||
|
```
|
||||||
|
ABV% = (OG - FG) × 131.25
|
||||||
|
```
|
||||||
|
|
||||||
|
With gravity stored in points, the drop is already in points, so
|
||||||
|
`ABV% = points_drop × 0.13125`.
|
||||||
|
|
||||||
|
> The previous version of this app used `(max - min) / 7.36 + 0.5`, which is not
|
||||||
|
> a recognised brewing formula and overstated every result. The old figure is
|
||||||
|
> still computed by `brewing.legacy_abv_percent()` so the history page can show
|
||||||
|
> the difference between the two.
|
||||||
|
|
||||||
|
For a run still in the fermenter the figure is an **estimate**: it uses the
|
||||||
|
run's first reading as the original gravity. A run with a single reading and no
|
||||||
|
recorded OG has no reliable estimate, and the UI shows a dash rather than a
|
||||||
|
misleading number.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The image is built and pushed by `.gitea/workflows/build_push.yml` on push to
|
||||||
|
`master`. See [RUNBOOK.md](RUNBOOK.md) for the cluster side.
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
# RUNBOOK — Brew Log (beer_data)
|
||||||
|
|
||||||
|
Operational notes for the `beer` namespace deployment.
|
||||||
|
|
||||||
|
## What runs where
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Namespace | `beer` |
|
||||||
|
| Deployment | `beer-deployment` (3 replicas) |
|
||||||
|
| Image | `git.aridgwayweb.com/armistace/beer-data:latest` |
|
||||||
|
| Service | `beer-service`, NodePort **30015** → container port 5000 |
|
||||||
|
| Database | MongoDB at `192.168.178.229`, database `beer_db` |
|
||||||
|
| Secret | `beercred` (registry pull credentials) |
|
||||||
|
|
||||||
|
Access: `http://<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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
WORKDIR /beer_data
|
||||||
|
|
||||||
COPY requirements.txt .
|
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 git.aridgwayweb.com/armistace/beer_base_image AS flask
|
||||||
#FROM beer_base_image AS flask
|
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
||||||
ADD src /beer_data/src
|
ADD src /beer_data/src
|
||||||
|
|
||||||
ENV FLASK_ENV production
|
# The app lives in src/flask and imports its siblings by bare name
|
||||||
ENV FLASK_DEBUG 1
|
# (``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,91 @@
|
|||||||
|
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
|
||||||
|
image: git.aridgwayweb.com/armistace/beer-data:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 5000
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: MONGO_HOST
|
||||||
|
value: "192.168.178.229"
|
||||||
|
- name: MONGO_USER
|
||||||
|
value: "root"
|
||||||
|
# MONGO_PASS and SECRET_KEY come from the live Secret - they are
|
||||||
|
# deliberately NOT in this file. See RUNBOOK.md.
|
||||||
|
- name: MONGO_PASS
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: beer-db
|
||||||
|
key: mongo_pass
|
||||||
|
- name: SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: beer-app
|
||||||
|
key: secret_key
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
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]
|
# Pinned to the versions the test suite runs against. The image is rebuilt
|
||||||
flask
|
# on every merge, so an unpinned dependency can change behaviour underneath a
|
||||||
requests_html
|
# deploy with no commit to show for it.
|
||||||
beautifulsoup4
|
Flask==3.1.3
|
||||||
click
|
Flask-WTF==1.3.0
|
||||||
Flask-WTF
|
waitress==3.0.2
|
||||||
bootstrap-flask
|
pymongo==4.19.0
|
||||||
waitress
|
requests==2.32.5
|
||||||
bokeh
|
|
||||||
pandas
|
|
||||||
duckdb
|
|
||||||
|
|||||||
@@ -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,164 @@
|
|||||||
|
"""Application factory for the beer_data web app.
|
||||||
|
|
||||||
|
The application is deliberately small: a handful of pages, one Mongo database,
|
||||||
|
no build step for the front end. Everything server-side lives in blueprints
|
||||||
|
under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import (Flask, g, redirect, request, session, url_for)
|
||||||
|
from flask_wtf import CSRFProtect
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
|
|
||||||
|
|
||||||
|
class ImproperlyConfigured(RuntimeError):
|
||||||
|
"""Raised when the app is missing configuration it cannot safely default.
|
||||||
|
|
||||||
|
Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never
|
||||||
|
exported one, so the app defines its own. It is a ``RuntimeError`` so it
|
||||||
|
is not mistaken for an HTTP error the app should try to render a page for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_secret_key() -> str:
|
||||||
|
"""Return the Flask secret key, refusing to start without a real one.
|
||||||
|
|
||||||
|
A known default secret key means anyone can forge a session cookie and
|
||||||
|
log in as the operator, so a missing or placeholder value must stop the
|
||||||
|
app rather than silently fall back. The one exception is the test
|
||||||
|
suite, which supplies its own key through the config.
|
||||||
|
"""
|
||||||
|
secret = os.getenv("SECRET_KEY", "").strip()
|
||||||
|
if not secret:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"SECRET_KEY is not set. Export a random value, e.g. "
|
||||||
|
"`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`."
|
||||||
|
)
|
||||||
|
if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"SECRET_KEY is still the placeholder value. Set a real secret key."
|
||||||
|
)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config: dict | None = None) -> Flask:
|
||||||
|
"""Build and configure the Flask application."""
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder="templates",
|
||||||
|
static_folder="static",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.config.update(
|
||||||
|
SECRET_KEY=_require_secret_key(),
|
||||||
|
MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"),
|
||||||
|
MONGO_USER=os.getenv("MONGO_USER", "root"),
|
||||||
|
MONGO_PASS=os.getenv("MONGO_PASS", ""),
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
# Flask-WTF needs this for the login form to render and validate.
|
||||||
|
WTF_CSRF_ENABLED=True,
|
||||||
|
)
|
||||||
|
if config:
|
||||||
|
app.config.update(config)
|
||||||
|
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Request-scoped repository
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
from mongo.repository import BeerRepository
|
||||||
|
|
||||||
|
def get_repo() -> BeerRepository:
|
||||||
|
"""One Mongo client per request, closed when the request ends."""
|
||||||
|
if "repo" not in g:
|
||||||
|
# A client can be injected via config (used by the test suite);
|
||||||
|
# otherwise build one from the environment.
|
||||||
|
injected = app.config.get("MONGO_CLIENT")
|
||||||
|
g.repo = BeerRepository(client=injected) if injected else BeerRepository()
|
||||||
|
return g.repo
|
||||||
|
|
||||||
|
app.extensions["beer_repo"] = get_repo
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def _close_repo(_exc=None):
|
||||||
|
repo = g.pop("repo", None)
|
||||||
|
# An injected client (the test suite) is owned by the caller — do not
|
||||||
|
# close it, or the next request in the same test gets a dead client.
|
||||||
|
if repo is not None and app.config.get("MONGO_CLIENT") is None:
|
||||||
|
repo.client.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Auth helpers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def logged_in() -> bool:
|
||||||
|
return bool(session.get("logged_in"))
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
return {
|
||||||
|
"logged_in": logged_in(),
|
||||||
|
"current_path": request.path,
|
||||||
|
"app_name": "Brew Log",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Error handlers
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def _not_found(_exc):
|
||||||
|
return render_template("error.html", code=404,
|
||||||
|
message="That page does not exist."), 404
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def _server_error(_exc): # pragma: no cover - defensive
|
||||||
|
return render_template("error.html", code=500,
|
||||||
|
message="Something broke on our side."), 500
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Blueprints
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
from views.auth import bp as auth_bp
|
||||||
|
from views.brews import bp as brews_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(brews_bp)
|
||||||
|
|
||||||
|
@app.route("/healthz")
|
||||||
|
def healthz():
|
||||||
|
"""Liveness probe. Never touches Mongo — it answers "is the process
|
||||||
|
up", not "is the app working". A database outage must not cause the
|
||||||
|
kubelet to restart a perfectly healthy process."""
|
||||||
|
return {"status": "ok"}, 200
|
||||||
|
|
||||||
|
@app.route("/readyz")
|
||||||
|
def readyz():
|
||||||
|
"""Readiness probe. Checks that Mongo is actually reachable, so a
|
||||||
|
pod with no database is taken out of the Service's endpoints instead
|
||||||
|
of serving errors."""
|
||||||
|
try:
|
||||||
|
repo = get_repo()
|
||||||
|
repo.client.admin.command("ping")
|
||||||
|
except Exception as exc: # noqa: BLE001 - any failure means not ready
|
||||||
|
return {"status": "not ready", "error": str(exc)}, 503
|
||||||
|
return {"status": "ready"}, 200
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
if logged_in():
|
||||||
|
return redirect(url_for("brews.index"))
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(*args, **kwargs): # noqa: D401 - re-export for views
|
||||||
|
"""Small shim so view modules can import ``render_template`` from one place."""
|
||||||
|
from flask import render_template as _rt
|
||||||
|
|
||||||
|
return _rt(*args, **kwargs)
|
||||||
+28
-135
@@ -1,140 +1,33 @@
|
|||||||
from bokeh.core.enums import SpatialUnitsType
|
"""Entry point for the beer_data web application.
|
||||||
import mongo.build_db as beer_database
|
|
||||||
import mongo.query_db as beer_database_query
|
|
||||||
from table import table_builder
|
|
||||||
|
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, session
|
Run in development:
|
||||||
from flask_wtf import FlaskForm, CSRFProtect
|
|
||||||
from flask_bootstrap import Bootstrap5
|
python -m beer_data
|
||||||
from wtforms import StringField, SubmitField, DateField, IntegerField, PasswordField, DecimalField, RadioField, TextAreaField, BooleanField
|
|
||||||
from wtforms.validators import DataRequired, Length, Optional
|
Run in production (what the container does):
|
||||||
from waitress import serve
|
|
||||||
from bokeh.models.layouts import HBox
|
waitress-serve --listen=0.0.0.0:5000 beer_data:app
|
||||||
from bokeh.plotting import column
|
"""
|
||||||
from charts import BeerCharts
|
|
||||||
from bokeh.io import output_file, show
|
from __future__ import annotations
|
||||||
from bokeh.layouts import row
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app_factory import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# The WSGI callable used by waitress / gunicorn.
|
||||||
|
application = app
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
def main() -> None:
|
||||||
app.secret_key = 'testsecret' #this value will change
|
"""Run the development server."""
|
||||||
|
host = os.getenv("HOST", "0.0.0.0")
|
||||||
bootstrap = Bootstrap5(app)
|
port = int(os.getenv("PORT", "5000"))
|
||||||
|
debug = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||||
csrf = CSRFProtect(app)
|
app.run(host=host, port=port, debug=debug)
|
||||||
# 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()
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
if form.validate_on_submit():
|
main()
|
||||||
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')
|
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
"""Pure brewing logic for the beer_data app.
|
||||||
|
|
||||||
|
Everything in this module is a pure function over plain data (no Flask, no Mongo,
|
||||||
|
no pandas) so the maths can be unit-tested without a database.
|
||||||
|
|
||||||
|
Gravity convention
|
||||||
|
------------------
|
||||||
|
The Mongo collection stores specific gravity as integer *points*: the digits after
|
||||||
|
the decimal point of the usual 1.0xx reading. ``1039`` means 1.039. This matches
|
||||||
|
how the readings were entered and keeps the stored data unchanged.
|
||||||
|
|
||||||
|
Alcohol by volume
|
||||||
|
-----------------
|
||||||
|
Standard homebrew formula (Charlie Papazian / "The Joy of Homebrewing"):
|
||||||
|
|
||||||
|
ABV% = (OG - FG) * 131.25
|
||||||
|
|
||||||
|
With gravities expressed in points the difference is directly in points, so:
|
||||||
|
|
||||||
|
ABV% = points_drop * 0.13125
|
||||||
|
|
||||||
|
The legacy application used ``(max - min) / 7.36 + 0.5`` which is not a recognised
|
||||||
|
formula and overstates the result (it also returns 0.5% for a run with no drop at
|
||||||
|
all). :func:`legacy_abv_percent` keeps the old maths available so the history page
|
||||||
|
can show the difference between the two, but new code uses :func:`abv_percent`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ABV_FACTOR",
|
||||||
|
"ASSUMED_ORIGINAL_GRAVITY",
|
||||||
|
"Reading",
|
||||||
|
"Run",
|
||||||
|
"abv_percent",
|
||||||
|
"assumed_og_abv_percent",
|
||||||
|
"days_between",
|
||||||
|
"final_reading",
|
||||||
|
"first_reading",
|
||||||
|
"gravity_drop_points",
|
||||||
|
"is_active",
|
||||||
|
"is_plausible_sg",
|
||||||
|
"last_reading",
|
||||||
|
"legacy_abv_percent",
|
||||||
|
"parse_date",
|
||||||
|
"parse_sg_points",
|
||||||
|
"run_summary",
|
||||||
|
"sg_points_to_decimal",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Conversion factor for points-drop -> percent alcohol (131.25 / 1000).
|
||||||
|
ABV_FACTOR = 0.13125
|
||||||
|
|
||||||
|
#: Original gravity assumed for an in-progress run when no OG was recorded.
|
||||||
|
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
|
||||||
|
ASSUMED_ORIGINAL_GRAVITY = 1012
|
||||||
|
|
||||||
|
#: Plausible range for a hydrometer reading, in points.
|
||||||
|
_MIN_PLAUSIBLE_SG = 990
|
||||||
|
_MAX_PLAUSIBLE_SG = 1200
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parsing helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def parse_sg_points(raw: object) -> int | None:
|
||||||
|
"""Return the gravity in points for *raw*, or ``None`` if it is not usable.
|
||||||
|
|
||||||
|
Accepts ints, floats and strings. ``"1039"``, ``1039`` and ``1039.0`` all
|
||||||
|
become ``1039``. Values like ``"1.039"`` are also accepted and converted, and
|
||||||
|
anything unparseable yields ``None`` rather than raising.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, bool): # bool is an int subclass; never a gravity
|
||||||
|
return None
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
value = float(raw)
|
||||||
|
else:
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = float(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value > 100: # already stored as points (1039)
|
||||||
|
return int(round(value))
|
||||||
|
if value >= 0.9: # a decimal reading such as 1.039
|
||||||
|
return int(round(value * 1000))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sg_points_to_decimal(points: int | None) -> float | None:
|
||||||
|
"""Convert points (``1039``) to the conventional decimal reading (``1.039``)."""
|
||||||
|
if points is None:
|
||||||
|
return None
|
||||||
|
return round(points / 1000.0, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(raw: object) -> date | None:
|
||||||
|
"""Parse a ``YYYY-MM-DD`` date, tolerating the odd alternative layout."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, datetime):
|
||||||
|
return raw.date()
|
||||||
|
if isinstance(raw, date):
|
||||||
|
return raw
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: object) -> bool:
|
||||||
|
"""Interpret the stored ``final_reading`` flag.
|
||||||
|
|
||||||
|
The collection stores it as the *string* ``"True"``/``"False"`` for historic
|
||||||
|
rows and as a real boolean for newer ones, so both are accepted.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
return str(value).strip().lower() in {"true", "1", "yes", "y", "t"}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Domain objects
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Reading:
|
||||||
|
"""A single gravity reading taken on a given day."""
|
||||||
|
|
||||||
|
date: date | None
|
||||||
|
sg_points: int | None
|
||||||
|
final_reading: bool = False
|
||||||
|
comment: str = ""
|
||||||
|
beer_type: str = ""
|
||||||
|
_id: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sg_decimal(self) -> float | None:
|
||||||
|
return sg_points_to_decimal(self.sg_points)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_plausible(self) -> bool:
|
||||||
|
return is_plausible_sg(self.sg_points)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_doc(cls, doc: dict) -> "Reading":
|
||||||
|
"""Build a :class:`Reading` from a raw Mongo document."""
|
||||||
|
return cls(
|
||||||
|
date=parse_date(doc.get("date")),
|
||||||
|
sg_points=parse_sg_points(doc.get("sg")),
|
||||||
|
final_reading=_as_bool(doc.get("final_reading")),
|
||||||
|
comment=(doc.get("comment") or "").strip(),
|
||||||
|
beer_type=(doc.get("beer_type") or "").strip(),
|
||||||
|
_id=str(doc.get("_id") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Run:
|
||||||
|
"""All readings that belong to one brew run."""
|
||||||
|
|
||||||
|
beer_run_id: str
|
||||||
|
readings: list[Reading] = field(default_factory=list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_docs(cls, beer_run_id: str, docs: Iterable[dict]) -> "Run":
|
||||||
|
readings = [Reading.from_doc(d) for d in docs]
|
||||||
|
readings.sort(key=lambda r: (r.date or date.min, r.sg_points or 0))
|
||||||
|
return cls(beer_run_id=str(beer_run_id), readings=readings)
|
||||||
|
|
||||||
|
# -- aggregate accessors ------------------------------------------------ #
|
||||||
|
|
||||||
|
def sg_values(self) -> list[int]:
|
||||||
|
"""Gravity points of every *usable* reading, in chronological order."""
|
||||||
|
return [r.sg_points for r in self.readings if r.sg_points is not None]
|
||||||
|
|
||||||
|
def original_gravity(self) -> int | None:
|
||||||
|
"""OG = the first usable reading of the run."""
|
||||||
|
values = self.sg_values()
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
def final_gravity(self) -> int | None:
|
||||||
|
"""FG = the last usable reading of the run (if the run is finished)."""
|
||||||
|
values = self.sg_values()
|
||||||
|
return values[-1] if values else None
|
||||||
|
|
||||||
|
def max_sg(self) -> int | None:
|
||||||
|
values = self.sg_values()
|
||||||
|
return max(values) if values else None
|
||||||
|
|
||||||
|
def min_sg(self) -> int | None:
|
||||||
|
values = self.sg_values()
|
||||||
|
return min(values) if values else None
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return is_active(self.readings)
|
||||||
|
|
||||||
|
def final_reading(self) -> Reading | None:
|
||||||
|
return final_reading(self.readings)
|
||||||
|
|
||||||
|
def first_reading(self) -> Reading | None:
|
||||||
|
return first_reading(self.readings)
|
||||||
|
|
||||||
|
def last_reading(self) -> Reading | None:
|
||||||
|
return last_reading(self.readings)
|
||||||
|
|
||||||
|
def gravity_drop_points(self) -> int | None:
|
||||||
|
og, fg = self.original_gravity(), self.final_gravity()
|
||||||
|
if og is None or fg is None:
|
||||||
|
return None
|
||||||
|
return og - fg
|
||||||
|
|
||||||
|
def abv_percent(self) -> float | None:
|
||||||
|
"""Standard ABV for a finished run."""
|
||||||
|
drop = self.gravity_drop_points()
|
||||||
|
return abv_percent(drop) if drop is not None else None
|
||||||
|
|
||||||
|
def projected_abv_percent(self, assumed_og: int = ASSUMED_ORIGINAL_GRAVITY,
|
||||||
|
og_override: int | None = None) -> float | None:
|
||||||
|
# `assumed_og` is retained for API compatibility but is deliberately
|
||||||
|
# NOT used as a fallback — see the docstring.
|
||||||
|
"""ABV estimate for an *unfinished* run, or ``None`` if unknowable.
|
||||||
|
|
||||||
|
The original gravity is taken from, in order of preference:
|
||||||
|
|
||||||
|
1. ``og_override`` — the OG recorded against the run by the user.
|
||||||
|
2. The run's first reading, when there are two or more readings.
|
||||||
|
The first reading of a run IS its original gravity — that is what
|
||||||
|
a reading taken on brew day is for.
|
||||||
|
With a single reading and no recorded OG there is NO reliable
|
||||||
|
estimate: the reading may have been taken on brew day or mid-
|
||||||
|
fermentation. The legacy app assumed 1012 anyway, which produced
|
||||||
|
*negative* alcohol whenever the reading was above 1012 — a perfectly
|
||||||
|
normal OG. Rather than show a misleading figure, ``None`` is
|
||||||
|
returned and the UI shows a dash until an OG is known. Record the OG
|
||||||
|
against the run and the estimate appears.
|
||||||
|
"""
|
||||||
|
values = self.sg_values()
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if og_override is not None:
|
||||||
|
og = og_override
|
||||||
|
elif len(values) >= 2:
|
||||||
|
og = values[0]
|
||||||
|
else:
|
||||||
|
# A single reading and no recorded OG: there is no reliable
|
||||||
|
# original gravity to measure against, so there is no estimate.
|
||||||
|
return None
|
||||||
|
|
||||||
|
fg = self.min_sg()
|
||||||
|
if fg is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
drop = og - fg
|
||||||
|
if drop <= 0:
|
||||||
|
# Gravity has risen during fermentation — an anomaly, not alcohol.
|
||||||
|
return None
|
||||||
|
|
||||||
|
return abv_percent(drop)
|
||||||
|
|
||||||
|
def days_in_fermenter(self, today: date | None = None) -> int | None:
|
||||||
|
first = self.first_reading()
|
||||||
|
if first is None or first.date is None:
|
||||||
|
return None
|
||||||
|
final = self.final_reading()
|
||||||
|
end = final.date if (final is not None and final.date is not None) else (today or date.today())
|
||||||
|
if end is None:
|
||||||
|
return None
|
||||||
|
return days_between(first.date, end)
|
||||||
|
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Best available human label for the run.
|
||||||
|
|
||||||
|
The stored ``beer_type`` drifts between readings of the same run, so the
|
||||||
|
most frequently used non-empty value wins; ties break towards the most
|
||||||
|
recent reading. Run 4 would resolve to "Nocturna the Christmas beer"
|
||||||
|
rather than "Necatron-Christmas Beer".
|
||||||
|
"""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for reading in self.readings:
|
||||||
|
label = reading.beer_type
|
||||||
|
if label:
|
||||||
|
counts[label] = counts.get(label, 0) + 1
|
||||||
|
if not counts:
|
||||||
|
return f"Run {self.beer_run_id}"
|
||||||
|
best = max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
|
||||||
|
return best
|
||||||
|
|
||||||
|
def _last_index_of(self, label: str) -> int:
|
||||||
|
for index in range(len(self.readings) - 1, -1, -1):
|
||||||
|
if self.readings[index].beer_type == label:
|
||||||
|
return index
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def comments(self) -> list[str]:
|
||||||
|
"""Every non-empty comment, oldest first."""
|
||||||
|
return [r.comment for r in self.readings if r.comment]
|
||||||
|
|
||||||
|
def outlier_readings(self) -> list[Reading]:
|
||||||
|
"""Readings whose gravity is outside the plausible hydrometer range."""
|
||||||
|
return [r for r in self.readings if r.sg_points is not None and not r.is_plausible]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Module level helpers (usable without a Run instance)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def abv_percent(points_drop: int | float | None) -> float | None:
|
||||||
|
"""Standard ABW/ABV from a gravity drop expressed in points."""
|
||||||
|
if points_drop is None:
|
||||||
|
return None
|
||||||
|
return round(float(points_drop) * ABV_FACTOR, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_abv_percent(max_points: int | float | None,
|
||||||
|
min_points: int | float | None) -> float | None:
|
||||||
|
"""The legacy app's formula, kept for the legacy-vs-standard comparison.
|
||||||
|
|
||||||
|
The old code computed ``((max - min) / 7.36) + 0.5``. It is not a recognised
|
||||||
|
brewing formula and returns 0.5% even when nothing has fermented.
|
||||||
|
"""
|
||||||
|
if max_points is None or min_points is None:
|
||||||
|
return None
|
||||||
|
return round(((float(max_points) - float(min_points)) / 7.36) + 0.5, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def assumed_og_abv_percent(current_points: int | None,
|
||||||
|
assumed_og: int = ASSUMED_ORIGINAL_GRAVITY) -> float | None:
|
||||||
|
"""ABV for an in-progress run measured against an assumed original gravity."""
|
||||||
|
if current_points is None:
|
||||||
|
return None
|
||||||
|
return abv_percent(assumed_og - current_points)
|
||||||
|
|
||||||
|
|
||||||
|
def is_plausible_sg(points: int | None) -> bool:
|
||||||
|
"""True when a gravity reading sits inside a believable hydrometer range."""
|
||||||
|
if points is None:
|
||||||
|
return False
|
||||||
|
return _MIN_PLAUSIBLE_SG <= points <= _MAX_PLAUSIBLE_SG
|
||||||
|
|
||||||
|
|
||||||
|
def is_active(readings: Sequence[Reading]) -> bool:
|
||||||
|
"""A run is active while it has no reading marked as the final one."""
|
||||||
|
return not any(r.final_reading for r in readings)
|
||||||
|
|
||||||
|
|
||||||
|
def final_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||||
|
"""The reading flagged as final, or ``None``."""
|
||||||
|
for reading in readings:
|
||||||
|
if reading.final_reading:
|
||||||
|
return reading
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def first_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||||
|
"""The chronologically first reading."""
|
||||||
|
dated = [r for r in readings if r.date is not None]
|
||||||
|
if dated:
|
||||||
|
return min(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
|
||||||
|
return readings[0] if readings else None
|
||||||
|
|
||||||
|
|
||||||
|
def last_reading(readings: Sequence[Reading]) -> Reading | None:
|
||||||
|
"""The chronologically last reading."""
|
||||||
|
dated = [r for r in readings if r.date is not None]
|
||||||
|
if dated:
|
||||||
|
return max(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
|
||||||
|
return readings[-1] if readings else None
|
||||||
|
|
||||||
|
|
||||||
|
def days_between(start: date, end: date) -> int:
|
||||||
|
return (end - start).days
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_decimal(points: int | None) -> str:
|
||||||
|
"""Format gravity points as a decimal reading, e.g. ``1039`` -> ``1.039``."""
|
||||||
|
if points is None:
|
||||||
|
return ""
|
||||||
|
return f"{points / 1000:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def run_summary(run: Run, today: date | None = None,
|
||||||
|
og_override: int | None = None) -> dict:
|
||||||
|
"""A flat dict describing a run, ready for a template or a JSON response."""
|
||||||
|
final = run.final_reading()
|
||||||
|
first = run.first_reading()
|
||||||
|
og = run.original_gravity()
|
||||||
|
fg = run.final_gravity()
|
||||||
|
return {
|
||||||
|
"beer_run_id": run.beer_run_id,
|
||||||
|
"name": run.display_name(),
|
||||||
|
"active": run.is_active(),
|
||||||
|
"og_points": og,
|
||||||
|
"fg_points": fg,
|
||||||
|
"og_decimal": sg_points_to_decimal(og),
|
||||||
|
"fg_decimal": sg_points_to_decimal(fg),
|
||||||
|
# Pre-formatted for the templates so no arithmetic happens in Jinja.
|
||||||
|
"og_decimal_str": _fmt_decimal(og),
|
||||||
|
"fg_decimal_str": _fmt_decimal(fg),
|
||||||
|
"max_sg": run.max_sg(),
|
||||||
|
"min_sg": run.min_sg(),
|
||||||
|
"min_sg_decimal": _fmt_decimal(run.min_sg()),
|
||||||
|
"drop_points": run.gravity_drop_points(),
|
||||||
|
"abv": run.abv_percent(),
|
||||||
|
"projected_abv": run.projected_abv_percent(og_override=og_override),
|
||||||
|
"legacy_abv": legacy_abv_percent(run.max_sg(), run.min_sg()),
|
||||||
|
"reading_count": len(run.readings),
|
||||||
|
"first_date": first.date.isoformat() if (first and first.date) else None,
|
||||||
|
"final_date": final.date.isoformat() if (final and final.date) else None,
|
||||||
|
"days_in_fermenter": run.days_in_fermenter(today),
|
||||||
|
"comments": run.comments(),
|
||||||
|
"outliers": [r.sg_points for r in run.outlier_readings()],
|
||||||
|
"readings": [
|
||||||
|
{
|
||||||
|
"date": r.date.isoformat() if r.date else None,
|
||||||
|
"sg": r.sg_points,
|
||||||
|
"sg_decimal": r.sg_decimal,
|
||||||
|
"final_reading": r.final_reading,
|
||||||
|
"comment": r.comment,
|
||||||
|
"beer_type": r.beer_type,
|
||||||
|
}
|
||||||
|
for r in run.readings
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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,304 @@
|
|||||||
|
"""Mongo access layer for the beer_data app.
|
||||||
|
|
||||||
|
Two collections are used:
|
||||||
|
|
||||||
|
``beer_data``
|
||||||
|
The historic readings. One document per gravity reading. **Never modified or
|
||||||
|
removed by the code in this module** apart from the explicit "add or update a
|
||||||
|
reading" path used by the brew-process form.
|
||||||
|
|
||||||
|
``beer_runs``
|
||||||
|
New in this version. One document per brew run holding the *canonical*
|
||||||
|
metadata that could not be derived from the readings themselves: the beer
|
||||||
|
name, the tap number it is on, the original gravity, and whether the run is
|
||||||
|
still in the fermenter. The backfill script only ever inserts into this
|
||||||
|
collection, so it is safe to run repeatedly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pymongo import MongoClient
|
||||||
|
from pymongo.errors import PyMongoError
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from brewing import Run
|
||||||
|
|
||||||
|
__all__ = ["BeerRepository", "get_repository"]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BeerRepository:
|
||||||
|
"""Thin, testable wrapper around the two Mongo collections."""
|
||||||
|
|
||||||
|
def __init__(self, client: MongoClient | None = None,
|
||||||
|
db_name: str = "beer_db") -> None:
|
||||||
|
self.client = client or _build_client()
|
||||||
|
self.db = self.client[db_name]
|
||||||
|
self.readings = self.db["beer_data"]
|
||||||
|
self.users = self.db["users"]
|
||||||
|
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
|
||||||
|
# shadow the runs() method on the instance.
|
||||||
|
self.run_meta = self.db["beer_runs"]
|
||||||
|
|
||||||
|
# -- users -------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def user_check(self, username: str, password: str) -> bool:
|
||||||
|
"""Check the login credentials.
|
||||||
|
|
||||||
|
Passwords are stored as a hash produced by
|
||||||
|
``werkzeug.security.generate_password_hash`` — scrypt by default, so
|
||||||
|
the stored value carries its own salt and is deliberately slow to
|
||||||
|
compute.
|
||||||
|
|
||||||
|
Legacy rows stored the password in plain text. Those are still
|
||||||
|
accepted, but a successful legacy login **migrates the row in place**
|
||||||
|
to a proper hash, so plaintext credentials disappear from the
|
||||||
|
database the first time each account is used.
|
||||||
|
"""
|
||||||
|
if not username or not password:
|
||||||
|
return False
|
||||||
|
|
||||||
|
doc = self.users.find_one({"username": str(username)})
|
||||||
|
if not doc:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stored = str(doc.get("password") or "")
|
||||||
|
if not stored:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if _is_hashed(stored):
|
||||||
|
return check_password_hash(stored, password)
|
||||||
|
|
||||||
|
# Legacy plaintext password — accept it, then upgrade the row.
|
||||||
|
if stored == password:
|
||||||
|
self._upgrade_password(doc, password)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _upgrade_password(self, doc: dict, password: str) -> None:
|
||||||
|
"""Replace a plaintext password with a proper hash."""
|
||||||
|
try:
|
||||||
|
self.users.update_one(
|
||||||
|
{"_id": doc["_id"]},
|
||||||
|
{"$set": {
|
||||||
|
"password": generate_password_hash(password),
|
||||||
|
"password_migrated_at": _now_iso(),
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
except PyMongoError:
|
||||||
|
# A failed upgrade must not stop the login succeeding — but the
|
||||||
|
# operator needs to know the password is still stored in plain
|
||||||
|
# text, so log it rather than swallowing the error silently.
|
||||||
|
logger.warning(
|
||||||
|
"Could not upgrade the password for user %r to a hashed value; "
|
||||||
|
"it is still stored in plain text",
|
||||||
|
doc.get("username"),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- readings ----------------------------------------------------------- #
|
||||||
|
|
||||||
|
def all_reading_docs(self) -> list[dict]:
|
||||||
|
"""Every reading document, oldest first."""
|
||||||
|
return list(self.readings.find().sort([("date", 1), ("beer_run_id", 1)]))
|
||||||
|
|
||||||
|
def reading_docs_for_run(self, beer_run_id: str) -> list[dict]:
|
||||||
|
return list(
|
||||||
|
self.readings.find({"beer_run_id": _as_id(beer_run_id)}).sort("date", 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
def runs(self) -> list[Run]:
|
||||||
|
"""Every run, built from the reading documents, oldest run first."""
|
||||||
|
grouped: dict[str, list[dict]] = {}
|
||||||
|
for doc in self.all_reading_docs():
|
||||||
|
grouped.setdefault(str(doc.get("beer_run_id")), []).append(doc)
|
||||||
|
|
||||||
|
def run_sort_key(run: Run) -> tuple:
|
||||||
|
first = run.first_reading()
|
||||||
|
return (first.date if (first and first.date) else date_min(), run.beer_run_id)
|
||||||
|
|
||||||
|
runs = [Run.from_docs(run_id, docs) for run_id, docs in grouped.items()]
|
||||||
|
runs.sort(key=run_sort_key)
|
||||||
|
return runs
|
||||||
|
|
||||||
|
def run(self, beer_run_id: str) -> Run | None:
|
||||||
|
docs = self.reading_docs_for_run(beer_run_id)
|
||||||
|
if not docs:
|
||||||
|
return None
|
||||||
|
return Run.from_docs(str(beer_run_id), docs)
|
||||||
|
|
||||||
|
def active_runs(self) -> list[Run]:
|
||||||
|
"""Runs with no final reading yet."""
|
||||||
|
return [r for r in self.runs() if r.is_active()]
|
||||||
|
|
||||||
|
def completed_runs(self) -> list[Run]:
|
||||||
|
return [r for r in self.runs() if not r.is_active()]
|
||||||
|
|
||||||
|
def upsert_reading(self, *, beer_run_id: str, beer_type: str, sg: int,
|
||||||
|
reading_date: str, final_reading: bool, comment: str) -> str:
|
||||||
|
"""Add a reading, or update the matching one if it already exists.
|
||||||
|
|
||||||
|
The unique key is ``(date, beer_run_id)`` which is what the original
|
||||||
|
application used, so re-submitting the same day updates in place
|
||||||
|
instead of creating a duplicate. Returns the ``_id`` of the document.
|
||||||
|
"""
|
||||||
|
query = {"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)}
|
||||||
|
update = {
|
||||||
|
"$set": {
|
||||||
|
"beer_run_id": str(beer_run_id),
|
||||||
|
"beer_type": beer_type,
|
||||||
|
"sg": str(sg),
|
||||||
|
"date": _as_date(reading_date),
|
||||||
|
"final_reading": "True" if final_reading else "False",
|
||||||
|
"comment": str(comment),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = self.readings.update_one(query, update, upsert=True)
|
||||||
|
if result.upserted_id is not None:
|
||||||
|
return str(result.upserted_id)
|
||||||
|
existing = self.readings.find_one(query)
|
||||||
|
return str(existing.get("_id")) if existing else ""
|
||||||
|
|
||||||
|
def set_final_reading(self, beer_run_id: str, reading_date: str,
|
||||||
|
final: bool) -> int:
|
||||||
|
"""Flag (or unflag) a reading as the final reading of its run."""
|
||||||
|
result = self.readings.update_many(
|
||||||
|
{"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)},
|
||||||
|
{"$set": {"final_reading": "True" if final else "False"}},
|
||||||
|
)
|
||||||
|
return result.modified_count
|
||||||
|
|
||||||
|
# -- run metadata ------------------------------------------------------- #
|
||||||
|
|
||||||
|
def run_metadata(self, beer_run_id: str) -> dict:
|
||||||
|
"""The stored metadata for a run, or an empty dict."""
|
||||||
|
doc = self.run_meta.find_one({"beer_run_id": _as_id(beer_run_id)})
|
||||||
|
if not doc:
|
||||||
|
return {}
|
||||||
|
doc.pop("_id", None)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def all_run_metadata(self) -> dict[str, dict]:
|
||||||
|
"""All stored run metadata, keyed by ``beer_run_id``."""
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for doc in self.run_meta.find():
|
||||||
|
doc.pop("_id", None)
|
||||||
|
result[_as_id(doc.get("beer_run_id"))] = doc
|
||||||
|
return result
|
||||||
|
|
||||||
|
def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict:
|
||||||
|
"""Create or update the metadata document for a run.
|
||||||
|
|
||||||
|
Only the keys passed in are written; anything already stored is left
|
||||||
|
alone.
|
||||||
|
"""
|
||||||
|
payload = {k: v for k, v in fields.items() if v is not None}
|
||||||
|
payload["updated_at"] = _now_iso()
|
||||||
|
self.run_meta.update_one(
|
||||||
|
{"beer_run_id": _as_id(beer_run_id)},
|
||||||
|
{"$set": payload, "$setOnInsert": {"created_at": _now_iso()}},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
return self.run_metadata(beer_run_id)
|
||||||
|
|
||||||
|
def delete_run_metadata(self, beer_run_id: str) -> bool:
|
||||||
|
"""Remove the metadata document for a run (readings are untouched)."""
|
||||||
|
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
|
||||||
|
|
||||||
|
# -- helpers ------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def next_run_id(self) -> str:
|
||||||
|
"""The next free ``beer_run_id``.
|
||||||
|
|
||||||
|
Run ids are plain integers in the existing data, so the next id is one
|
||||||
|
past the highest numeric id currently in use.
|
||||||
|
"""
|
||||||
|
highest = 0
|
||||||
|
for run in self.runs():
|
||||||
|
try:
|
||||||
|
highest = max(highest, int(run.beer_run_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return str(highest + 1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Connection helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _build_client() -> MongoClient:
|
||||||
|
user = os.getenv("MONGO_USER", "root")
|
||||||
|
password = os.getenv("MONGO_PASS", "")
|
||||||
|
host = os.getenv("MONGO_HOST", "127.0.0.1")
|
||||||
|
uri = os.getenv("MONGO_URI") or f"mongodb://{user}:{password}@{host}/beer_db?authSource=admin"
|
||||||
|
return MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_id(value: object) -> str:
|
||||||
|
"""Coerce a run id to a plain string.
|
||||||
|
|
||||||
|
Every value that reaches a Mongo query is coerced first. Without this, a
|
||||||
|
caller passing a dict (e.g. ``{"$ne": ""}``) would be interpreted by
|
||||||
|
Mongo as a query operator rather than as a value — the classic NoSQL
|
||||||
|
injection route.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _as_date(value: object) -> str:
|
||||||
|
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hashed(stored: str) -> bool:
|
||||||
|
"""True when *stored* looks like a werkzeug password hash.
|
||||||
|
|
||||||
|
Werkzeug hashes are of the form ``method$salt$hash``, e.g.
|
||||||
|
``scrypt:32768:8:1$abcdef...$0123...``. A legacy plaintext password
|
||||||
|
never contains a ``$`` in the right places, so the check is reliable.
|
||||||
|
"""
|
||||||
|
return "$" in stored and len(stored.split("$")) >= 3
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password for storage (scrypt via werkzeug)."""
|
||||||
|
return generate_password_hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def date_min():
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
return date.min
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository() -> BeerRepository:
|
||||||
|
"""Build a repository against the configured Mongo."""
|
||||||
|
return BeerRepository()
|
||||||
|
|
||||||
|
|
||||||
|
def is_alive(repo: BeerRepository) -> bool:
|
||||||
|
"""Cheap health check used by the status page."""
|
||||||
|
try:
|
||||||
|
repo.client.admin.command("ping")
|
||||||
|
return True
|
||||||
|
except PyMongoError:
|
||||||
|
return False
|
||||||
|
|
||||||
@@ -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,637 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Brew Log — design system
|
||||||
|
Beer-house palette: warm amber/copper on near-black, with a condensed
|
||||||
|
display face (Anton) for headings and Inter for everything else.
|
||||||
|
|
||||||
|
Palette taken from the brewery-128 template:
|
||||||
|
--primary #be7f39 (amber / copper)
|
||||||
|
--primary-dark #0e0e0e
|
||||||
|
--black-primary #212024
|
||||||
|
--black-secondary #161616
|
||||||
|
--gray #e9e9e9
|
||||||
|
--dark-gray #afafaf
|
||||||
|
--paragraph-gray #535353
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ fonts */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Anton';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/anton-latin.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/inter-latin-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- tokens */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* surfaces */
|
||||||
|
--bg: #121110;
|
||||||
|
--bg-raised: #1c1a19;
|
||||||
|
--bg-raised-2: #242120;
|
||||||
|
--bg-input: #161514;
|
||||||
|
|
||||||
|
/* lines */
|
||||||
|
--border: #332e2a;
|
||||||
|
--border-strong: #4a4239;
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
--text: #f2ede8;
|
||||||
|
--text-muted: #afafaf;
|
||||||
|
--text-faint: #7d746c;
|
||||||
|
|
||||||
|
/* brand */
|
||||||
|
--amber: #be7f39;
|
||||||
|
--amber-bright: #dda05c;
|
||||||
|
--amber-deep: #8f5f28;
|
||||||
|
--amber-dim: rgba(190, 127, 57, 0.14);
|
||||||
|
--amber-text: #e2b47c;
|
||||||
|
|
||||||
|
/* foam / highlights */
|
||||||
|
--foam: #f7f3ee;
|
||||||
|
--cream: #eef4f8;
|
||||||
|
|
||||||
|
/* status */
|
||||||
|
--warn: #e8a33d;
|
||||||
|
--warn-dim: rgba(232, 163, 61, 0.14);
|
||||||
|
--danger: #d9534f;
|
||||||
|
--danger-dim: rgba(217, 83, 79, 0.14);
|
||||||
|
--success: #7db902;
|
||||||
|
--success-dim: rgba(125, 185, 2, 0.14);
|
||||||
|
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 10px 30px rgba(0, 0, 0, .35);
|
||||||
|
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--display: 'Anton', 'Arial Narrow', Impact, sans-serif;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--amber-text); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.15; font-weight: 650; }
|
||||||
|
h1 { font-size: 1.65rem; }
|
||||||
|
h2 { font-size: 1.25rem; }
|
||||||
|
h3 { font-size: 1.02rem; }
|
||||||
|
|
||||||
|
p { margin: 0 0 .75rem; }
|
||||||
|
|
||||||
|
/* Display type: the condensed face, used sparingly for headings and figures. */
|
||||||
|
.display {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- topbar */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: rgba(18, 17, 16, .94);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__inner {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: .6rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brand:hover { text-decoration: none; }
|
||||||
|
|
||||||
|
.brand__mark {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(190, 127, 57, .35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand__text {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.22rem;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav { display: flex; gap: .25rem; flex: 1; }
|
||||||
|
|
||||||
|
.nav__link {
|
||||||
|
padding: .4rem .8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .12s ease, color .12s ease;
|
||||||
|
}
|
||||||
|
.nav__link:hover { background: var(--bg-raised-2); color: var(--text); text-decoration: none; }
|
||||||
|
.nav__link.is-active {
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber-text);
|
||||||
|
box-shadow: inset 0 -2px 0 var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
|
||||||
|
.topbar__user { color: var(--text-faint); font-size: .85rem; font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ page */
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 4rem;
|
||||||
|
}
|
||||||
|
.page--narrow { max-width: 460px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.85rem;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.page__sub { color: var(--text-muted); font-size: .9rem; margin: .15rem 0 0; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- cards */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A thin amber seam along the top of each card — the "label on the barrel" touch. */
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 40%, transparent 85%);
|
||||||
|
opacity: .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__head {
|
||||||
|
padding: .9rem 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
}
|
||||||
|
.card__hint { color: var(--text-faint); font-size: .82rem; }
|
||||||
|
.card__body { padding: 1.1rem; }
|
||||||
|
.card__body--flush { padding: 0; }
|
||||||
|
|
||||||
|
.grid { display: grid; gap: 1rem; }
|
||||||
|
.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
/* The active-beer cards: fill the row whatever the number of brews. */
|
||||||
|
.grid--cards { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||||
|
.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.grid--sidebar { grid-template-columns: minmax(0, 380px) minmax(0, 1fr); align-items: start; }
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid--sidebar { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid--2, .grid--3, .grid--4 { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.topbar__inner { flex-wrap: wrap; gap: .75rem; }
|
||||||
|
.nav { order: 3; width: 100%; overflow-x: auto; }
|
||||||
|
.page__title { font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- stats */
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
}
|
||||||
|
.stat__label {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .09em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.stat__value {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--amber-bright);
|
||||||
|
}
|
||||||
|
.stat__meta { color: var(--text-muted); font-size: .82rem; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- forms */
|
||||||
|
|
||||||
|
.field { margin-bottom: .9rem; }
|
||||||
|
.field__label {
|
||||||
|
display: block;
|
||||||
|
font-size: .74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .09em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
.field__hint { color: var(--text-faint); font-size: .78rem; margin-top: .25rem; }
|
||||||
|
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
|
||||||
|
|
||||||
|
input[type="text"], input[type="password"], input[type="number"],
|
||||||
|
input[type="date"], input[type="email"], textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: .5rem .65rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .92rem;
|
||||||
|
transition: border-color .12s ease, box-shadow .12s ease;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--amber);
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-dim);
|
||||||
|
}
|
||||||
|
input::placeholder, textarea::placeholder { color: var(--text-faint); }
|
||||||
|
textarea { resize: vertical; min-height: 5rem; }
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The native checkbox is a white square on a near-black page — replace it. */
|
||||||
|
.checkbox input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background .12s ease, border-color .12s ease;
|
||||||
|
}
|
||||||
|
.checkbox input[type="checkbox"]:hover { border-color: var(--amber); }
|
||||||
|
.checkbox input[type="checkbox"]:checked {
|
||||||
|
background: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
.checkbox input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: .32rem;
|
||||||
|
top: .07rem;
|
||||||
|
width: .28rem;
|
||||||
|
height: .55rem;
|
||||||
|
border: solid #17110a;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
transform: rotate(43deg);
|
||||||
|
}
|
||||||
|
.checkbox input[type="checkbox"]:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--amber-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the native number spinners — the light spinner widgets jar badly
|
||||||
|
against the dark theme. */
|
||||||
|
input[type="number"]::-webkit-outer-spin-button,
|
||||||
|
input[type="number"]::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="number"] { -moz-appearance: textfield; }
|
||||||
|
|
||||||
|
/* The native date picker's calendar icon is near-black on near-black. */
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(0.75) sepia(1) saturate(3) hue-rotate(350deg) brightness(1.1);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Dark scrollbars to match the page. */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) var(--bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg); }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--amber-deep); }
|
||||||
|
|
||||||
|
/* A figure that should read as a number, not as body text. */
|
||||||
|
.strong { font-weight: 650; color: var(--text); }
|
||||||
|
|
||||||
|
.field-row { display: flex; gap: .75rem; }
|
||||||
|
.field-row > * { flex: 1; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- buttons */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .5rem .95rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--amber);
|
||||||
|
color: #17110a;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: .01em;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: filter .12s ease, background .12s ease, border-color .12s ease;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.12); text-decoration: none; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.btn--ghost:hover { background: var(--bg-raised-2); color: var(--text); }
|
||||||
|
|
||||||
|
.btn--danger { background: transparent; border-color: var(--danger); color: var(--danger); }
|
||||||
|
.btn--danger:hover { background: var(--danger-dim); }
|
||||||
|
|
||||||
|
.btn--sm { padding: .3rem .6rem; font-size: .78rem; }
|
||||||
|
.btn--block { width: 100%; }
|
||||||
|
|
||||||
|
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- tables */
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
table.data {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
text-align: left;
|
||||||
|
padding: .55rem .8rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 650;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-raised-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: .6rem .8rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
table.data tr:last-child td { border-bottom: none; }
|
||||||
|
table.data tbody tr:hover { background: var(--bg-raised-2); }
|
||||||
|
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
|
||||||
|
table.data .comment { color: var(--text-muted); font-size: .84rem; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------- chips */
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .3rem;
|
||||||
|
padding: .12rem .5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chip--active { background: var(--amber-dim); color: var(--amber-text); }
|
||||||
|
.chip--done { background: rgba(175, 175, 175, .13); color: var(--text-muted); }
|
||||||
|
.chip--warn { background: var(--warn-dim); color: var(--warn); }
|
||||||
|
.chip--danger { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
.chip--good { background: var(--success-dim); color: var(--success); }
|
||||||
|
.chip--tap {
|
||||||
|
background: var(--bg-raised-2);
|
||||||
|
border: 1px solid var(--amber-deep);
|
||||||
|
color: var(--amber-text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- flashes */
|
||||||
|
|
||||||
|
.flashes { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: .6rem .9rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.flash--success { border-color: rgba(125, 185, 2, .45); background: var(--success-dim); color: #c3e08a; }
|
||||||
|
.flash--error { border-color: rgba(217, 83, 79, .45); background: var(--danger-dim); color: #f0a8a6; }
|
||||||
|
.flash--warning { border-color: rgba(232, 163, 61, .45); background: var(--warn-dim); color: #f2c880; }
|
||||||
|
.flash--info { border-color: rgba(190, 127, 57, .45); background: var(--amber-dim); color: var(--amber-text); }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- login */
|
||||||
|
|
||||||
|
.login {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 480px at 50% -10%, rgba(190, 127, 57, .13), transparent 62%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
.login__card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.login__card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
background: linear-gradient(90deg, var(--amber-deep), var(--amber-bright), var(--amber-deep));
|
||||||
|
}
|
||||||
|
.login__mark { font-size: 2.4rem; line-height: 1; }
|
||||||
|
.login__title {
|
||||||
|
margin: .6rem 0 .15rem;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--amber-bright);
|
||||||
|
}
|
||||||
|
.login__sub { color: var(--text-muted); font-size: .88rem; margin-bottom: 1.4rem; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- active card */
|
||||||
|
|
||||||
|
.brew-card {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.brew-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 45%, transparent 90%);
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
|
||||||
|
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
|
||||||
|
.brew-card__meta { color: var(--text-faint); font-size: .8rem; font-family: var(--mono); }
|
||||||
|
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
|
||||||
|
.brew-card__stat .stat__label { font-size: .66rem; }
|
||||||
|
.brew-card__stat .stat__value { font-size: 1.3rem; }
|
||||||
|
.brew-card__spark { height: 46px; }
|
||||||
|
.brew-card__spark--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-style: italic;
|
||||||
|
padding: .35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- helpers */
|
||||||
|
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.faint { color: var(--text-faint); }
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.small { font-size: .84rem; }
|
||||||
|
.tiny { font-size: .76rem; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.stack > * + * { margin-top: .75rem; }
|
||||||
|
.mt-0 { margin-top: 0; }
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.row--between { justify-content: space-between; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.empty__icon { font-size: 2rem; display: block; margin-bottom: .5rem; opacity: .7; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- reading */
|
||||||
|
|
||||||
|
.reading-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.reading-list__item {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: .6rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.reading-list__item:last-child { border-bottom: none; }
|
||||||
|
.reading-list__date {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: 0 0 5.5rem;
|
||||||
|
}
|
||||||
|
.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
|
||||||
|
.reading-list__body { flex: 1; min-width: 0; }
|
||||||
|
.reading-list__comment { color: var(--text-muted); font-size: .84rem; }
|
||||||
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,26 +1,50 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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>
|
||||||
|
</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">{{ session.get('username', '') }}</span>
|
||||||
|
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<style>
|
<main class="{% block main_class %}page{% endblock %}">
|
||||||
body {
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
background: #e8f1f9;
|
{% if messages %}
|
||||||
}
|
<div class="flashes">
|
||||||
</style>
|
{% for category, message in messages %}
|
||||||
</head>
|
<div class="flash flash--{{ category }}">{{ message }}</div>
|
||||||
<body>
|
{% endfor %}
|
||||||
<!-- this is a base template using Bootstrap-Flask
|
</div>
|
||||||
https://bootstrap-flask.readthedocs.io/ -->
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% block content %} {% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- you can delete the next line if you're not using any Bootstrap JS -->
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
{{ bootstrap.load_js() }}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 }}</span>
|
||||||
|
<div class="faint tiny">{{ run.min_sg_decimal }}</div>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{% if run.projected_abv is not none %}
|
||||||
|
{{ '%.2f' % run.projected_abv }}%
|
||||||
|
{% else %}
|
||||||
|
<span class="faint" title="Record the original gravity for this run 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" style="margin-top:2rem">
|
||||||
|
<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 %}
|
||||||
@@ -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,107 @@
|
|||||||
|
{% 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--4" style="margin-bottom:1.25rem">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat__label">Original gravity</div>
|
||||||
|
<div class="stat__value">
|
||||||
|
{% if summary.og_points is not none %}{{ summary.og_points }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if summary.og_decimal %}
|
||||||
|
<div class="stat__meta">{{ summary.og_decimal_str }}</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_points }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if summary.fg_decimal %}
|
||||||
|
<div class="stat__meta">{{ summary.fg_decimal_str }}</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{% else %}projected{% 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>
|
||||||
|
|
||||||
|
<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,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,152 @@
|
|||||||
|
"""The brew-process pages: record readings and review the runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import (Blueprint, flash, redirect, render_template, request,
|
||||||
|
session, url_for)
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import (BooleanField, DateField, IntegerField, StringField,
|
||||||
|
SubmitField, TextAreaField)
|
||||||
|
from wtforms.validators import DataRequired, Length, NumberRange, Optional
|
||||||
|
|
||||||
|
from brewing import is_plausible_sg, run_summary
|
||||||
|
|
||||||
|
bp = Blueprint("brews", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def repo():
|
||||||
|
"""The request-scoped repository (one Mongo client per request)."""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
return current_app.extensions["beer_repo"]()
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(fn):
|
||||||
|
"""Redirect to the login page when there is no session."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not session.get("logged_in"):
|
||||||
|
return redirect(url_for("auth.login", next=request.path))
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _last_reading_date(run) -> str | None:
|
||||||
|
"""The ISO date of the most recent reading in a run."""
|
||||||
|
last = run.last_reading()
|
||||||
|
if last is None or last.date is None:
|
||||||
|
return None
|
||||||
|
return last.date.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingForm(FlaskForm):
|
||||||
|
"""Add or update a gravity reading."""
|
||||||
|
|
||||||
|
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
|
||||||
|
beer_run_type = StringField("Beer / cider name",
|
||||||
|
validators=[Optional(), Length(max=120)])
|
||||||
|
Date = DateField("Reading date", validators=[DataRequired()])
|
||||||
|
sg = IntegerField("Gravity reading (points)",
|
||||||
|
validators=[DataRequired(), NumberRange(min=900, max=1300)])
|
||||||
|
final_reading = BooleanField("This is the final reading")
|
||||||
|
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
|
||||||
|
submit = SubmitField("Save reading")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/brews", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""The brew-process page: record a reading, then review the runs."""
|
||||||
|
form = ReadingForm()
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
sg_points = int(form.sg.data)
|
||||||
|
if not is_plausible_sg(sg_points):
|
||||||
|
flash(
|
||||||
|
f"{sg_points} is outside the usual hydrometer range "
|
||||||
|
"(990-1200). It was saved anyway — double-check the reading.",
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
repo().upsert_reading(
|
||||||
|
beer_run_id=str(form.beer_run_id.data),
|
||||||
|
beer_type=(form.beer_run_type.data or "").strip(),
|
||||||
|
sg=sg_points,
|
||||||
|
reading_date=form.Date.data.isoformat(),
|
||||||
|
final_reading=bool(form.final_reading.data),
|
||||||
|
comment=(form.comment.data or "").strip(),
|
||||||
|
)
|
||||||
|
flash("Reading saved.", "success")
|
||||||
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
|
runs = repo().runs()
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for run in runs:
|
||||||
|
summary = run_summary(run)
|
||||||
|
summary["canonical_name"] = run.display_name()
|
||||||
|
summary["last_date"] = _last_reading_date(run)
|
||||||
|
summaries.append(summary)
|
||||||
|
|
||||||
|
# Active runs: most recent activity first — that is what is fermenting now.
|
||||||
|
active = sorted([s for s in summaries if s["active"]],
|
||||||
|
key=lambda s: s["last_date"] or "", reverse=True)
|
||||||
|
# Finished runs: most recently finished first.
|
||||||
|
completed = sorted([s for s in summaries if not s["active"]],
|
||||||
|
key=lambda s: s["final_date"] or "", reverse=True)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"brews.html",
|
||||||
|
form=form,
|
||||||
|
summaries=summaries,
|
||||||
|
active_runs=active,
|
||||||
|
completed_runs=completed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/runs/<run_id>")
|
||||||
|
@login_required
|
||||||
|
def run_detail(run_id: str):
|
||||||
|
"""One run in detail, with all of its readings."""
|
||||||
|
run = repo().run(run_id)
|
||||||
|
if run is None:
|
||||||
|
flash(f"No run numbered {run_id}.", "error")
|
||||||
|
return redirect(url_for("brews.index"))
|
||||||
|
|
||||||
|
summary = run_summary(run)
|
||||||
|
summary["canonical_name"] = run.display_name()
|
||||||
|
|
||||||
|
return render_template("run_detail.html", run=run, summary=summary)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/runs/<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,305 @@
|
|||||||
|
"""Tests for the web layer: login, the brew-process page and the API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from conftest import seed_readings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def logged_in(client, repo):
|
||||||
|
"""A client with an authenticated session and a user in the database."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ public #
|
||||||
|
|
||||||
|
def test_healthz_is_public(client):
|
||||||
|
response = client.get("/healthz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_readyz_checks_the_database(client):
|
||||||
|
response = client.get("/readyz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json == {"status": "ready"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_readyz_reports_not_ready_when_mongo_is_down(client, monkeypatch):
|
||||||
|
"""A pod that cannot reach Mongo must not serve traffic."""
|
||||||
|
from pymongo.errors import ServerSelectionTimeoutError
|
||||||
|
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise ServerSelectionTimeoutError("no mongo")
|
||||||
|
|
||||||
|
monkeypatch.setattr("mongo.repository.BeerRepository.__init__",
|
||||||
|
lambda self, **kw: (_ for _ in ()).throw(ServerSelectionTimeoutError("no mongo")))
|
||||||
|
response = client.get("/readyz")
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_redirects_to_login(client):
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_renders(client):
|
||||||
|
response = client.get("/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"Brew Log" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_bad_credentials_is_rejected(client, repo):
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
response = client.post("/login", data={"username": "beer", "password": "wrong"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"not recognised" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_pages_redirect_when_logged_out(client):
|
||||||
|
for path in ("/brews", "/runs/1"):
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 302, path
|
||||||
|
assert "/login" in response.headers["Location"], path
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- brew pages #
|
||||||
|
|
||||||
|
def test_brews_page_lists_runs(logged_in, repo):
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
||||||
|
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
||||||
|
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/brews")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.data.decode()
|
||||||
|
assert "Pale Ale" in body
|
||||||
|
assert "Ginger Beer" in body
|
||||||
|
# The comment on the reading should be surfaced.
|
||||||
|
assert "Keghing" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_detail_page_renders(logged_in, repo):
|
||||||
|
seed_readings(repo, [
|
||||||
|
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
|
||||||
|
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
|
||||||
|
])
|
||||||
|
|
||||||
|
response = logged_in.get("/runs/1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.data.decode()
|
||||||
|
assert "Pale Ale" in body
|
||||||
|
assert "Keghing" in body
|
||||||
|
assert "5.25%" in body
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- write paths #
|
||||||
|
|
||||||
|
def test_recording_a_reading_stores_it(logged_in, repo):
|
||||||
|
response = logged_in.post("/brews", data={
|
||||||
|
"beer_run_id": "5",
|
||||||
|
"beer_run_type": "Test Porter",
|
||||||
|
"Date": "2026-03-04",
|
||||||
|
"sg": "1045",
|
||||||
|
"comment": "Hops in",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "5"})
|
||||||
|
assert doc is not None
|
||||||
|
assert doc["sg"] == "1045"
|
||||||
|
assert doc["beer_type"] == "Test Porter"
|
||||||
|
assert doc["comment"] == "Hops in"
|
||||||
|
assert doc["final_reading"] == "False"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recording_a_reading_twice_updates_in_place(logged_in, repo):
|
||||||
|
"""The unique key is (date, beer_run_id) — no duplicate rows."""
|
||||||
|
for sg in ("1045", "1035"):
|
||||||
|
logged_in.post("/brews", data={
|
||||||
|
"beer_run_id": "6",
|
||||||
|
"beer_run_type": "Test",
|
||||||
|
"Date": "2026-03-04",
|
||||||
|
"sg": sg,
|
||||||
|
})
|
||||||
|
|
||||||
|
count = repo.readings.count_documents({"beer_run_id": "6"})
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "6"})
|
||||||
|
assert count == 1
|
||||||
|
assert doc["sg"] == "1035"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_a_final_reading_closes_the_run(logged_in, repo):
|
||||||
|
seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")])
|
||||||
|
|
||||||
|
response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"},
|
||||||
|
follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "7"})
|
||||||
|
assert doc["final_reading"] == "True"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
|
||||||
|
seed_readings(repo, [
|
||||||
|
("7", "Ale", "1045", "2026-03-01", False, ""),
|
||||||
|
("7", "Ale", "1010", "2026-03-20", True, ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True)
|
||||||
|
|
||||||
|
doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"})
|
||||||
|
assert doc["final_reading"] == "False"
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- API #
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- misc #
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_assets_are_served(client):
|
||||||
|
for path in ("/static/js/app.js", "/static/css/app.css",
|
||||||
|
"/static/img/logo.svg"):
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 200, path
|
||||||
|
assert len(response.data) > 100, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_the_session(logged_in):
|
||||||
|
response = logged_in.post("/logout", follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# After logging out the protected page bounces back to the login page.
|
||||||
|
response = logged_in.get("/brews")
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login" in response.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
|
||||||
|
response = logged_in.post("/brews", data={
|
||||||
|
"beer_run_id": "12",
|
||||||
|
"beer_run_type": "Odd",
|
||||||
|
"Date": "2026-05-01",
|
||||||
|
"sg": "950",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"outside the usual hydrometer range" in response.data
|
||||||
|
# It is still stored — the warning is advisory, not a rejection.
|
||||||
|
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- security: passwords #
|
||||||
|
|
||||||
|
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
|
||||||
|
"""A legacy plaintext row still logs in, and is rewritten as a hash on
|
||||||
|
the way in so plaintext credentials disappear from the database."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
assert repo.user_check("beer", "beer_pass") is True
|
||||||
|
|
||||||
|
doc = repo.users.find_one({"username": "beer"})
|
||||||
|
assert doc["password"] != "beer_pass"
|
||||||
|
assert "$" in doc["password"] # a werkzeug hash
|
||||||
|
assert "password_migrated_at" in doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_password_migration_still_lets_the_login_succeed(repo, caplog):
|
||||||
|
"""If the upgrade write fails, the login must still work — but the
|
||||||
|
failure is logged so the operator knows the password is still plaintext."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pymongo.errors import PyMongoError
|
||||||
|
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise PyMongoError("write failed")
|
||||||
|
|
||||||
|
original = repo.users.update_one
|
||||||
|
repo.users.update_one = boom
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
assert repo.user_check("beer", "beer_pass") is True
|
||||||
|
|
||||||
|
repo.users.update_one = original
|
||||||
|
|
||||||
|
assert any("plain text" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hashed_password_verifies(repo):
|
||||||
|
from mongo.repository import hash_password
|
||||||
|
|
||||||
|
repo.users.insert_one({"username": "beer", "password": hash_password("beer_pass")})
|
||||||
|
|
||||||
|
assert repo.user_check("beer", "beer_pass") is True
|
||||||
|
assert repo.user_check("beer", "wrong") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_check_rejects_empty_credentials(repo):
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
assert repo.user_check("", "") is False
|
||||||
|
assert repo.user_check("beer", "") is False
|
||||||
|
assert repo.user_check("", "beer_pass") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_check_rejects_a_nosql_operator_payload(repo):
|
||||||
|
"""A dict must never be able to act as a Mongo query operator."""
|
||||||
|
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
|
||||||
|
|
||||||
|
assert repo.user_check({"$ne": ""}, "anything") is False
|
||||||
|
assert repo.user_check({"$gt": ""}, {"$ne": ""}) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_id_cannot_be_used_as_an_operator(repo):
|
||||||
|
"""A run id is coerced to a string before it reaches a query."""
|
||||||
|
from mongo.repository import _as_id
|
||||||
|
|
||||||
|
assert _as_id({"$ne": ""}) == ""
|
||||||
|
assert _as_id(20) == "20"
|
||||||
|
assert _as_id("20") == "20"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_app_refuses_to_start_without_a_secret_key(monkeypatch):
|
||||||
|
"""A missing SECRET_KEY must stop the app, not fall back to a default."""
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import app_factory
|
||||||
|
|
||||||
|
# Reload so the guard reads the environment as it is *now*. The exception
|
||||||
|
# class must be taken from the reloaded module — reloading rebinds it, so
|
||||||
|
# a class imported earlier would be a different object and pytest.raises
|
||||||
|
# would not match it.
|
||||||
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||||
|
importlib.reload(app_factory)
|
||||||
|
with pytest.raises(app_factory.ImproperlyConfigured):
|
||||||
|
app_factory.create_app()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||||
|
importlib.reload(app_factory)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import app_factory
|
||||||
|
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "change-me-in-the-deployment")
|
||||||
|
importlib.reload(app_factory)
|
||||||
|
with pytest.raises(app_factory.ImproperlyConfigured):
|
||||||
|
app_factory.create_app()
|
||||||
|
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
|
||||||
|
importlib.reload(app_factory)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""Unit tests for the pure brewing logic in ``brewing.py``.
|
||||||
|
|
||||||
|
These are the tests that matter most: they pin the gravity parsing, the ABV
|
||||||
|
formula and the run-aggregation rules that the whole UI is built on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from brewing import (ABV_FACTOR, Reading, Run, abv_percent, assumed_og_abv_percent,
|
||||||
|
days_between, final_reading, first_reading,
|
||||||
|
is_plausible_sg, is_active, last_reading,
|
||||||
|
legacy_abv_percent, parse_date, parse_sg_points,
|
||||||
|
run_summary, sg_points_to_decimal)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- parsing #
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("1039", 1039),
|
||||||
|
(1039, 1039),
|
||||||
|
(1039.0, 1039),
|
||||||
|
(" 1045 ", 1045),
|
||||||
|
("1.039", 1039),
|
||||||
|
(1.045, 1045),
|
||||||
|
("None", None),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
("abc", None),
|
||||||
|
(True, None),
|
||||||
|
])
|
||||||
|
def test_parse_sg_points(raw, expected):
|
||||||
|
assert parse_sg_points(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_sg_points_to_decimal():
|
||||||
|
assert sg_points_to_decimal(1039) == 1.039
|
||||||
|
assert sg_points_to_decimal(1012) == 1.012
|
||||||
|
assert sg_points_to_decimal(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("2026-11-08", date(2026, 11, 8)),
|
||||||
|
(date(2026, 11, 8), date(2026, 11, 8)),
|
||||||
|
("08/11/2026", date(2026, 11, 8)),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
("not a date", None),
|
||||||
|
])
|
||||||
|
def test_parse_date(raw, expected):
|
||||||
|
assert parse_date(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- ABV #
|
||||||
|
|
||||||
|
def test_abv_uses_the_standard_formula():
|
||||||
|
"""ABV% = (OG - FG) * 131.25, i.e. points drop * 0.13125."""
|
||||||
|
# 1.050 -> 1.010 is a 40 point drop = 5.25% ABV.
|
||||||
|
assert abv_percent(40) == 5.25
|
||||||
|
assert abv_percent(40) == pytest.approx(40 * ABV_FACTOR)
|
||||||
|
assert abv_percent(0) == 0.0
|
||||||
|
assert abv_percent(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_abv_matches_worked_examples():
|
||||||
|
# Session IPA: 1.050 -> 1.010 = 5.25%
|
||||||
|
assert abv_percent(50 - 10) == 5.25
|
||||||
|
# 1.060 -> 1.012 = 6.29%
|
||||||
|
assert abv_percent(60 - 12) == pytest.approx(6.29, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_formula_is_kept_for_comparison():
|
||||||
|
"""The old formula is preserved verbatim so history can show the delta."""
|
||||||
|
assert legacy_abv_percent(1050, 1010) == round(((1050 - 1010) / 7.36) + 0.5, 2)
|
||||||
|
# The legacy formula claims 0.5% even with no drop at all.
|
||||||
|
assert legacy_abv_percent(1010, 1010) == 0.5
|
||||||
|
assert legacy_abv_percent(None, 1010) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_formula_overstates_the_real_figure():
|
||||||
|
"""The reason the history page compares the two at all."""
|
||||||
|
standard = abv_percent(50 - 10)
|
||||||
|
legacy = legacy_abv_percent(1050, 1010)
|
||||||
|
assert legacy > standard
|
||||||
|
|
||||||
|
|
||||||
|
def test_assumed_og_abv():
|
||||||
|
assert assumed_og_abv_percent(1012) == pytest.approx(0.0)
|
||||||
|
assert assumed_og_abv_percent(1010) == pytest.approx(0.26)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- plausibility #
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("points,plausible", [
|
||||||
|
(1010, True),
|
||||||
|
(990, True),
|
||||||
|
(1200, True),
|
||||||
|
(989, False),
|
||||||
|
(1201, False),
|
||||||
|
(900, False),
|
||||||
|
(None, False),
|
||||||
|
])
|
||||||
|
def test_is_plausible_sg(points, plausible):
|
||||||
|
assert is_plausible_sg(points) is plausible
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- readings #
|
||||||
|
|
||||||
|
def _doc(run_id="1", beer_type="Pale Ale", sg="1045", date="2026-01-01",
|
||||||
|
final="False", comment=""):
|
||||||
|
return {
|
||||||
|
"_id": f"id-{run_id}-{date}-{sg}",
|
||||||
|
"beer_run_id": run_id,
|
||||||
|
"beer_type": beer_type,
|
||||||
|
"sg": sg,
|
||||||
|
"date": date,
|
||||||
|
"final_reading": final,
|
||||||
|
"comment": comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_from_doc_accepts_string_and_bool_flags():
|
||||||
|
assert Reading.from_doc(_doc(final="True")).final_reading is True
|
||||||
|
assert Reading.from_doc(_doc(final="False")).final_reading is False
|
||||||
|
assert Reading.from_doc(_doc(final=True)).final_reading is True
|
||||||
|
assert Reading.from_doc(_doc(final=False)).final_reading is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_reading_from_doc_cleans_whitespace():
|
||||||
|
reading = Reading.from_doc(_doc(beer_type=" Pale Ale ", comment=" hops in "))
|
||||||
|
assert reading.beer_type == "Pale Ale"
|
||||||
|
assert reading.comment == "hops in"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_and_last_reading():
|
||||||
|
readings = [
|
||||||
|
Reading.from_doc(_doc(date="2026-03-01", sg="1050")),
|
||||||
|
Reading.from_doc(_doc(date="2026-01-01", sg="1060")),
|
||||||
|
Reading.from_doc(_doc(date="2026-02-01", sg="1040")),
|
||||||
|
]
|
||||||
|
assert first_reading(readings).date == date(2026, 1, 1)
|
||||||
|
assert last_reading(readings).date == date(2026, 3, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_between():
|
||||||
|
assert days_between(date(2026, 1, 1), date(2026, 1, 8)) == 7
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- runs #
|
||||||
|
|
||||||
|
def test_run_orders_readings_chronologically():
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(date="2026-03-01", sg="1010"),
|
||||||
|
_doc(date="2026-01-01", sg="1060"),
|
||||||
|
])
|
||||||
|
assert [r.date for r in run.readings] == [date(2026, 1, 1), date(2026, 3, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_original_and_final_gravity():
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(date="2026-01-01", sg="1050"),
|
||||||
|
_doc(date="2026-01-10", sg="1030"),
|
||||||
|
_doc(date="2026-01-20", sg="1010", final="True"),
|
||||||
|
])
|
||||||
|
assert run.original_gravity() == 1050
|
||||||
|
assert run.final_gravity() == 1010
|
||||||
|
assert run.gravity_drop_points() == 40
|
||||||
|
assert run.abv_percent() == 5.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_is_active_until_a_final_reading_exists():
|
||||||
|
active = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
|
||||||
|
assert active.is_active() is True
|
||||||
|
assert is_active(active.readings) is True
|
||||||
|
|
||||||
|
finished = Run.from_docs("2", [
|
||||||
|
_doc(run_id="2", date="2026-01-01", sg="1050"),
|
||||||
|
_doc(run_id="2", date="2026-01-20", sg="1010", final="True"),
|
||||||
|
])
|
||||||
|
assert finished.is_active() is False
|
||||||
|
assert final_reading(finished.readings) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_display_name_prefers_the_most_used_label():
|
||||||
|
"""The stored beer_type drifts across readings; the most-used one wins."""
|
||||||
|
run = Run.from_docs("4", [
|
||||||
|
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
|
||||||
|
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
|
||||||
|
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
|
||||||
|
])
|
||||||
|
# All three are unique, so the most recent wins.
|
||||||
|
assert run.display_name() == "Nocturna Christmas beer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_display_name_falls_back_to_the_run_number():
|
||||||
|
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
|
||||||
|
assert run.display_name() == "Run 7"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_comments_are_collected_oldest_first():
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(date="2026-01-01", comment="first"),
|
||||||
|
_doc(date="2026-01-10", comment=""),
|
||||||
|
_doc(date="2026-01-20", comment="last"),
|
||||||
|
])
|
||||||
|
assert run.comments() == ["first", "last"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_outliers_are_flagged():
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(date="2026-01-01", sg="1045"),
|
||||||
|
_doc(date="2026-01-10", sg="900"), # implausible
|
||||||
|
_doc(date="2026-01-20", sg="1010"),
|
||||||
|
])
|
||||||
|
assert [r.sg_points for r in run.outlier_readings()] == [900]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_days_in_fermenter_for_a_finished_run():
|
||||||
|
run = Run.from_docs("1", [
|
||||||
|
_doc(date="2026-01-01", sg="1050"),
|
||||||
|
_doc(date="2026-01-15", sg="1010", final="True"),
|
||||||
|
])
|
||||||
|
assert run.days_in_fermenter() == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_days_in_fermenter_for_an_active_run_uses_today():
|
||||||
|
run = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
|
||||||
|
assert run.days_in_fermenter(today=date(2026, 1, 11)) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_projected_abv_is_never_negative():
|
||||||
|
"""Regression: the legacy assumed-OG of 1012 produced negative alcohol
|
||||||
|
whenever the current reading was above 1012 (a normal OG)."""
|
||||||
|
run = Run.from_docs("20", [
|
||||||
|
_doc(run_id="20", beer_type="Out on patio", date="2026-11-08", sg="1039"),
|
||||||
|
])
|
||||||
|
projected = run.projected_abv_percent()
|
||||||
|
# No OG is known for a single-reading run, so there is no estimate —
|
||||||
|
# and certainly no negative one.
|
||||||
|
assert projected is None
|
||||||
|
|
||||||
|
run2 = Run.from_docs("20b", [
|
||||||
|
_doc(run_id="20b", date="2026-11-08", sg="1039"),
|
||||||
|
_doc(run_id="20b", date="2026-11-15", sg="1020"),
|
||||||
|
])
|
||||||
|
assert run2.projected_abv_percent() >= 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_projected_abv_uses_the_first_reading_as_the_og():
|
||||||
|
run = Run.from_docs("21", [
|
||||||
|
_doc(run_id="21", date="2026-11-08", sg="1045"),
|
||||||
|
_doc(run_id="21", date="2026-11-15", sg="1030"),
|
||||||
|
])
|
||||||
|
# 15 point drop = 1.97%
|
||||||
|
assert run.projected_abv_percent() == pytest.approx(1.97)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projected_abv_is_unknown_for_a_single_reading_with_no_og():
|
||||||
|
"""One reading and no recorded OG: there is no reliable estimate, so the
|
||||||
|
figure is None (the UI shows a dash) rather than a misleading number."""
|
||||||
|
run = Run.from_docs("22", [_doc(run_id="22", date="2026-11-08", sg="1005")])
|
||||||
|
assert run.projected_abv_percent() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_projected_abv_uses_a_recorded_og_override():
|
||||||
|
"""A run with a single reading can still be estimated once an OG has
|
||||||
|
been recorded against it."""
|
||||||
|
run = Run.from_docs("23", [_doc(run_id="23", date="2026-11-08", sg="1010")])
|
||||||
|
assert run.projected_abv_percent(og_override=1045) == pytest.approx(4.59)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projected_abv_is_none_when_gravity_rises():
|
||||||
|
"""Gravity rising during fermentation is an anomaly, not alcohol."""
|
||||||
|
run = Run.from_docs("24", [
|
||||||
|
_doc(run_id="24", date="2026-11-08", sg="1010"),
|
||||||
|
_doc(run_id="24", date="2026-11-15", sg="1015"),
|
||||||
|
])
|
||||||
|
assert run.projected_abv_percent() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_summary_shape():
|
||||||
|
run = Run.from_docs("9", [
|
||||||
|
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-01", sg="1050"),
|
||||||
|
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-20", sg="1010",
|
||||||
|
final="True", comment="Keghing"),
|
||||||
|
])
|
||||||
|
summary = run_summary(run)
|
||||||
|
assert summary["beer_run_id"] == "9"
|
||||||
|
assert summary["name"] == "Pale Ale"
|
||||||
|
assert summary["active"] is False
|
||||||
|
assert summary["abv"] == 5.25
|
||||||
|
assert summary["legacy_abv"] == legacy_abv_percent(1050, 1010)
|
||||||
|
assert summary["og_points"] == 1050
|
||||||
|
assert summary["fg_points"] == 1010
|
||||||
|
assert summary["comments"] == ["Keghing"]
|
||||||
|
assert len(summary["readings"]) == 2
|
||||||
|
assert summary["readings"][0]["date"] == "2026-01-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_summary_handles_an_empty_run():
|
||||||
|
summary = run_summary(Run.from_docs("99", []))
|
||||||
|
assert summary["abv"] is None
|
||||||
|
assert summary["reading_count"] == 0
|
||||||
|
assert summary["name"] == "Run 99"
|
||||||
Reference in New Issue
Block a user