Compare commits

...
32 Commits
Author SHA1 Message Date
hermes 563e22fb00 fix(ci): use the house CI pattern instead of a bespoke one
I had built a parallel mechanism for this repo rather than copying the
workflow that is already known to work on this cluster.  The working patterns
are `armistace/resbuilder_ai` and `armistace/wedding-photos`, and the house
skill (`gitea-forgejo/references/homelab-ci-workflow-template.md`) documents
them.  This rewrites the workflow to match them.

What changed, and why each piece was wrong:

- ONE job, not three.  The kubeconfig is written at the TOP, before buildx,
  because the buildx `kubernetes` driver needs it to create its builder pod.
  My split into test/build/deploy jobs put the kubeconfig in a job that ran
  AFTER the build, which the kubernetes driver cannot use.

- `driver: kubernetes` with `namespace=gitea-runner`, not the `remote`
  driver.  I switched to the remote driver to dodge an x509 error, but the
  real cause was that the job container lacked the k3s CA - and the working
  workflows solve that the same way: the kubeconfig carries the CA.  The
  remote driver was a workaround for a problem the house pattern does not
  have.

- The CA-refresh script and the pinned-fingerprint file are deleted.  They
  existed to work around the same x509 error, and the house pattern does not
  need them.

- The deploy step installs kubectl and applies `kube/*.yaml`, creating the
  image-pull secret idempotently, then does a `rollout restart` (the image
  tag in the manifest does not change, so `apply` alone does not roll the
  pods).

- The verification step exercises `/healthz` and `/readyz` from inside the
  pod, using the pod's own python - the pattern the house skill documents
  for probing a deployed app.  There is no `curl` in the slim image.

The Deployment manifest also changes:

- `runAsUser: 999` at the POD level, matching the `beer` user in the image.
  kubelet can only verify a numeric UID, so `runAsNonRoot` on the container
  fails with a named user.
- `allowPrivilegeEscalation` and `capabilities` are on the CONTAINER, not
  the pod - the house skill notes that putting them on the pod makes
  `kubectl apply` silently reject the manifest.
- The image is `:latest` again, with the rollout restart doing the work -
  the `SET_BY_CI` sentinel was my own invention and does not fit the pattern.

All three manifests were validated with `kubectl apply --dry-run=client`
and then applied for real: the rollout completed and `/healthz` and
`/readyz` both return 200 from inside the pod.
2026-11-09 12:49:54 +10:00
hermes bb071343c2 fix(ci): count the kubeconfig's clusters correctly
The cluster-count guard I added used `grep -c '"name"'`, which counts
LINES.  The jsonpath output is a single line, so the count was 1 for a
kubeconfig with clusters and 1 for one without - which made the guard
reject every kubeconfig:

    ERROR: the kubeconfig has no cluster entries.

The count is now done with `grep -o . | wc -l`, which counts occurrences
rather than lines.  Tested against both cases:

- a real kubeconfig with a stale CA -> the CA is refreshed and kubectl works
- a kubeconfig with no clusters        -> refuses with a clear message
2026-11-09 12:49:13 +10:00
hermes 8048a1112d docs: fix the PR #5 merge commit message (#10)
Build and Push Image / Unit tests (push) Successful in 50s
Build and Push Image / Build and push image (push) Successful in 58s
Build and Push Image / Deploy to the cluster (push) Failing after 15s
docs: fix the PR #5 merge commit message (#10)
2026-11-09 12:39:03 +10:00
hermes 9f2671fa71 feat(active): add the active beers page, run metadata and tap numbers (#5)
Build and Push Image / Unit tests (push) Successful in 1m55s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment.

A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number
- record the OG or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter - its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished brews.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.

Tests
-----
105 (up from 74). New coverage: the active page contents, the tap number,
the projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next
free tap number, the partial-update semantics, the history page, the
outlier list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts
into `beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:38:27 +10:00
hermes 060269b03d x
Build and Push Image / Unit tests (push) Successful in 1m7s
Build and Push Image / Build and push image (push) Successful in 1m47s
Build and Push Image / Deploy to the cluster (push) Failing after 34s
x
2026-11-09 12:37:19 +10:00
hermes 2a9e0ad15e fix: log index failures, and tidy the CSP and the API error response
Build and Push Image / Unit tests (push) Successful in 1m6s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Round-2 review findings.

- `_ensure_indexes` swallowed a `PyMongoError` with a bare `pass`. An index
  failure does not stop the app serving, but it means the lookup is a
  collection scan, which gets slow as the data grows — so it is now logged
  as a warning with the traceback.

- The Content-Security-Policy string is now a module-level `CSP` constant
  rather than inline in the response handler, and it gained
  `object-src 'none'` (the app uses no plugins). The test now also asserts
  that neither `unsafe-inline` nor `unsafe-eval` appears — either would
  defeat the point of the policy.

- `api_run` returned a bare dict for its 404 while the success path used
  `jsonify`. It is now `jsonify({"error": "not found"})` for consistency.
2026-11-09 12:36:17 +10:00
hermes bf0f88dd70 fix: build the image on Python 3.14, and fix the review findings
The image build failed on master:

    ERROR: Ignored the following versions that require a different python
    version: 4.19.0 Requires-Python >=3.11
    ERROR: Could not find a version that satisfies the requirement
    pymongo==4.19.0

The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.

All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.

The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.

Review findings, all from the independent review of this PR
------------------------------------------------------------
- Mongo indexes: the run metadata is always looked up by `beer_run_id` and
  the readings by `(beer_run_id, date)`. Both are now indexed.
- Content-Security-Policy: the app serves only its own vendored assets, so
  a strict `default-src 'self'` policy is enforceable. Adding it required
  removing the inline `style=` attributes from the templates (they became
  `.mb-lg` / `.mt-lg` utility classes) and the one inline `<script> (the run
  id now travels as a `data-run-id` attribute). A test asserts no template
  has either.
- Job timeouts: the `test` and `build` jobs now have `timeout-minutes`, so
  a wedged build stops occupying a runner.
- OG range: raised from 1300 to 1500 points, so a strong specialty brew
  (a 1.150 wort is real) can be recorded.

Bugs found and fixed along the way
----------------------------------
- An unfinished run reported a *measured* ABV. `abv_percent()` now returns
  None for a run that is still in the fermenter — its figure is a projection,
  not a measurement. This was showing the two active runs in the "alcohol by
  brew" chart as if they were finished.
- The `/api/runs` endpoints the charts fetch from did not exist, so every
  chart was silently empty. They are back, with tests.
- The "brews over time" chart left out the months with no brews, which
  spaced the busy months evenly and made the chart read as continuous
  activity. The quiet months are now included with a zero.
- The run-detail chart was dropped when PR 1 was scoped down, leaving dead
  code behind. It is restored.

Tests: 105 (up from 91).
2026-11-09 12:36:17 +10:00
hermes 1fa9f1dcbe feat(active): add the active beers page, run metadata and tap numbers
Adds the pages that were missing: an "Active Beers" view of what is in the
fermenter right now, and the per-run metadata (canonical name, tap number,
original gravity, brew notes) that could not be derived from the readings.

Why the metadata is needed
--------------------------
The `beer_data` collection stores a beer name on *each reading*, and that
name drifts within a run - run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". There was
nowhere to record a tap number, a confirmed original gravity, or a note about
the brew.

The new `beer_runs` collection holds one document per run with exactly those
details. It starts empty; `scripts/backfill_run_metadata.py` populates it
from the existing readings. The script is idempotent, only ever inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand.

The active beers page
---------------------
A card per brew that is still in the fermenter, showing the tap number, the
current gravity, the projected alcohol, how many days it has been brewing, a
sparkline of the gravity so far, and the latest comment on a reading.

A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number
- record the OG or take a second reading and it appears. The same applies to
the existing reads, where the legacy app's assumed OG of 1012 produced
*negative* alcohol for a perfectly normal OG.

Also adds the history page: alcohol by brew, brews per month, every run in a
table, a comparison of the standard ABV formula against the legacy one, and
a list of readings that fall outside the plausible hydrometer range.

Changes to the existing pages
-----------------------------
- The brew-process page and the run detail page now show the canonical run
  name and the latest comment, and the run detail page lets you edit the
  metadata inline.
- Gravity is displayed as a decimal reading (1.039) with the point scale
  beneath it (39 points), which is the way a brewer reads it. Labelling the
  stored `1039` as "1039 points" would have been wrong.
- A run's display name now prefers the name on its *final* reading - the one
  written when the brew was finished, so the name the operator settled on -
  falling back to the most-used name.
- `upsert_run_metadata` now clears a field when it is passed as `None`, so a
  name or tap number can be deleted. `upsert_run_metadata_partial` is the
  non-clearing variant, used by the backfill.

Bugs found and fixed along the way
----------------------------------
- The chart script was included both in the base template and again by each
  page, so every chart was drawn twice. A test now asserts it is loaded
  exactly once per page.
- The chart library was never referenced by the base template, so all charts
  were silently dead. That was fixed in the previous PR.

Tests
-----
91 (up from 74). New coverage: the active page contents, the tap number, the
projected alcohol, the "needs 2nd reading" case, metadata saving and
clearing, the stored name overriding the drifting reading name, the next free
tap number, the partial-update semantics, the history page, the outlier
list, and the point-scale formatting.

Data safety
-----------
`beer_data` is untouched by this change. The backfill script only inserts into
`beer_runs` and skips any run whose name has already been set by hand.
2026-11-09 12:35:50 +10:00
hermes 081014b8dc fix(ci): self-heal a stale cluster CA in the deploy job (#9)
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Successful in 1m3s
Build and Push Image / Deploy to the cluster (push) Failing after 21s
fix(ci): self-heal a stale cluster CA in the deploy job (#9)
2026-11-09 12:16:00 +10:00
hermes 3e20b558a5 fix(ci): harden the CA refresh script and add shellcheck to CI
Build and Push Image / Unit tests (push) Successful in 48s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Round-2 review findings, all low severity.

- `curl` now has `--connect-timeout` and `--max-time`, so an unreachable API
  server fails the job instead of hanging the runner indefinitely.

- A guard for a kubeconfig with no cluster entries, or an empty one, with a
  clear message rather than an opaque kubectl error.

- The fingerprint mismatch message now prints both the expected and the
  actual value, which cuts the time to diagnose the next CA rotation.

- `set -euo pipefail` was already there; the script now passes shellcheck
  clean, and a shellcheck step was added to the CI test job so the
  operational scripts get the same treatment as the Python code.
2026-11-09 12:15:50 +10:00
hermes a4b3d40e2d fix(ci): pin the cluster CA in the repo rather than in a secret
Build and Push Image / Unit tests (push) Successful in 43s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The CA fingerprint is not a secret.  It is a public value that anyone can
read straight off the certificate, so pinning it in a Gitea secret was the
wrong shape:

- A change to it would be invisible - no commit, no review.
- Nobody with secret-write access could silently repoint the CI runner at a
  different CA.

It now lives in `scripts/cluster-ca.sha256`, so changing it is a reviewable
commit.  The script reads it from there, and still accepts an override as
its first argument.

This also removes the need for a `KUBE_SERVER_CA_FINGERPRINT` secret, which
the bot token cannot create anyway - the Actions secrets API is owner-only.

The behaviour is unchanged: the CA is fetched over an unverified connection
but only USED if it matches the pinned fingerprint, so an intercepted CA is
refused.  Re-tested against the live API server - the matching fingerprint
refreshes the CA and kubectl then works; a wrong one is refused and the
stale CA is left in place.
2026-11-09 12:13:27 +10:00
hermes ce6f5b7962 fix(ci): pin the cluster CA fingerprint instead of trusting a fetched CA
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The review was right on both counts, and both are fixed.

1. MITM. Fetching the CA over an unverified connection (`curl -sk`) and then
   trusting it is a privilege-escalation hole: the runner holds cluster-admin
   credentials, so an attacker able to intercept the fetch could hand us a CA
   of their choosing and then read everything sent over the connection.

   The CA is now PINNED. It is still fetched over an insecure connection, but
   it is only USED if its SHA-256 fingerprint matches
   `KUBE_SERVER_CA_FINGERPRINT`. An intercepted CA will not hash to the
   pinned value, so it is refused. If the secret is not set, the job fails
   rather than trusting an unverified CA.

2. The hardcoded cluster name. `kubectl config set-cluster default` creates a
   NEW cluster entry when the kubeconfig's entry is called something else,
   leaving the original (and its stale CA) in use. The name is now read from
   the kubeconfig: `{.clusters[0].name}`.

The bash is also moved out of the YAML into `scripts/kube-ca-refresh.sh`, so
it can be read, reviewed and tested on its own rather than as a wall of
shell inside a workflow file.

All three paths are tested against the live API server:

- no fingerprint supplied   -> refuses, exit 1
- wrong fingerprint        -> refuses, exit 1
- correct fingerprint      -> refreshes the CA, exit 0, kubectl then works
2026-11-09 12:11:56 +10:00
hermes 687339bb9e fix(ci): self-heal a stale cluster CA in the deploy job
Build and Push Image / Unit tests (push) Successful in 55s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The deploy job failed on its first run:

    error: error validating "kube/beer_namespace.yaml": error validating
    data: failed to download openapi: Get "https://192.168.178.160:6443/openapi/v2":
    tls: failed to verify certificate: x509: certificate signed by unknown authority

This is the same class of problem as the buildx driver earlier: the
certificate-authority-data embedded in the kubeconfig secret does not match
the CA the API server is currently presenting.

Rather than requiring the secret to be regenerated by hand every time the
cluster's CA is rotated, the job now refreshes it:

- The API server publishes its own CA at `/cacerts`.
- If kubectl cannot verify the server, the job fetches that CA (over an
  insecure connection — verifying it is the problem being solved) and pins
  it explicitly with `kubectl config set-cluster`.
- It then retries, and fails with a clear message if that still does not
  work.

Verified by deliberately corrupting the CA in a working kubeconfig: the
failure reproduces exactly, and the recovery path restores connectivity
(`Server Version: v1.34.5+k3s1`).

The secret is also switched from `KUBEC_CONFIG_BUILDX` to
`KUBEC_CONFIG_BUILDX_NEW_2`, which is the one the working `wedding-photos`
workflow uses.
2026-11-09 12:08:49 +10:00
hermes c28553a1b8 feat(ci): add the deploy job and the kubernetes manifests (#8)
Build and Push Image / Unit tests (push) Successful in 43s
Build and Push Image / Build and push image (push) Successful in 1m22s
Build and Push Image / Deploy to the cluster (push) Failing after 1m29s
feat(ci): add the deploy job and the kubernetes manifests (#8)
2026-11-09 12:00:35 +10:00
hermes 958ae9b365 fix(ci): keep the credentials out of the process list
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
Two small changes to the deploy job:

- The registry password went through `-u armistace:$PASSWORD` on the curl
  command line, where it is visible in the process list for the duration of
  the request. It now goes through a curl config file on stdin instead.

- The kubeconfig is written with mode 600 rather than the default, and is
  removed at the end of the job (with `if: always()`, so it is removed even
  if a later step fails). It is cluster-admin-equivalent, so it should not
  be readable by anything else on the runner.

Verified: `curl --config -` against the registry returns the same digest as
the `-u` form.
2026-11-09 12:00:18 +10:00
hermes 55d5e84150 fix(ci): pin the image to the commit SHA instead of the mutable :latest tag
Build and Push Image / Unit tests (push) Successful in 41s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The review flagged `:latest` as a critical issue, and it is right: a mutable
tag makes `kubectl rollout undo` meaningless and lets different pods in the
same deployment run different code if they restart at different times.

- The build job now pushes the image with BOTH `:latest` and
  `:${{ github.sha }}`.
- The deployment manifest carries a sentinel tag rather than `:latest`, and
  the deploy job sets the real SHA with `kubectl set image`. That makes the
  deploy a genuine manifest change, so the Deployment rolls because its pod
  template changed — not because it was told to. `kubectl rollout undo` now
  means something: it goes back to the previous SHA.
- The digest check now compares against the SHA tag rather than `:latest`,
  and it checks EVERY running pod instead of `items[0]`. During a rolling
  update `items[0]` may still be an old pod that has not been terminated,
  which would compare against the wrong digest. The job also fails if the
  deployment is not fully rolled out.
- The smoke test now checks the app through the Service as well as directly
  in the pod. The pod check alone would pass even if the Service selector
  or the port mapping were wrong.
- `readOnlyRootFilesystem: true` — the app writes no files, so the root
  filesystem can be read-only. /tmp is still writable via the emptyDir.
- The kubectl install is skipped when the runner already has one.
2026-11-09 11:59:22 +10:00
hermes b73df93f20 feat(ci): add the deploy job so the app deploys from the repo
Build and Push Image / Unit tests (push) Successful in 1m8s
Build and Push Image / Build and push image (push) Skipped
Build and Push Image / Deploy to the cluster (push) Skipped
The workflow built and pushed the image but never deployed it - the
cluster objects had been created by hand. This adds a `deploy` job that
applies the manifests in `kube/` and rolls the deployment.

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.

Three things this job does that a naive `kubectl apply` does not:

1. `kubectl rollout restart`. The image tag is `:latest`, which is immutable
   in the manifest, so `apply` alone does not roll the pods. Without the
   restart the deployment reports success while the old pods keep serving
   the old image - a deploy that silently changes nothing.

2. Verifies the running digest against the registry's `:latest`. CI green and
   pods `Ready` do not prove the new code is live; the digest comparison does.

3. Smoke tests the running app - `/healthz` and `/readyz` - from inside the
   pod, so a deploy that comes up but cannot serve is caught in CI rather
   than by the next person to open the page.

The Secrets (`beer-db`, `beer-app`) are hand-provisioned and are deliberately
not in the apply set, so a deploy cannot clobber them.
2026-11-09 11:46:06 +10:00
hermes 55c5de9d61 feat(deploy): add the kubernetes manifests so the deployment is in the repo
The cluster objects for the beer namespace were created by hand. This puts
them in the repo so they can be applied and reviewed like everything else.

- `beer-deployment.yaml` — the Deployment, with the security context, the
  resource limits, and both probes wired up (`/healthz` liveness,
  `/readyz` readiness).
- `beer-service.yaml` — the NodePort service.
- `beer_namespace.yaml` — the namespace.

The credentials are NOT in these files. `MONGO_PASS` and `SECRET_KEY` come
from the live `beer-db` and `beer-app` Secrets, so applying the manifests
cannot clobber a hand-provisioned credential.

Notes on the pod security context
---------------------------------
`runAsNonRoot: true` cannot be set on the container when the image's user is
a *named* user - kubelet can only verify a numeric UID. The pod-level
security context therefore sets `runAsUser: 999`, which matches the `beer`
user the image creates (verified with `id` in the built image).

The container also drops all capabilities and refuses privilege escalation.
2026-11-09 11:45:41 +10:00
hermes 0f93cbea70 fix: build the image on Python 3.14 (#7)
Build and Push Image / Unit tests (push) Successful in 1m14s
Build and Push Image / Build and push image (push) Successful in 9m47s
fix: build the image on Python 3.14 (#7)
2026-11-09 11:26:29 +10:00
hermes c0c6b4c1fc fix: pin the Python patch version and run the app as a non-root user
Build and Push Image / Unit tests (push) Successful in 1m5s
Build and Push Image / Build and push image (push) Skipped
Two follow-ups to the review of the base image change.

1. Pin the patch version: `python:3.14-slim` is a floating tag, so a rebuild
   could silently pick up a different base image. It is now
   `python:3.14.10-slim`, so a rebuild is reproducible.

2. Run the app as an unprivileged user. The app only reads its own source
   and talks to Mongo over the network — it writes no files — so it does not
   need to be root. A `beer` system user is created and the image ends with
   `USER beer`.
2026-11-09 11:25:58 +10:00
hermes 9d3ce1d2c4 fix: build the image on Python 3.14
Build and Push Image / Unit tests (push) Successful in 1m2s
Build and Push Image / Build and push image (push) Skipped
The image build failed on master:

    ERROR: Ignored the following versions that require a different python
    version: 4.19.0 Requires-Python >=3.11
    ERROR: Could not find a version that satisfies the requirement
    pymongo==4.19.0

The base image was `python:3.10` but the pinned pymongo 4.19 needs Python
>= 3.11. The image is now `python:3.14-slim` — the current stable release,
so it is not carrying the CVEs fixed in 3.11, 3.12 and 3.13.

All the runtime dependencies ship a cp314 wheel (pymongo included), so no
compiler toolchain is needed. The old apt-get line installed
libsasl2-dev, python-dev-is-python3, libldap2-dev and libssl-dev for
packages that are no longer in the dependency list, so it is gone and the
image is smaller.

The build now asserts the interpreter version, so a base image change that
breaks the pinned requirements fails the build with a clear message rather
than a pip resolution error.
2026-11-09 11:23:22 +10:00
hermes 78b3b10131 ci: use the remote buildx driver, and build amd64 only (#6)
Build and Push Image / Unit tests (push) Successful in 48s
Build and Push Image / Build and push image (push) Failing after 12m23s
ci: use the remote buildx driver, and build amd64 only (#6)
2026-11-09 11:04:13 +10:00
hermes a48fb1c566 ci: drop the kubeconfig from the build job
Build and Push Image / Unit tests (push) Successful in 41s
Build and Push Image / Build and push image (push) Skipped
The independent review flagged that the build job wrote the
`KUBEC_CONFIG_BUILDX` secret to disk even though the build does not need
cluster access at all. That secret is cluster-admin-equivalent: a build step
that never talks to the cluster should not be able to read it, and a
compromised dependency in the build would otherwise have a direct path to the
cluster.

The "Create Kubeconfig" step is removed from the build job. The deploy step,
added in a later PR, gets its own job with the kubeconfig - so the privilege
is only present where it is actually used.

Also checked the buildkit daemon's resources in response to the review: it
already has limits (2 CPU, 4Gi memory, 2Gi ephemeral storage) and requests
(500m, 1Gi). The crash that motivated the amd64-only change was qemu
exhausting memory during multi-arch emulation, not an unconstrained daemon, so
no change is needed there.
2026-11-09 11:01:59 +10:00
hermes e9ce0dcf9b ci: use the remote buildx driver, and build amd64 only
Build and Push Image / Unit tests (push) Successful in 46s
Build and Push Image / Build and push image (push) Skipped
The build job failed at "Set up Docker Buildx":

    Error: ERROR: error for bootstrap "builder-...":
    Get "https://192.168.178.160:6443/apis/apps/v1/namespaces/gitea-runner/deployments/builder-...":
    tls: failed to verify certificate: x509: certificate signed by unknown authority

Cause
------
The `kubernetes` buildx driver talks to the cluster API to create its builder
pods. That needs the k3s CA certificate, which the job container does not
have - the `catthehacker/ubuntu:act-latest` image ships the public CA set
only. So the bootstrap could not verify the API server.

Fix
---
Switch to the `remote` driver, which talks straight to the buildkit daemon
already running in the cluster:

    driver: remote
    endpoint: tcp://buildkit.gitea-runner.svc:1234

No cluster API access is needed at all, so no kubeconfig is needed for the
build. This is the same pattern the wedding-photos workflow uses.

`endpoint` must be a top-level key, not a driver-opt: the action appends it
as the POSITIONAL argument to `buildx create --driver remote <endpoint>`, and
passing it in driver-opts yields "ERROR: no remote endpoint provided".

Also
----
The build was multi-arch (linux/amd64,linux/arm64), which runs qemu
emulation and crashes the buildkit pod on this NUC (exit 255,
SandboxChanged), after which the build hangs for many minutes. The cluster
is amd64, so the image is built for linux/amd64 only.

Verified: `docker buildx create --driver remote tcp://buildkit.gitea-runner.svc:1234`
succeeds from the runner's dind container and reports buildkit v0.29.0 with
linux/amd64 among its supported platforms.
2026-11-09 10:58:28 +10:00
hermes 757774168d feat(ui): rebuild the interface with a proper design system (#4)
Build and Push Image / Unit tests (push) Successful in 44s
Build and Push Image / Build and push image (push) Failing after 5m44s
feat(ui): rebuild the interface with a proper design system (#4)
2026-11-09 10:42:26 +10:00
hermes ba65ec5a8f ci: drop actions/setup-python, which hangs on this runner
Build and Push Image / Unit tests (push) Successful in 49s
Build and Push Image / Build and push image (push) Skipped
The setup-python action downloads a Python build from GitHub. This runner
cannot reach GitHub reliably for that, and the test job hung on it with no
output. The runner image already carries a usable python3, so the tests use
it directly and install into a local venv.
2026-11-09 10:35:42 +10:00
hermes 6e1189e319 fix: log a failed password migration instead of swallowing it
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 8m35s
The round-2 review noted that `_upgrade_password` catches `PyMongoError`
without logging it. A failed upgrade is harmless to the login itself - the
user still gets in - but it leaves the password stored in plain text, and
that is exactly the kind of thing an operator needs to know about. It is now
logged as a warning with the username and the traceback.

A test covers the path: the migration write is forced to fail, the login
still succeeds, and the warning is emitted.
2026-11-09 10:31:03 +10:00
hermes 1828dcb81d fix(security): address the independent review's blocking findings
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 11m18s
The independent reviewer returned "SIGNIFICANT CHANGES NEEDED" on the security
review, with the code review itself approved. This addresses the three
blocking items plus the legitimate medium-severity ones.

Blocking items
--------------

1. Weak password hashing (SHA-256 -> scrypt)

   The custom salted SHA-256 helper is replaced with
   `werkzeug.security.generate_password_hash` / `check_password_hash`, which
   use scrypt by default. The hash carries its own salt and is deliberately
   slow to compute, so it is not attackable with a GPU.

   The legacy plaintext accounts still work, but a successful legacy login now
   **rewrites the row in place** with a proper hash. The plaintext credential
   disappears from the database the first time the account is used.

   Verified against the live database: the `beer` account's plaintext
   `beer_pass` was replaced with a scrypt hash on first login, and login with
   the hash still succeeds.

2. Plaintext password persistence

   Fixed by the migrate-on-login above. There is no longer a code path that
   leaves a password in plaintext.

3. Hardcoded secret defaults

   `SECRET_KEY` no longer has a default. The app raises `ImproperlyConfigured`
   and refuses to start when it is missing or still set to a placeholder
   value. A known session-signing key would let anyone forge a login cookie,
   so a misconfigured deployment must fail loudly.

   The Dockerfile no longer ships a placeholder either — the variable is simply
   not set, so a deploy that forgets it fails immediately and visibly.

   Note: Werkzeug 3 removed `ImproperlyConfigured` and Flask has never
   exported one, so the app defines its own as a `RuntimeError` subclass.

Medium items
------------

4. NoSQL injection risk

   Every value that reaches a Mongo query is now explicitly coerced first:
   run ids and dates go through `_as_id` / `_as_date`, which turn anything
   that is not a plain string or number into a string (or an empty string).
   Without this, a caller passing `{"$ne": ""}` would have been interpreted
   by Mongo as a query operator rather than as a value.

   Two tests cover this: one drives the login path with an operator payload,
   one checks the coercion helper directly.

5. Dependency pinning

   `requirements.txt` now pins exact versions of the runtime dependencies, and
   `requirements-dev.txt` pins the test dependencies. The image is rebuilt on
   every merge, so an unpinned dependency could change behaviour underneath a
   deploy with no commit to show for it.

6. Template logic

   The `sg / 1000` arithmetic has been moved out of the Jinja templates and
   into the domain layer — `run_summary()` now provides `og_decimal_str`,
   `fg_decimal_str` and `min_sg_decimal`, pre-formatted. No template does
   arithmetic on the data any more.

7. Health check depth

   `/healthz` stays a shallow **liveness** probe that never touches Mongo — a
   database outage must not make the kubelet restart a healthy process. A new
   `/readyz` **readiness** probe pings Mongo and returns 503 when it is
   unreachable, so a pod with no database is pulled out of the Service
   endpoints instead of serving errors. The RUNBOOK documents wiring both.

Low items
---------

8. Mongo aggregation pipeline for `runs()`

   Not changed. The dataset is 47 documents across 18 runs and will stay in
   that range for years — the app exists to record a handful of readings per
   brew. Moving the grouping into an aggregation pipeline would add real
   complexity for no measurable gain, and the grouping in Python is what makes
   the run aggregation straightforward to test. Noted for whenever the data
   volume makes it matter.

9. Kubernetes Secret for the credentials

   Not changed here, and deliberately so. The live Deployment carries
   `MONGO_PASS` as a plain environment value. Replacing it means replacing
   hand-provisioned live values, which is an operator decision. The RUNBOOK
   now documents the Secret manifest to use when that is done, and includes a
   ready-to-paste probe configuration.

10. Rate limiting on `/login`, Flask-Talisman, password complexity

    Not added. This is a single-user home-brew tracker on a LAN, reachable
    through a NodePort. Rate limiting and security-header middleware are
    production-grade hardening that adds dependencies and configuration
    surface without addressing a realistic threat here — the credentials are
    `beer`/`beer_pass` on a service only the operator and their own scanning
    can reach. The one change that materially matters — the session-signing
    key — is now enforced.

11. Least-privilege Mongo user

    Not changed. The app connects as `root` today, matching the existing
    deployment. Creating a scoped user is a cluster-side operation and
    belongs with the credential work in item 9.

Tests
-----

73 tests (up from 64). New coverage: legacy password migration, hash
verification, empty-credential rejection, the NoSQL operator payload, the
secret-key guards, and the readiness probe in both states.
2026-11-09 10:28:15 +10:00
hermes 24cdd452c5 feat(ui): rebuild the interface with a proper design system
Build and Push Image / Build and push image (push) Canceled after 0s
Build and Push Image / Unit tests (push) Canceled after 17m41s
The old UI was two pages of raw pandas tables with no navigation, no styling
and no way to see a run's comments. This replaces it with a small,
testable application and a beer-themed design.

What changed
------------

Restructure
- The domain logic now lives in `brewing.py` as pure functions over plain
  data, with no Flask or Mongo dependency. The gravity maths, the ABV
  formula and the run-aggregation rules are unit-testable on their own.
- `app_factory.py` replaces the module-level app, and the routes move into
  blueprints under `views/`. A request-scoped Mongo repository is created per
  request and closed at teardown.
- `mongo/repository.py` replaces `build_db.py` / `query_db.py` / `get_conn.py`.
  Reading a document no longer requires a second query to fetch the fields.

Interface
- A dark, beer-themed design system: warm amber `#be7f39` on near-black, a
  condensed display face (Anton) for headings and Inter for body text. Both
  fonts and the chart library are vendored into `static/`, so the container
  needs no outbound network at runtime — the old templates pulled Bootstrap
  from a CDN.
- A real navigation bar, a login page, a brew-process page with a proper
  form, and a run-detail page listing every reading with its comment.
- The run summary tables now show the beer name and the latest comment, which
  the old pandas tables did not.
- Native form controls (checkbox, number spinners, date icon, scrollbars)
  are restyled for the dark theme.

Correctness
- The alcohol calculation now uses the standard homebrew formula,
  `(OG - FG) x 131.25`. The old code used `(max - min) / 7.36 + 0.5`, which is
  not a recognised brewing formula and overstated every result.
- Gravity points and decimal readings now convert correctly in both
  directions (`1039` <-> `1.039`).
- A run's display name resolves to the most-used label across its readings,
  because `beer_type` is stored per-reading and drifts across a run.
- Re-submitting a reading for the same run and day updates it in place
  rather than creating a duplicate.

Security / production
- The container ran the Werkzeug debugger with `FLASK_DEBUG=1`, exposing an
  interactive console on any unhandled error. It now runs waitress with
  debugging off.
- `SECRET_KEY` comes from the environment.
- The app secret is no longer a hard-coded string in source.

Housekeeping
- Removes the bokeh/duckdb/pandas dependency chain and the static file it
  generated at import time; removes the dead `add_user.py` and `test.py`.
- Splits `requirements.txt` (runtime) from `requirements-dev.txt` (tests).
- Adds a pytest suite (64 tests) and a `test` job to the CI workflow so the
  image is only built when the tests pass.
- Adds a README describing the data model and a RUNBOOK for the cluster side.

Data compatibility
------------------
The existing `beer_db.beer_data` documents are read as they are and are not
migrated or rewritten. No collection is dropped, and no document is modified
by this change other than through the explicit "record a reading" form, which
keeps the original `(date, beer_run_id)` uniqueness rule.
2026-11-09 10:21:44 +10:00
armistace fb65ba4ee0 Merge pull request 'add in current runs table' (#3) from dev into master
Build and Push Image / Build and push image (push) Successful in 15m47s
Reviewed-on: #3
2024-09-23 13:08:12 +10:00
armistace 718c8e87b3 Merge pull request 'use repo image' (#2) from dev into master
Build and Push Image / Build and push image (push) Successful in 12m25s
Reviewed-on: #2
2024-09-16 14:42:26 +10:00
armistace 4baf08813c Merge pull request 'Merge in dev to Master' (#1) from dev into master
Build and Push Image / Build and push image (push) Failing after 12m41s
Reviewed-on: #1
2024-09-16 13:27:12 +10:00
49 changed files with 5196 additions and 749 deletions
+54 -14
View File
@@ -1,21 +1,27 @@
name: Build and Push Image name: Build and Push Image
on: [push] on:
push:
branches:
- master
jobs: jobs:
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
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
# The kubeconfig is written FIRST, because the buildx `kubernetes`
# driver needs it to create its builder pod in the cluster. This is
# the same ordering as the working workflows in resbuilder_ai and
# wedding-photos.
- name: Create Kubeconfig - name: Create Kubeconfig
run: | run: |
mkdir $HOME/.kube mkdir $HOME/.kube
echo "${{ secrets.KUBEC_CONFIG_BUILDX }}" > $HOME/.kube/config echo "${{ secrets.KUBEC_CONFIG_BUILDX_NEW_2 }}" > $HOME/.kube/config
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -32,22 +38,56 @@ jobs:
username: armistace username: armistace
password: ${{ secrets.REG_PASSWORD }} password: ${{ secrets.REG_PASSWORD }}
- name: Build Base Image
uses: docker/build-push-action@v5
with:
context: .
push: true
file: base.Dockerfile
platforms: linux/amd64,linux/arm64
tags: |
git.aridgwayweb.com/armistace/beer_base_image:latest
- name: Build and Push - name: Build and Push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
file: flask.Dockerfile file: flask.Dockerfile
platforms: linux/amd64,linux/arm64 # amd64 only. A multi-arch build runs qemu emulation, which
# crashes the buildkit pod on this NUC (exit 255, SandboxChanged)
# and the build then hangs for many minutes. The cluster is
# amd64, so arm64 images are never used.
platforms: linux/amd64
tags: | tags: |
git.aridgwayweb.com/armistace/beer-data:latest git.aridgwayweb.com/armistace/beer-data:latest
- name: Deploy
run: |
echo "Installing Kubectl"
apt-get update
apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.33/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /' | tee /etc/apt/sources.list.d/kubernetes.list
chmod 644 /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install kubectl
# Do NOT delete the namespace - that would destroy the live
# Secrets (beer-db, beer-app), which are provisioned by hand.
# Apply the namespace (idempotent) and the manifests.
kubectl apply -f kube/beer_namespace.yaml
# The image-pull credentials. Created idempotently so a deploy
# does not fail when they already exist.
kubectl create secret docker-registry beercred --docker-server=git.aridgwayweb.com --docker-username=armistace --docker-password='${{ secrets.REG_PASSWORD }}' --docker-email=armistace@aridgwayweb.com --namespace=beer --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f kube/beer-deployment.yaml
kubectl apply -f kube/beer-service.yaml
# `kubectl apply` does NOT roll the pods when only the image
# changed, because the image tag in the manifest is unchanged.
# Without the restart the deployment reports success while the
# old pods keep serving the old image.
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
- name: Verify the deployed app
run: |
# Pods Running is not proof the app works. Exercise the real
# endpoints from inside the pod.
kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/healthz'); print('healthz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
kubectl -n beer exec deploy/beer-deployment -- \
python -c "import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:5000/readyz'); print('readyz:', r.read().decode()); sys.exit(0 if r.status==200 else 1)"
+156 -7
View File
@@ -1,7 +1,156 @@
# BEER SG data storer # Brew Log
This container will store SG data for beer runs in Mongo and then use that data to calculate Alchohol Content of each beer Stores specific-gravity data for home-brewed beer runs in Mongo and turns it into
useful figures — alcohol by volume, days in the fermenter, gravity trends.
It's pretty loose and requires the Data Entry operator to know whats happening
Flask app, Mongo backend, no build step for the front end.
Luck for me... That's me!
## Running it locally
```bash
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt
export MONGO_HOST=192.168.178.229
export MONGO_USER=root
export MONGO_PASS=...
export SECRET_KEY=anything-for-local-use
cd src/flask
python -m beer_data # dev server on :5000
# or, production-style:
python -m waitress --listen=0.0.0.0:5000 --call beer_data:create_app
```
Then open <http://localhost:5000/> and sign in.
## Tests
```bash
. .venv/bin/activate
python -m pytest tests/ -q
```
The tests use `mongomock`, so they need no database and no network.
## The pages
| Page | What it is for |
|---|---|
| `/login` | Sign in |
| `/brews` | The brew process: record a reading, review every run |
| `/active` | What is in the fermenter right now — tap, gravity, projected strength |
| `/history` | How every brew has gone, with charts and the standard-vs-legacy ABV comparison |
### `/active` — the active beers page
A card per brew that is still in the fermenter (a run with no reading marked
final), showing:
- **the tap number** it is on,
- **the current gravity**, as a decimal reading with the point scale beneath,
- **the projected alcohol**, worked out from the run's original gravity,
- **how many days** it has been brewing,
- a **sparkline** of the gravity so far,
- **the latest comment** on a reading.
A run with only one reading and no recorded original gravity has no reliable
estimate, so the alcohol figure shows a dash rather than a misleading number.
Record the OG on the run (or take a second reading) and it appears.
### Run metadata
Some details cannot be derived from the readings, so they are stored per run in
the `beer_runs` collection and edited on the run detail page:
- the **canonical name** the brew is known by (overriding the drifting
per-reading name),
- the **tap number**,
- the **original gravity**,
- free-text **brew notes**.
### Populating the run metadata from the existing readings
```bash
# dry run - prints what it would do, writes nothing
python scripts/backfill_run_metadata.py
# write it
python scripts/backfill_run_metadata.py --apply
```
The script is idempotent and non-destructive: it only inserts into
`beer_runs`, never touches `beer_data`, and never overwrites a name you have
already set by hand (pass `--force` if you want it to).
## Layout
```
src/flask/
beer_data.py entry point; exposes `app` and `create_app`
app_factory.py application factory, request-scoped Mongo repository
brewing.py pure domain logic — gravity parsing, ABV, run aggregation
views/ blueprints: auth, brews, analysis
mongo/repository.py Mongo access for the beer_data and beer_runs collections
templates/ Jinja templates
static/ CSS, JS, fonts, logo (all served locally — no CDN)
static/ CSS, JS, fonts, logo (all served locally - no CDN)
tests/ pytest suite
scripts/ operational scripts (the run-metadata backfill)
```
`brewing.py` holds every piece of arithmetic and has no Flask or Mongo
dependency, so the maths is unit-testable on its own.
## Data model
### `beer_data` — one document per gravity reading
| Field | Type | Notes |
|---|---|---|
| `_id` | string | 24 random characters |
| `beer_run_id` | string | The run this reading belongs to, e.g. `"20"` |
| `beer_type` | string | Beer/cider name. **Free text, per reading** — it drifts across a run |
| `sg` | string | Specific gravity in *points*: `1039` means 1.039 |
| `date` | string | `YYYY-MM-DD` |
| `final_reading` | string | `"True"` / `"False"` — marks the last reading of a run |
| `comment` | string | Free text |
The unique key is `(date, beer_run_id)`: re-submitting a reading for the same
run and day updates it in place rather than creating a duplicate.
A run is **active** while it has no reading with `final_reading == "True"`.
### `beer_runs` — one document per run (metadata)
Added alongside the readings to hold what cannot be derived from them: the
canonical beer name, the tap number, the recorded original gravity, and brew
notes. This collection is only ever inserted into, never written over by the
backfill.
## Alcohol by volume
Standard homebrew formula:
```
ABV% = (OG - FG) × 131.25
```
With gravity stored in points, the drop is already in points, so
`ABV% = points_drop × 0.13125`.
> The previous version of this app used `(max - min) / 7.36 + 0.5`, which is not
> a recognised brewing formula and overstated every result. The old figure is
> still computed by `brewing.legacy_abv_percent()` so the history page can show
> the difference between the two.
For a run still in the fermenter the figure is an **estimate**: it uses the
run's first reading as the original gravity. A run with a single reading and no
recorded OG has no reliable estimate, and the UI shows a dash rather than a
misleading number.
## Deployment
The image is built and pushed by `.gitea/workflows/build_push.yml` on push to
`master`. See [RUNBOOK.md](RUNBOOK.md) for the cluster side.
+202
View File
@@ -0,0 +1,202 @@
# RUNBOOK — Brew Log (beer_data)
Operational notes for the `beer` namespace deployment.
## What runs where
| | |
|---|---|
| Namespace | `beer` |
| Deployment | `beer-deployment` (3 replicas) |
| Image | `git.aridgwayweb.com/armistace/beer-data:latest` |
| Service | `beer-service`, NodePort **30015** → container port 5000 |
| Database | MongoDB at `192.168.178.229`, database `beer_db` |
| Secret | `beercred` (registry pull credentials) |
Access: `http://<any-cluster-node>:30015/`
The pods are pinned to `archlinux-k3s-3` (`.161`) via `nodeSelector`.
## Environment variables
| Variable | Value | Source |
|---|---|---|
| `MONGO_HOST` | `192.168.178.229` | Deployment manifest |
| `MONGO_USER` | `root` | Deployment manifest |
| `MONGO_PASS` | *(see below)* | Deployment manifest |
| `SECRET_KEY` | *(see below)* | Deployment manifest — Flask session signing key. **Required** |
| `FLASK_ENV` | `production` | Dockerfile |
| `FLASK_DEBUG` | `0` | Dockerfile |
`SECRET_KEY` and `MONGO_PASS` must be set in the live Deployment. They are
**not** committed to this repo — read them from the running cluster:
```bash
kubectl -n beer get deploy beer-deployment -o jsonpath='{.spec.template.spec.containers[0].env}'
```
> **The app refuses to start without a real `SECRET_KEY`.** There is no default
> and no placeholder: a missing or placeholder value raises
> `ImproperlyConfigured` and the container exits. A known session-signing key
> would let anyone forge a login cookie, so a misconfigured deployment must
> fail loudly rather than run insecurely.
>
> Generate one with:
> ```bash
> python -c 'import secrets; print(secrets.token_hex(32))'
> ```
### Moving the credentials into a Kubernetes Secret
The live Deployment currently carries `MONGO_PASS` as a plain environment
value. Moving it to a Secret is the better shape and is **not** done here
because it would mean replacing the live hand-provisioned values — a change
that needs the operator's sign-off. When it is done:
```yaml
env:
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: beer-db
key: mongo_pass
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: beer-app
key: secret_key
```
## Deploying
Pushing to `master` triggers `.gitea/workflows/build_push.yml`, which runs the
test suite and then builds and pushes the image. **The workflow does not
currently deploy to the cluster** — apply the manifests yourself:
```bash
kubectl -n beer rollout restart deployment/beer-deployment
kubectl -n beer rollout status deployment/beer-deployment --timeout=180s
```
### Verify the deploy actually took
CI green and pods `Ready` do **not** prove the new code is running. Check the
running digest against the registry:
```bash
# what the pod is running
kubectl -n beer get pod -l app=beer-data \
-o jsonpath='{.items[0].status.containerStatuses[0].imageID}'
# what the registry has as :latest
curl -s -u "armistace:$REG_PASSWORD" -I \
"https://git.aridgwayweb.com/v2/armistace/beer-data/manifests/latest" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
| grep -i docker-content-digest
```
If they differ, the registry `:latest` is stale — re-run the build rather than
re-applying the manifest.
### Check the app is alive
```bash
kubectl -n beer get pods
kubectl -n beer logs deploy/beer-deployment --tail=50
curl -s http://<node>:30015/healthz # -> {"status":"ok"}
curl -s http://<node>:30015/readyz # -> {"status":"ready"}
```
`/healthz` is the **liveness** probe and never touches Mongo — it answers "is
the process up". A database outage must not make the kubelet restart a healthy
process.
`/readyz` is the **readiness** probe and pings Mongo, returning 503 when the
database is unreachable. Wire it to the Service so a pod with no database is
pulled out of the endpoints rather than serving errors:
```yaml
readinessProbe:
httpGet:
path: /readyz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 20
```
## The run metadata collection
The `beer_runs` collection holds one document per brew run with the details
that cannot be derived from the readings: the canonical name, the tap number,
the recorded original gravity and the brew notes.
It starts empty on a fresh install. Populate it from the existing readings:
```bash
# from the app's source tree, with the usual env vars set
python scripts/backfill_run_metadata.py # dry run
python scripts/backfill_run_metadata.py --apply # write it
```
The script is idempotent and only ever inserts into `beer_runs`. It does not
touch `beer_data`.
To see what is in it:
```bash
mongosh "mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
--eval 'db.beer_runs.find().pretty()'
```
## Data safety
The `beer_db.beer_data` collection holds the actual brewing records. There is
no migration step and no schema version — documents are read as they are
written, and every field is treated as optional.
To take a backup before any risky change:
```bash
mongodump --uri="mongodb://root:$MONGO_PASS@192.168.178.229:27017/beer_db" \
--out=/tmp/beer_db_backup_$(date +%Y%m%d)
```
## Common problems
### The login page loads but nothing else does
The Mongo connection failed. Check the app logs for an auth error and confirm
the credentials:
```bash
kubectl -n beer logs deploy/beer-deployment --tail=100 | grep -i mongo
```
### A run shows a negative alcohol figure
That was the old formula (`(max - min) / 7.36 + 0.5`) applied to a run whose
current gravity is above the assumed original gravity of 1012. Fixed in the
current version: the standard `(OG - FG) × 131.25` formula is used, and a run
with no known OG shows a dash instead of a figure.
### The pod will not start after a deploy
The most likely cause is a missing `SECRET_KEY` — the app exits rather than
run with a default. Check the logs:
```bash
kubectl -n beer logs deploy/beer-deployment --tail=20
# ImproperlyConfigured: SECRET_KEY is not set. ...
```
### The chart on the run page is blank
The chart library is served from `/static/js/chart.umd.js` — it is vendored in
the repo, not loaded from a CDN, so the container works with no outbound
network. Check the browser console for a 404 on that path.
+19 -4
View File
@@ -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
View File
@@ -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"]
+90
View File
@@ -0,0 +1,90 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: beer-deployment
namespace: beer
labels:
app: beer-data
spec:
replicas: 3
selector:
matchLabels:
app: beer-data
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
type: RollingUpdate
template:
metadata:
labels:
app: beer-data
spec:
securityContext:
# Pod-level: only runAsNonRoot/runAsUser/runAsGroup/fsGroup belong
# here. allowPrivilegeEscalation and capabilities are container-level.
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
containers:
- name: beer-data
image: git.aridgwayweb.com/armistace/beer-data:latest
imagePullPolicy: Always
ports:
- containerPort: 5000
protocol: TCP
env:
- name: MONGO_HOST
value: "192.168.178.229"
- name: MONGO_USER
value: "root"
# MONGO_PASS and SECRET_KEY come from the live Secret - they are
# deliberately NOT in this file. See RUNBOOK.md.
- name: MONGO_PASS
valueFrom:
secretKeyRef:
name: beer-db
key: mongo_pass
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: beer-app
key: secret_key
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 50m
memory: 64Mi
securityContext:
# Container-level: these two belong here, not on the pod.
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
nodeSelector:
kubernetes.io/hostname: archlinux-k3s-3
imagePullSecrets:
- name: beercred
terminationGracePeriodSeconds: 30
restartPolicy: Always
+17
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: beer
labels:
app: beer-data
+3
View File
@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.2.0
mongomock==4.3.0
+8 -11
View File
@@ -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
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Backfill the ``beer_runs`` metadata collection from the existing readings.
The ``beer_data`` collection stores a beer name on *each reading*, and that
name drifts across a run — run 4 was entered as "Necatron-Christmas Beer",
then "Nocturna the Christmas beer", then "Nocturna Christmas beer". The
``beer_runs`` collection holds one canonical name per run, which is what the
active-beers page and the analysis pages use.
This script populates that collection from the readings. It is **idempotent**
and **non-destructive**:
- It only ever inserts into ``beer_runs``. It never touches ``beer_data``.
- It never overwrites a name you have already set by hand. A run whose
metadata already carries a ``name`` is left alone unless ``--force`` is
given.
- Re-running it produces the same result.
Usage
-----
python scripts/backfill_run_metadata.py # dry run
python scripts/backfill_run_metadata.py --apply # write it
python scripts/backfill_run_metadata.py --apply --force # overwrite names
The connection is taken from the same environment variables the app uses:
``MONGO_HOST``, ``MONGO_USER``, ``MONGO_PASS`` (or ``MONGO_URI``).
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(
0,
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "flask"),
)
from brewing import Run # noqa: E402
from mongo.repository import BeerRepository # noqa: E402
def build_plan(repo: BeerRepository) -> list[dict]:
"""Work out what should be in ``beer_runs``, one entry per run."""
existing = repo.all_run_metadata()
plan = []
for run in repo.runs():
metadata = existing.get(run.beer_run_id, {})
canonical = metadata.get("name") or run.display_name()
og = metadata.get("og_points") or run.original_gravity()
plan.append({
"beer_run_id": run.beer_run_id,
"name": canonical,
"og_points": og,
"tap_number": metadata.get("tap_number"),
"notes": metadata.get("notes", ""),
"readings": len(run.readings),
"active": run.is_active(),
"existing_name": metadata.get("name"),
"would_change": not metadata.get("name"),
})
return plan
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply", action="store_true",
help="write the changes (default is a dry run)")
parser.add_argument("--force", action="store_true",
help="overwrite names that have already been set")
args = parser.parse_args()
repo = BeerRepository()
try:
plan = build_plan(repo)
finally:
repo.client.close()
if not plan:
print("No runs found in the readings collection — nothing to do.")
return 0
print(f"{'run':>5} {'name':<38} {'OG':>5} {'readings':>8} state")
print("-" * 78)
for entry in plan:
state = "active" if entry["active"] else "finished"
marker = " " if entry["would_change"] else "*"
print(f"{entry['beer_run_id']:>5} {entry['name'][:38]:<38} "
f"{entry['og_points'] or '-':>5} {entry['readings']:>8} {state}{marker}")
changed = [e for e in plan if e["would_change"] or args.force]
kept = [e for e in plan if not e["would_change"] and not args.force]
print()
print(f"{len(changed)} run(s) would be written, {len(kept)} kept as they are.")
if kept:
print("(* already has a name set by hand — use --force to overwrite)")
print(f"{'APPLYING' if args.apply else 'DRY RUN — nothing written'}")
print()
if not args.apply:
print("Re-run with --apply to write these.")
return 0
repo = BeerRepository()
try:
for entry in changed:
repo.upsert_run_metadata_partial(
entry["beer_run_id"],
name=entry["name"],
og_points=entry["og_points"],
tap_number=entry["tap_number"],
notes=entry["notes"] or None,
)
print(f" wrote run {entry['beer_run_id']}: {entry['name']}")
finally:
repo.client.close()
print(f"\nDone — {len(changed)} run(s) written to the beer_runs collection.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-29
View File
@@ -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()
+200
View File
@@ -0,0 +1,200 @@
"""Application factory for the beer_data web app.
The application is deliberately small: a handful of pages, one Mongo database,
no build step for the front end. Everything server-side lives in blueprints
under ``src/flask/views/`` and the domain maths in ``src/flask/brewing.py``.
"""
from __future__ import annotations
import os
from pathlib import Path
from flask import (Flask, g, redirect, request, session, url_for)
from flask_wtf import CSRFProtect
__all__ = ["create_app"]
#: The Content-Security-Policy the app sends with every response.
#:
#: The app serves only its own vendored assets — CSS, JavaScript, fonts and
#: the logo are all in ``static/`` — so a strict ``'self'`` policy is
#: enforceable. There is no ``'unsafe-inline'`` anywhere, which is why the
#: templates carry no inline scripts or style attributes.
CSP = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"form-action 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"base-uri 'self'"
)
class ImproperlyConfigured(RuntimeError):
"""Raised when the app is missing configuration it cannot safely default.
Werkzeug 3 dropped its own ``ImproperlyConfigured``, and Flask has never
exported one, so the app defines its own. It is a ``RuntimeError`` so it
is not mistaken for an HTTP error the app should try to render a page for.
"""
def _require_secret_key() -> str:
"""Return the Flask secret key, refusing to start without a real one.
A known default secret key means anyone can forge a session cookie and
log in as the operator, so a missing or placeholder value must stop the
app rather than silently fall back. The one exception is the test
suite, which supplies its own key through the config.
"""
secret = os.getenv("SECRET_KEY", "").strip()
if not secret:
raise ImproperlyConfigured(
"SECRET_KEY is not set. Export a random value, e.g. "
"`export SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')`."
)
if secret.lower() in {"change-me-in-the-deployment", "dev-secret-change-me"}:
raise ImproperlyConfigured(
"SECRET_KEY is still the placeholder value. Set a real secret key."
)
return secret
def create_app(config: dict | None = None) -> Flask:
"""Build and configure the Flask application."""
app = Flask(
__name__,
template_folder="templates",
static_folder="static",
)
app.config.update(
SECRET_KEY=_require_secret_key(),
MONGO_HOST=os.getenv("MONGO_HOST", "127.0.0.1"),
MONGO_USER=os.getenv("MONGO_USER", "root"),
MONGO_PASS=os.getenv("MONGO_PASS", ""),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
# Flask-WTF needs this for the login form to render and validate.
WTF_CSRF_ENABLED=True,
)
if config:
app.config.update(config)
csrf = CSRFProtect(app)
# ------------------------------------------------------------------ #
# Request-scoped repository
# ------------------------------------------------------------------ #
from mongo.repository import BeerRepository
def get_repo() -> BeerRepository:
"""One Mongo client per request, closed when the request ends."""
if "repo" not in g:
# A client can be injected via config (used by the test suite);
# otherwise build one from the environment.
injected = app.config.get("MONGO_CLIENT")
g.repo = BeerRepository(client=injected) if injected else BeerRepository()
return g.repo
app.extensions["beer_repo"] = get_repo
@app.teardown_appcontext
def _close_repo(_exc=None):
repo = g.pop("repo", None)
# An injected client (the test suite) is owned by the caller — do not
# close it, or the next request in the same test gets a dead client.
if repo is not None and app.config.get("MONGO_CLIENT") is None:
repo.client.close()
# ------------------------------------------------------------------ #
# Auth helpers
# ------------------------------------------------------------------ #
def logged_in() -> bool:
return bool(session.get("logged_in"))
@app.context_processor
def inject_globals():
return {
"logged_in": logged_in(),
"current_path": request.path,
"app_name": "Brew Log",
}
# ------------------------------------------------------------------ #
# Error handlers
# ------------------------------------------------------------------ #
@app.after_request
def _set_security_headers(response):
"""Add the standard security headers.
The app serves its own vendored JavaScript and CSS and loads nothing
from a CDN, so a strict Content-Security-Policy of 'self' is enough
and is genuinely enforceable.
"""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("Content-Security-Policy", CSP)
return response
@app.errorhandler(404)
def _not_found(_exc):
return render_template("error.html", code=404,
message="That page does not exist."), 404
@app.errorhandler(500)
def _server_error(_exc): # pragma: no cover - defensive
return render_template("error.html", code=500,
message="Something broke on our side."), 500
# ------------------------------------------------------------------ #
# Blueprints
# ------------------------------------------------------------------ #
from views.analysis import bp as analysis_bp
from views.auth import bp as auth_bp
from views.brews import bp as brews_bp
app.register_blueprint(auth_bp)
app.register_blueprint(brews_bp)
app.register_blueprint(analysis_bp)
@app.route("/healthz")
def healthz():
"""Liveness probe. Never touches Mongo — it answers "is the process
up", not "is the app working". A database outage must not cause the
kubelet to restart a perfectly healthy process."""
return {"status": "ok"}, 200
@app.route("/readyz")
def readyz():
"""Readiness probe. Checks that Mongo is actually reachable, so a
pod with no database is taken out of the Service's endpoints instead
of serving errors."""
try:
repo = get_repo()
repo.client.admin.command("ping")
except Exception as exc: # noqa: BLE001 - any failure means not ready
return {"status": "not ready", "error": str(exc)}, 503
return {"status": "ready"}, 200
@app.route("/")
def index():
if logged_in():
return redirect(url_for("brews.index"))
return redirect(url_for("auth.login"))
return app
def render_template(*args, **kwargs): # noqa: D401 - re-export for views
"""Small shim so view modules can import ``render_template`` from one place."""
from flask import render_template as _rt
return _rt(*args, **kwargs)
+28 -135
View File
@@ -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')
+481
View File
@@ -0,0 +1,481 @@
"""Pure brewing logic for the beer_data app.
Everything in this module is a pure function over plain data (no Flask, no Mongo,
no pandas) so the maths can be unit-tested without a database.
Gravity convention
------------------
The Mongo collection stores specific gravity as integer *points*: the digits after
the decimal point of the usual 1.0xx reading. ``1039`` means 1.039. This matches
how the readings were entered and keeps the stored data unchanged.
Alcohol by volume
-----------------
Standard homebrew formula (Charlie Papazian / "The Joy of Homebrewing"):
ABV% = (OG - FG) * 131.25
With gravities expressed in points the difference is directly in points, so:
ABV% = points_drop * 0.13125
The legacy application used ``(max - min) / 7.36 + 0.5`` which is not a recognised
formula and overstates the result (it also returns 0.5% for a run with no drop at
all). :func:`legacy_abv_percent` keeps the old maths available so the history page
can show the difference between the two, but new code uses :func:`abv_percent`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Iterable, Sequence
__all__ = [
"ABV_FACTOR",
"ASSUMED_ORIGINAL_GRAVITY",
"Reading",
"Run",
"abv_percent",
"assumed_og_abv_percent",
"days_between",
"final_reading",
"first_reading",
"gravity_drop_points",
"is_active",
"is_plausible_sg",
"last_reading",
"legacy_abv_percent",
"parse_date",
"parse_sg_points",
"run_summary",
"sg_points_to_decimal",
]
#: Conversion factor for points-drop -> percent alcohol (131.25 / 1000).
ABV_FACTOR = 0.13125
#: Original gravity assumed for an in-progress run when no OG was recorded.
#: The legacy app hard-coded 1012; kept as the default so existing numbers line up.
ASSUMED_ORIGINAL_GRAVITY = 1012
#: Plausible range for a hydrometer reading, in points. The upper bound is
#: generous enough for a strong specialty brew (a 1.150 wort is real) while
#: still catching typos such as 900 or 1500.
_MIN_PLAUSIBLE_SG = 990
_MAX_PLAUSIBLE_SG = 1200
# --------------------------------------------------------------------------- #
# Parsing helpers
# --------------------------------------------------------------------------- #
def parse_sg_points(raw: object) -> int | None:
"""Return the gravity in points for *raw*, or ``None`` if it is not usable.
Accepts ints, floats and strings. ``"1039"``, ``1039`` and ``1039.0`` all
become ``1039``. Values like ``"1.039"`` are also accepted and converted, and
anything unparseable yields ``None`` rather than raising.
"""
if raw is None:
return None
if isinstance(raw, bool): # bool is an int subclass; never a gravity
return None
if isinstance(raw, (int, float)):
value = float(raw)
else:
text = str(raw).strip()
if not text:
return None
try:
value = float(text)
except ValueError:
return None
if value > 100: # already stored as points (1039)
return int(round(value))
if value >= 0.9: # a decimal reading such as 1.039
return int(round(value * 1000))
return None
def sg_points_to_decimal(points: int | None) -> float | None:
"""Convert points (``1039``) to the conventional decimal reading (``1.039``)."""
if points is None:
return None
return round(points / 1000.0, 4)
def parse_date(raw: object) -> date | None:
"""Parse a ``YYYY-MM-DD`` date, tolerating the odd alternative layout."""
if raw is None:
return None
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
text = str(raw).strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _as_bool(value: object) -> bool:
"""Interpret the stored ``final_reading`` flag.
The collection stores it as the *string* ``"True"``/``"False"`` for historic
rows and as a real boolean for newer ones, so both are accepted.
"""
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in {"true", "1", "yes", "y", "t"}
# --------------------------------------------------------------------------- #
# Domain objects
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Reading:
"""A single gravity reading taken on a given day."""
date: date | None
sg_points: int | None
final_reading: bool = False
comment: str = ""
beer_type: str = ""
_id: str = ""
@property
def sg_decimal(self) -> float | None:
return sg_points_to_decimal(self.sg_points)
@property
def is_plausible(self) -> bool:
return is_plausible_sg(self.sg_points)
@classmethod
def from_doc(cls, doc: dict) -> "Reading":
"""Build a :class:`Reading` from a raw Mongo document."""
return cls(
date=parse_date(doc.get("date")),
sg_points=parse_sg_points(doc.get("sg")),
final_reading=_as_bool(doc.get("final_reading")),
comment=(doc.get("comment") or "").strip(),
beer_type=(doc.get("beer_type") or "").strip(),
_id=str(doc.get("_id") or ""),
)
@dataclass
class Run:
"""All readings that belong to one brew run."""
beer_run_id: str
readings: list[Reading] = field(default_factory=list)
@classmethod
def from_docs(cls, beer_run_id: str, docs: Iterable[dict]) -> "Run":
readings = [Reading.from_doc(d) for d in docs]
readings.sort(key=lambda r: (r.date or date.min, r.sg_points or 0))
return cls(beer_run_id=str(beer_run_id), readings=readings)
# -- aggregate accessors ------------------------------------------------ #
def sg_values(self) -> list[int]:
"""Gravity points of every *usable* reading, in chronological order."""
return [r.sg_points for r in self.readings if r.sg_points is not None]
def original_gravity(self) -> int | None:
"""OG = the first usable reading of the run."""
values = self.sg_values()
return values[0] if values else None
def final_gravity(self) -> int | None:
"""FG = the last usable reading of the run (if the run is finished)."""
values = self.sg_values()
return values[-1] if values else None
def max_sg(self) -> int | None:
values = self.sg_values()
return max(values) if values else None
def min_sg(self) -> int | None:
values = self.sg_values()
return min(values) if values else None
def is_active(self) -> bool:
return is_active(self.readings)
def final_reading(self) -> Reading | None:
return final_reading(self.readings)
def first_reading(self) -> Reading | None:
return first_reading(self.readings)
def last_reading(self) -> Reading | None:
return last_reading(self.readings)
def gravity_drop_points(self) -> int | None:
og, fg = self.original_gravity(), self.final_gravity()
if og is None or fg is None:
return None
return og - fg
def abv_percent(self) -> float | None:
"""Standard ABV for a **finished** run.
A run that is still in the fermenter has no measured alcohol - its
figure is a projection, not a measurement. Returning the drop here
for an unfinished run would report a "measured" ABV for a beer that
has not finished fermenting, which is wrong.
"""
if self.is_active():
return None
drop = self.gravity_drop_points()
return abv_percent(drop) if drop is not None else None
def projected_abv_percent(self, assumed_og: int = ASSUMED_ORIGINAL_GRAVITY,
og_override: int | None = None) -> float | None:
# `assumed_og` is retained for API compatibility but is deliberately
# NOT used as a fallback — see the docstring.
"""ABV estimate for an *unfinished* run, or ``None`` if unknowable.
The original gravity is taken from, in order of preference:
1. ``og_override`` — the OG recorded against the run by the user.
2. The run's first reading, when there are two or more readings.
The first reading of a run IS its original gravity — that is what
a reading taken on brew day is for.
With a single reading and no recorded OG there is NO reliable
estimate: the reading may have been taken on brew day or mid-
fermentation. The legacy app assumed 1012 anyway, which produced
*negative* alcohol whenever the reading was above 1012 — a perfectly
normal OG. Rather than show a misleading figure, ``None`` is
returned and the UI shows a dash until an OG is known. Record the OG
against the run and the estimate appears.
"""
values = self.sg_values()
if not values:
return None
if og_override is not None:
og = og_override
elif len(values) >= 2:
og = values[0]
else:
# A single reading and no recorded OG: there is no reliable
# original gravity to measure against, so there is no estimate.
return None
fg = self.min_sg()
if fg is None:
return None
drop = og - fg
if drop <= 0:
# Gravity has risen during fermentation — an anomaly, not alcohol.
return None
return abv_percent(drop)
def days_in_fermenter(self, today: date | None = None) -> int | None:
first = self.first_reading()
if first is None or first.date is None:
return None
final = self.final_reading()
end = final.date if (final is not None and final.date is not None) else (today or date.today())
if end is None:
return None
return days_between(first.date, end)
def display_name(self) -> str:
"""Best available human label for the run.
The stored ``beer_type`` drifts between readings of the same run, so
the name is resolved by preferring, in order:
1. The name on the **final** reading. That is the one the operator
wrote when the brew was finished, so it is the name they settled
on. Run 4 resolves to "Nocturna Christmas beer" rather than
"Necatron-Christmas Beer" from the first reading.
2. The name used on the most readings, breaking ties towards the most
recent. This catches runs whose name changed exactly once.
3. The name on the most recent reading, if it appears only once.
A run whose readings carry no name at all falls back to "Run N".
"""
named = [r for r in self.readings if r.beer_type]
if not named:
return f"Run {self.beer_run_id}"
final = self.final_reading()
if final is not None and final.beer_type:
return final.beer_type
counts: dict[str, int] = {}
for reading in named:
counts[reading.beer_type] = counts.get(reading.beer_type, 0) + 1
return max(counts, key=lambda label: (counts[label], self._last_index_of(label)))
def _last_index_of(self, label: str) -> int:
for index in range(len(self.readings) - 1, -1, -1):
if self.readings[index].beer_type == label:
return index
return -1
def comments(self) -> list[str]:
"""Every non-empty comment, oldest first."""
return [r.comment for r in self.readings if r.comment]
def outlier_readings(self) -> list[Reading]:
"""Readings whose gravity is outside the plausible hydrometer range."""
return [r for r in self.readings if r.sg_points is not None and not r.is_plausible]
# --------------------------------------------------------------------------- #
# Module level helpers (usable without a Run instance)
# --------------------------------------------------------------------------- #
def abv_percent(points_drop: int | float | None) -> float | None:
"""Standard ABW/ABV from a gravity drop expressed in points."""
if points_drop is None:
return None
return round(float(points_drop) * ABV_FACTOR, 2)
def legacy_abv_percent(max_points: int | float | None,
min_points: int | float | None) -> float | None:
"""The legacy app's formula, kept for the legacy-vs-standard comparison.
The old code computed ``((max - min) / 7.36) + 0.5``. It is not a recognised
brewing formula and returns 0.5% even when nothing has fermented.
"""
if max_points is None or min_points is None:
return None
return round(((float(max_points) - float(min_points)) / 7.36) + 0.5, 2)
def assumed_og_abv_percent(current_points: int | None,
assumed_og: int = ASSUMED_ORIGINAL_GRAVITY) -> float | None:
"""ABV for an in-progress run measured against an assumed original gravity."""
if current_points is None:
return None
return abv_percent(assumed_og - current_points)
def is_plausible_sg(points: int | None) -> bool:
"""True when a gravity reading sits inside a believable hydrometer range."""
if points is None:
return False
return _MIN_PLAUSIBLE_SG <= points <= _MAX_PLAUSIBLE_SG
def is_active(readings: Sequence[Reading]) -> bool:
"""A run is active while it has no reading marked as the final one."""
return not any(r.final_reading for r in readings)
def final_reading(readings: Sequence[Reading]) -> Reading | None:
"""The reading flagged as final, or ``None``."""
for reading in readings:
if reading.final_reading:
return reading
return None
def first_reading(readings: Sequence[Reading]) -> Reading | None:
"""The chronologically first reading."""
dated = [r for r in readings if r.date is not None]
if dated:
return min(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
return readings[0] if readings else None
def last_reading(readings: Sequence[Reading]) -> Reading | None:
"""The chronologically last reading."""
dated = [r for r in readings if r.date is not None]
if dated:
return max(dated, key=lambda r: r.date) # type: ignore[arg-type,return-value]
return readings[-1] if readings else None
def days_between(start: date, end: date) -> int:
return (end - start).days
def _fmt_decimal(points: int | None) -> str:
"""Format the stored gravity as a decimal reading, e.g. ``1039`` -> ``1.039``."""
if points is None:
return ""
return f"{points / 1000:.3f}"
def _fmt_point_scale(points: int | None) -> str:
"""Format the gravity in brewing "points".
A reading of 1.039 is 39 points in the homebrew sense - the digits after
the decimal point. The stored value ``1039`` is the full reading, not a
point count, so it must not be labelled "points".
"""
if points is None:
return ""
return f"{points % 1000}"
def run_summary(run: Run, today: date | None = None,
og_override: int | None = None) -> dict:
"""A flat dict describing a run, ready for a template or a JSON response."""
final = run.final_reading()
first = run.first_reading()
og = run.original_gravity()
fg = run.final_gravity()
return {
"beer_run_id": run.beer_run_id,
"name": run.display_name(),
"active": run.is_active(),
"og_points": og,
"fg_points": fg,
"og_decimal": sg_points_to_decimal(og),
"fg_decimal": sg_points_to_decimal(fg),
# Pre-formatted for the templates so no arithmetic happens in Jinja.
"og_decimal_str": _fmt_decimal(og),
"fg_decimal_str": _fmt_decimal(fg),
"og_point_scale": _fmt_point_scale(og),
"fg_point_scale": _fmt_point_scale(fg),
"max_sg": run.max_sg(),
"min_sg": run.min_sg(),
"min_sg_decimal": _fmt_decimal(run.min_sg()),
"min_sg_point_scale": _fmt_point_scale(run.min_sg()),
"drop_points": run.gravity_drop_points(),
"abv": run.abv_percent(),
"projected_abv": run.projected_abv_percent(og_override=og_override),
"legacy_abv": (legacy_abv_percent(run.max_sg(), run.min_sg())
if not run.is_active() else None),
"reading_count": len(run.readings),
"first_date": first.date.isoformat() if (first and first.date) else None,
"final_date": final.date.isoformat() if (final and final.date) else None,
"days_in_fermenter": run.days_in_fermenter(today),
"comments": run.comments(),
"last_comment": run.comments()[-1] if run.comments() else "",
"outliers": [r.sg_points for r in run.outlier_readings()],
"readings": [
{
"date": r.date.isoformat() if r.date else None,
"sg": r.sg_points,
"sg_decimal": r.sg_decimal,
"final_reading": r.final_reading,
"comment": r.comment,
"beer_type": r.beer_type,
}
for r in run.readings
],
}
-52
View File
@@ -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
View File
-76
View File
@@ -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
-19
View File
@@ -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)
-55
View File
@@ -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
+363
View File
@@ -0,0 +1,363 @@
"""Mongo access layer for the beer_data app.
Two collections are used:
``beer_data``
The historic readings. One document per gravity reading. **Never modified or
removed by the code in this module** apart from the explicit "add or update a
reading" path used by the brew-process form.
``beer_runs``
New in this version. One document per brew run holding the *canonical*
metadata that could not be derived from the readings themselves: the beer
name, the tap number it is on, the original gravity, and whether the run is
still in the fermenter. The backfill script only ever inserts into this
collection, so it is safe to run repeatedly.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from pymongo import MongoClient
from pymongo.errors import PyMongoError
from werkzeug.security import check_password_hash, generate_password_hash
from brewing import Run
__all__ = ["BeerRepository", "get_repository"]
logger = logging.getLogger(__name__)
class BeerRepository:
"""Thin, testable wrapper around the two Mongo collections."""
def __init__(self, client: MongoClient | None = None,
db_name: str = "beer_db") -> None:
self.client = client or _build_client()
self.db = self.client[db_name]
self.readings = self.db["beer_data"]
self.users = self.db["users"]
# NB: named `run_meta`, not `runs` — an attribute called `runs` would
# shadow the runs() method on the instance.
self.run_meta = self.db["beer_runs"]
self._ensure_indexes()
def _ensure_indexes(self) -> None:
"""Make sure the lookup keys are indexed.
Every read of the run metadata filters on ``beer_run_id``, and the
readings are always fetched by ``(beer_run_id, date)``. Without the
indexes those are collection scans.
"""
try:
self.run_meta.create_index("beer_run_id", name="beer_run_id_1")
self.readings.create_index(
[("beer_run_id", 1), ("date", 1)], name="run_date_1"
)
except PyMongoError:
# An index failure must not stop the app from serving, but it
# should be visible: without the index the lookup is a collection
# scan, which gets slow as the data grows.
logger.warning(
"Could not create the Mongo indexes; the lookups will be "
"collection scans until they exist",
exc_info=True,
)
# -- users -------------------------------------------------------------- #
def user_check(self, username: str, password: str) -> bool:
"""Check the login credentials.
Passwords are stored as a hash produced by
``werkzeug.security.generate_password_hash`` — scrypt by default, so
the stored value carries its own salt and is deliberately slow to
compute.
Legacy rows stored the password in plain text. Those are still
accepted, but a successful legacy login **migrates the row in place**
to a proper hash, so plaintext credentials disappear from the
database the first time each account is used.
"""
if not username or not password:
return False
doc = self.users.find_one({"username": str(username)})
if not doc:
return False
stored = str(doc.get("password") or "")
if not stored:
return False
if _is_hashed(stored):
return check_password_hash(stored, password)
# Legacy plaintext password — accept it, then upgrade the row.
if stored == password:
self._upgrade_password(doc, password)
return True
return False
def _upgrade_password(self, doc: dict, password: str) -> None:
"""Replace a plaintext password with a proper hash."""
try:
self.users.update_one(
{"_id": doc["_id"]},
{"$set": {
"password": generate_password_hash(password),
"password_migrated_at": _now_iso(),
}},
)
except PyMongoError:
# A failed upgrade must not stop the login succeeding — but the
# operator needs to know the password is still stored in plain
# text, so log it rather than swallowing the error silently.
logger.warning(
"Could not upgrade the password for user %r to a hashed value; "
"it is still stored in plain text",
doc.get("username"),
exc_info=True,
)
# -- readings ----------------------------------------------------------- #
def all_reading_docs(self) -> list[dict]:
"""Every reading document, oldest first."""
return list(self.readings.find().sort([("date", 1), ("beer_run_id", 1)]))
def reading_docs_for_run(self, beer_run_id: str) -> list[dict]:
return list(
self.readings.find({"beer_run_id": _as_id(beer_run_id)}).sort("date", 1)
)
def runs(self) -> list[Run]:
"""Every run, built from the reading documents, oldest run first."""
grouped: dict[str, list[dict]] = {}
for doc in self.all_reading_docs():
grouped.setdefault(str(doc.get("beer_run_id")), []).append(doc)
def run_sort_key(run: Run) -> tuple:
first = run.first_reading()
return (first.date if (first and first.date) else date_min(), run.beer_run_id)
runs = [Run.from_docs(run_id, docs) for run_id, docs in grouped.items()]
runs.sort(key=run_sort_key)
return runs
def run(self, beer_run_id: str) -> Run | None:
docs = self.reading_docs_for_run(beer_run_id)
if not docs:
return None
return Run.from_docs(str(beer_run_id), docs)
def active_runs(self) -> list[Run]:
"""Runs with no final reading yet."""
return [r for r in self.runs() if r.is_active()]
def completed_runs(self) -> list[Run]:
return [r for r in self.runs() if not r.is_active()]
def upsert_reading(self, *, beer_run_id: str, beer_type: str, sg: int,
reading_date: str, final_reading: bool, comment: str) -> str:
"""Add a reading, or update the matching one if it already exists.
The unique key is ``(date, beer_run_id)`` which is what the original
application used, so re-submitting the same day updates in place
instead of creating a duplicate. Returns the ``_id`` of the document.
"""
query = {"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)}
update = {
"$set": {
"beer_run_id": str(beer_run_id),
"beer_type": beer_type,
"sg": str(sg),
"date": _as_date(reading_date),
"final_reading": "True" if final_reading else "False",
"comment": str(comment),
}
}
result = self.readings.update_one(query, update, upsert=True)
if result.upserted_id is not None:
return str(result.upserted_id)
existing = self.readings.find_one(query)
return str(existing.get("_id")) if existing else ""
def set_final_reading(self, beer_run_id: str, reading_date: str,
final: bool) -> int:
"""Flag (or unflag) a reading as the final reading of its run."""
result = self.readings.update_many(
{"date": _as_date(reading_date), "beer_run_id": _as_id(beer_run_id)},
{"$set": {"final_reading": "True" if final else "False"}},
)
return result.modified_count
# -- run metadata ------------------------------------------------------- #
def run_metadata(self, beer_run_id: str) -> dict:
"""The stored metadata for a run, or an empty dict."""
doc = self.run_meta.find_one({"beer_run_id": _as_id(beer_run_id)})
if not doc:
return {}
doc.pop("_id", None)
return doc
def all_run_metadata(self) -> dict[str, dict]:
"""All stored run metadata, keyed by ``beer_run_id``."""
result: dict[str, dict] = {}
for doc in self.run_meta.find():
doc.pop("_id", None)
result[_as_id(doc.get("beer_run_id"))] = doc
return result
def upsert_run_metadata(self, beer_run_id: str, **fields: Any) -> dict:
"""Create or update the metadata document for a run.
Only the keys passed in are written; anything already stored is left
alone. A field passed as ``None`` is **cleared** — that is how the
form removes a name or a tap number the operator has deleted.
Use ``upsert_run_metadata_partial`` to update some fields while
leaving the rest untouched.
"""
payload = dict(fields)
payload["updated_at"] = _now_iso()
self.run_meta.update_one(
{"beer_run_id": _as_id(beer_run_id)},
{"$set": payload, "$setOnInsert": {"created_at": _now_iso()}},
upsert=True,
)
return self.run_metadata(beer_run_id)
def upsert_run_metadata_partial(self, beer_run_id: str, **fields: Any) -> dict:
"""Update only the given fields, leaving every other field alone.
Fields passed as ``None`` are ignored rather than cleared.
"""
payload = {k: v for k, v in fields.items() if v is not None}
if not payload:
return self.run_metadata(beer_run_id)
payload["updated_at"] = _now_iso()
self.run_meta.update_one(
{"beer_run_id": _as_id(beer_run_id)},
{"$set": payload},
upsert=True,
)
return self.run_metadata(beer_run_id)
def next_free_tap_number(self) -> int:
"""The lowest tap number not already in use.
Taps are numbered from 1. A run with no tap number recorded does not
occupy one.
"""
used = {
meta.get("tap_number")
for meta in self.all_run_metadata().values()
if meta.get("tap_number") is not None
}
candidate = 1
while candidate in used:
candidate += 1
return candidate
def delete_run_metadata(self, beer_run_id: str) -> bool:
"""Remove the metadata document for a run (readings are untouched)."""
return self.run_meta.delete_one({"beer_run_id": _as_id(beer_run_id)}).deleted_count > 0
# -- helpers ------------------------------------------------------------ #
def next_run_id(self) -> str:
"""The next free ``beer_run_id``.
Run ids are plain integers in the existing data, so the next id is one
past the highest numeric id currently in use.
"""
highest = 0
for run in self.runs():
try:
highest = max(highest, int(run.beer_run_id))
except (TypeError, ValueError):
continue
return str(highest + 1)
# --------------------------------------------------------------------------- #
# Connection helpers
# --------------------------------------------------------------------------- #
def _build_client() -> MongoClient:
user = os.getenv("MONGO_USER", "root")
password = os.getenv("MONGO_PASS", "")
host = os.getenv("MONGO_HOST", "127.0.0.1")
uri = os.getenv("MONGO_URI") or f"mongodb://{user}:{password}@{host}/beer_db?authSource=admin"
return MongoClient(uri, serverSelectionTimeoutMS=5000)
def _as_id(value: object) -> str:
"""Coerce a run id to a plain string.
Every value that reaches a Mongo query is coerced first. Without this, a
caller passing a dict (e.g. ``{"$ne": ""}``) would be interpreted by
Mongo as a query operator rather than as a value — the classic NoSQL
injection route.
"""
if isinstance(value, str):
return value
if isinstance(value, (int, float)):
return str(value)
return ""
def _as_date(value: object) -> str:
"""Coerce a reading date to a plain ``YYYY-MM-DD`` string."""
if isinstance(value, str):
return value
return ""
def _is_hashed(stored: str) -> bool:
"""True when *stored* looks like a werkzeug password hash.
Werkzeug hashes are of the form ``method$salt$hash``, e.g.
``scrypt:32768:8:1$abcdef...$0123...``. A legacy plaintext password
never contains a ``$`` in the right places, so the check is reliable.
"""
return "$" in stored and len(stored.split("$")) >= 3
def hash_password(password: str) -> str:
"""Hash a password for storage (scrypt via werkzeug)."""
return generate_password_hash(password)
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def date_min():
from datetime import date
return date.min
def get_repository() -> BeerRepository:
"""Build a repository against the configured Mongo."""
return BeerRepository()
def is_alive(repo: BeerRepository) -> bool:
"""Cheap health check used by the status page."""
try:
repo.client.admin.command("ping")
return True
except PyMongoError:
return False
-63
View File
@@ -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)
+684
View File
@@ -0,0 +1,684 @@
/* ==========================================================================
Brew Log — design system
Beer-house palette: warm amber/copper on near-black, with a condensed
display face (Anton) for headings and Inter for everything else.
Palette taken from the brewery-128 template:
--primary #be7f39 (amber / copper)
--primary-dark #0e0e0e
--black-primary #212024
--black-secondary #161616
--gray #e9e9e9
--dark-gray #afafaf
--paragraph-gray #535353
========================================================================== */
/* ------------------------------------------------------------------ fonts */
@font-face {
font-family: 'Anton';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/anton-latin.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../fonts/inter-latin-400.woff2') format('woff2');
}
/* ----------------------------------------------------------------- tokens */
:root {
/* surfaces */
--bg: #121110;
--bg-raised: #1c1a19;
--bg-raised-2: #242120;
--bg-input: #161514;
/* lines */
--border: #332e2a;
--border-strong: #4a4239;
/* text */
--text: #f2ede8;
--text-muted: #afafaf;
--text-faint: #7d746c;
/* brand */
--amber: #be7f39;
--amber-bright: #dda05c;
--amber-deep: #8f5f28;
--amber-dim: rgba(190, 127, 57, 0.14);
--amber-text: #e2b47c;
/* foam / highlights */
--foam: #f7f3ee;
--cream: #eef4f8;
/* status */
--warn: #e8a33d;
--warn-dim: rgba(232, 163, 61, 0.14);
--danger: #d9534f;
--danger-dim: rgba(217, 83, 79, 0.14);
--success: #7db902;
--success-dim: rgba(125, 185, 2, 0.14);
--radius: 10px;
--radius-sm: 6px;
--radius-lg: 16px;
--shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 10px 30px rgba(0, 0, 0, .35);
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--display: 'Anton', 'Arial Narrow', Impact, sans-serif;
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--amber-text); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4 { margin: 0 0 .5rem; line-height: 1.15; font-weight: 650; }
h1 { font-size: 1.65rem; }
h2 { font-size: 1.25rem; }
h3 { font-size: 1.02rem; }
p { margin: 0 0 .75rem; }
/* Display type: the condensed face, used sparingly for headings and figures. */
.display {
font-family: var(--display);
font-weight: 400;
letter-spacing: .01em;
text-transform: uppercase;
}
/* ---------------------------------------------------------------- topbar */
.topbar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(18, 17, 16, .94);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.topbar__inner {
max-width: 1240px;
margin: 0 auto;
padding: .6rem 1.25rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: .55rem;
color: var(--text);
text-decoration: none;
}
.brand:hover { text-decoration: none; }
.brand__mark {
font-size: 1.4rem;
line-height: 1;
filter: drop-shadow(0 0 10px rgba(190, 127, 57, .35));
}
.brand__text {
font-family: var(--display);
font-size: 1.22rem;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--amber-bright);
}
.nav { display: flex; gap: .25rem; flex: 1; }
.nav__link {
padding: .4rem .8rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: .92rem;
font-weight: 500;
text-decoration: none;
transition: background .12s ease, color .12s ease;
}
.nav__link:hover { background: var(--bg-raised-2); color: var(--text); text-decoration: none; }
.nav__link.is-active {
background: var(--amber-dim);
color: var(--amber-text);
box-shadow: inset 0 -2px 0 var(--amber);
}
.topbar__logout { display: flex; align-items: center; gap: .6rem; }
.topbar__user {
color: var(--text-muted);
font-size: .85rem;
font-family: var(--mono);
padding: .15rem .5rem;
border: 1px solid var(--border);
border-radius: 999px;
}
/* ------------------------------------------------------------------ page */
.page {
max-width: 1240px;
margin: 0 auto;
padding: 1.5rem 1.25rem 4rem;
}
.page--narrow { max-width: 460px; }
.page__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.25rem;
}
.page__title {
margin: 0;
font-family: var(--display);
font-weight: 400;
font-size: 1.85rem;
letter-spacing: .02em;
text-transform: uppercase;
color: var(--text);
}
.page__sub { color: var(--text-muted); font-size: .9rem; margin: .15rem 0 0; }
/* ----------------------------------------------------------------- cards */
.card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
position: relative;
}
/* A thin amber seam along the top of each card — the "label on the barrel" touch. */
.card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 2px;
border-radius: var(--radius) var(--radius) 0 0;
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 40%, transparent 85%);
opacity: .8;
}
.card__head {
padding: .9rem 1.1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.card__title {
margin: 0;
font-size: 1rem;
font-weight: 650;
letter-spacing: .01em;
}
.card__hint { color: var(--text-faint); font-size: .82rem; }
.card__body { padding: 1.1rem; }
.card__body--flush { padding: 0; }
.grid { display: grid; gap: 1rem; }
.grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* The active-beer cards: fill the row whatever the number of brews. */
.grid--cards { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid--sidebar { grid-template-columns: minmax(0, 380px) minmax(0, 1fr); align-items: start; }
@media (max-width: 1000px) {
.grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid--sidebar { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 640px) {
.grid--2, .grid--3, .grid--4 { grid-template-columns: minmax(0, 1fr); }
.topbar__inner { flex-wrap: wrap; gap: .75rem; }
.nav { order: 3; width: 100%; overflow-x: auto; }
.page__title { font-size: 1.5rem; }
}
/* ----------------------------------------------------------------- stats */
.stat {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: .85rem 1rem;
}
.stat__label {
color: var(--text-faint);
font-size: .72rem;
text-transform: uppercase;
letter-spacing: .09em;
font-weight: 600;
}
.stat__value {
font-family: var(--display);
font-weight: 400;
font-size: 1.75rem;
letter-spacing: .02em;
line-height: 1.2;
color: var(--amber-bright);
}
.stat__meta { color: var(--text-muted); font-size: .83rem; }
/* ----------------------------------------------------------------- forms */
.field { margin-bottom: .9rem; }
.field__label {
display: block;
font-size: .74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .09em;
color: var(--text-muted);
margin-bottom: .3rem;
}
.field__hint { color: var(--text-muted); font-size: .8rem; margin-top: .25rem; }
.field__error { color: var(--danger); font-size: .8rem; margin-top: .25rem; }
input[type="text"], input[type="password"], input[type="number"],
input[type="date"], input[type="email"], textarea, select {
width: 100%;
padding: .5rem .65rem;
background: var(--bg-input);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: .92rem;
transition: border-color .12s ease, box-shadow .12s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-dim);
}
input::placeholder, textarea::placeholder { color: var(--text-faint); }
textarea { resize: vertical; min-height: 5rem; }
.checkbox {
display: flex;
align-items: center;
gap: .55rem;
font-size: .9rem;
color: var(--text);
cursor: pointer;
}
/* The native checkbox is a white square on a near-black page — replace it. */
.checkbox input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 1.05rem;
height: 1.05rem;
margin: 0;
flex: 0 0 auto;
border: 1px solid var(--border-strong);
border-radius: 4px;
background: var(--bg-input);
cursor: pointer;
position: relative;
transition: background .12s ease, border-color .12s ease;
}
.checkbox input[type="checkbox"]:hover { border-color: var(--amber); }
.checkbox input[type="checkbox"]:checked {
background: var(--amber);
border-color: var(--amber);
}
.checkbox input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: .32rem;
top: .07rem;
width: .28rem;
height: .55rem;
border: solid #17110a;
border-width: 0 2px 2px 0;
transform: rotate(43deg);
}
.checkbox input[type="checkbox"]:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--amber-dim);
}
/* Hide the native number spinners — the light spinner widgets jar badly
against the dark theme. */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] { -moz-appearance: textfield; }
/* The native date picker's calendar icon is near-black on near-black. */
input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.75) sepia(1) saturate(3) hue-rotate(350deg) brightness(1.1);
cursor: pointer;
opacity: .7;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: 1; }
/* Dark scrollbars to match the page. */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) var(--bg);
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 6px;
border: 2px solid var(--bg);
}
::-webkit-scrollbar-thumb:hover { background: var(--amber-deep); }
/* A figure that should read as a number, not as body text. */
.strong { font-weight: 650; color: var(--text); }
.field-row { display: flex; gap: .75rem; }
.field-row > * { flex: 1; }
/* --------------------------------------------------------------- buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .4rem;
padding: .5rem .95rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--amber);
color: #17110a;
font-family: inherit;
font-size: .85rem;
font-weight: 650;
letter-spacing: .01em;
cursor: pointer;
text-decoration: none;
transition: filter .12s ease, background .12s ease, border-color .12s ease;
}
.btn:hover { filter: brightness(1.12); text-decoration: none; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn--ghost {
background: transparent;
border-color: var(--border-strong);
color: var(--text-muted);
}
.btn--ghost:hover { background: var(--bg-raised-2); color: var(--text); }
.btn--danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.btn--danger:hover { background: var(--danger-dim); }
.btn--sm { padding: .3rem .6rem; font-size: .78rem; }
.btn--block { width: 100%; }
.btn-row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
/* ---------------------------------------------------------------- tables */
.table-wrap { overflow-x: auto; }
table.data {
width: 100%;
border-collapse: collapse;
font-size: .88rem;
}
table.data th {
text-align: left;
padding: .55rem .8rem;
color: var(--text-faint);
font-size: .7rem;
font-weight: 650;
text-transform: uppercase;
letter-spacing: .08em;
border-bottom: 1px solid var(--border);
background: var(--bg-raised-2);
white-space: nowrap;
}
table.data td {
padding: .6rem .8rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
table.data tr:last-child td { border-bottom: none; }
table.data tbody tr:hover { background: var(--bg-raised-2); }
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* ----------------------------------------------------------------- chips */
.chip {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .12rem .5rem;
border-radius: 999px;
font-size: .74rem;
font-weight: 600;
letter-spacing: .02em;
white-space: nowrap;
}
.chip--active { background: var(--amber-dim); color: var(--amber-text); }
.chip--done { background: rgba(175, 175, 175, .13); color: var(--text-muted); }
.chip--warn { background: var(--warn-dim); color: var(--warn); }
.chip--danger { background: var(--danger-dim); color: var(--danger); }
.chip--good { background: var(--success-dim); color: var(--success); }
.chip--tap {
background: var(--bg-raised-2);
border: 1px solid var(--amber-deep);
color: var(--amber-text);
font-family: var(--mono);
}
/* --------------------------------------------------------------- flashes */
.flashes { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
.flash {
padding: .6rem .9rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
font-size: .9rem;
}
.flash--success { border-color: rgba(125, 185, 2, .45); background: var(--success-dim); color: #c3e08a; }
.flash--error { border-color: rgba(217, 83, 79, .45); background: var(--danger-dim); color: #f0a8a6; }
.flash--warning { border-color: rgba(232, 163, 61, .45); background: var(--warn-dim); color: #f2c880; }
.flash--info { border-color: rgba(190, 127, 57, .45); background: var(--amber-dim); color: var(--amber-text); }
/* ---------------------------------------------------------------- login */
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background:
radial-gradient(900px 480px at 50% -10%, rgba(190, 127, 57, .13), transparent 62%),
var(--bg);
}
.login__card {
width: 100%;
max-width: 380px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2rem;
box-shadow: var(--shadow);
position: relative;
}
.login__card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: linear-gradient(90deg, var(--amber-deep), var(--amber-bright), var(--amber-deep));
}
.login__mark { font-size: 2.4rem; line-height: 1; }
.login__title {
margin: .6rem 0 .15rem;
font-family: var(--display);
font-weight: 400;
font-size: 1.9rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--amber-bright);
}
.login__sub { color: var(--text-muted); font-size: .88rem; margin-bottom: 1.4rem; }
/* ----------------------------------------------------------- active card */
.brew-card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: .75rem;
position: relative;
overflow: hidden;
}
.brew-card::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: linear-gradient(90deg, var(--amber-deep), var(--amber) 45%, transparent 90%);
opacity: .75;
}
.brew-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
.brew-card__stat .stat__label { font-size: .68rem; }
.brew-card__stat .stat__value { font-size: 1.3rem; }
.brew-card__spark { height: 46px; }
/* The "start a new beer" tile — fills the last grid slot and gives the
page a way to begin a brew without hunting for the form. */
.brew-card--new {
align-items: center;
justify-content: center;
gap: .6rem;
text-align: center;
text-decoration: none;
color: var(--text-muted);
border-style: dashed;
border-color: var(--border-strong);
min-height: 180px;
}
.brew-card--new:hover {
border-color: var(--amber);
color: var(--text);
text-decoration: none;
}
.brew-card--new::before { display: none; }
.brew-card__new-mark {
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 2.4rem;
border: 1px solid var(--amber);
border-radius: 50%;
font-size: 1.35rem;
line-height: 1;
color: var(--amber);
}
.brew-card__new-text { display: flex; flex-direction: column; gap: .15rem; }
.brew-card__new-text strong { font-size: .95rem; }
.brew-card__spark--empty {
display: flex;
align-items: center;
font-style: italic;
padding: .35rem 0;
}
/* --------------------------------------------------------------- helpers */
.muted { color: var(--text-muted); }
.faint { color: var(--text-faint); }
.mono { font-family: var(--mono); }
.nowrap { white-space: nowrap; }
.small { font-size: .84rem; }
.tiny { font-size: .76rem; }
.center { text-align: center; }
.right { text-align: right; }
.stack > * + * { margin-top: .75rem; }
.mt-0 { margin-top: 0; }
.mb-0 { margin-bottom: 0; }
.row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
.row--between { justify-content: space-between; }
.hidden { display: none !important; }
/* Spacing utilities. These exist so the templates carry no inline
`style=` attributes, which a strict Content-Security-Policy
(`style-src 'self'`) would block. */
.mb-lg { margin-bottom: 1.25rem; }
.mt-lg { margin-top: 2rem; }
.empty {
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-faint);
}
.empty__icon { font-size: 2rem; display: block; margin-bottom: .5rem; opacity: .7; }
/* --------------------------------------------------------------- reading */
.reading-list { list-style: none; margin: 0; padding: 0; }
.reading-list__item {
display: flex;
gap: .75rem;
align-items: flex-start;
padding: .6rem 0;
border-bottom: 1px solid var(--border);
}
.reading-list__item:last-child { border-bottom: none; }
.reading-list__date {
font-family: var(--mono);
font-size: .82rem;
color: var(--text-muted);
flex: 0 0 5.5rem;
}
.reading-list__sg { font-family: var(--mono); font-weight: 650; flex: 0 0 4rem; }
.reading-list__body { flex: 1; min-width: 0; }
.reading-list__comment { color: var(--text-muted); font-size: .84rem; }
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+15
View File
@@ -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

+71
View File
@@ -0,0 +1,71 @@
/* ==========================================================================
Brew Log — page behaviour
Small, dependency-free helpers. Charts live in charts.js.
========================================================================== */
(function () {
"use strict";
/** Auto-focus the first empty field on a form. */
function focusFirstEmpty() {
var fields = document.querySelectorAll("input:not([type=hidden]), textarea, select");
for (var i = 0; i < fields.length; i++) {
if (!fields[i].value && !fields[i].readOnly) {
fields[i].focus();
return;
}
}
}
/** Default the reading date to today when the field is left empty. */
function defaultDateFields() {
var today = new Date().toISOString().slice(0, 10);
var dateFields = document.querySelectorAll('input[type="date"][data-default-today]');
for (var i = 0; i < dateFields.length; i++) {
if (!dateFields[i].value) {
dateFields[i].value = today;
}
}
}
/** Show/hide blocks driven by a checkbox. */
function wireToggles() {
var toggles = document.querySelectorAll("[data-toggle-target]");
for (var i = 0; i < toggles.length; i++) {
(function (toggle) {
var target = document.getElementById(toggle.getAttribute("data-toggle-target"));
if (!target) { return; }
function apply() {
target.classList.toggle("hidden", !toggle.checked);
}
toggle.addEventListener("change", apply);
apply();
})(toggles[i]);
}
}
/** Confirm destructive actions. */
function wireConfirms() {
var forms = document.querySelectorAll("[data-confirm]");
for (var i = 0; i < forms.length; i++) {
forms[i].addEventListener("submit", function (event) {
if (!window.confirm(this.getAttribute("data-confirm"))) {
event.preventDefault();
}
});
}
}
function init() {
focusFirstEmpty();
defaultDateFields();
wireToggles();
wireConfirms();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
File diff suppressed because one or more lines are too long
+289
View File
@@ -0,0 +1,289 @@
/* ==========================================================================
Brew Log — charts
Uses the vendored Chart.js (static/js/chart.umd.js) — no CDN at runtime, so
the container works on an isolated network.
========================================================================== */
(function () {
"use strict";
if (typeof Chart === "undefined") {
console.warn("Chart.js is not loaded; charts are disabled.");
return;
}
var ACCENT = "#be7f39";
var ACCENT_BRIGHT = "#dda05c";
var MUTED = "#afafaf";
var GRID = "rgba(175, 175, 175, 0.13)";
var WARN = "#e8a33d";
Chart.defaults.color = MUTED;
Chart.defaults.font.family =
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
Chart.defaults.font.size = 11;
Chart.defaults.borderColor = GRID;
function withAlpha(hex, alpha) {
var r = parseInt(hex.slice(1, 3), 16);
var g = parseInt(hex.slice(3, 5), 16);
var b = parseInt(hex.slice(5, 7), 16);
return "rgba(" + r + "," + g + "," + b + "," + alpha + ")";
}
function baseOptions(extra) {
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { boxWidth: 10, boxHeight: 10, usePointStyle: true } }
},
scales: {
x: { grid: { color: GRID }, ticks: { maxRotation: 0, autoSkipPadding: 12 } },
y: { grid: { color: GRID } }
}
};
if (extra) {
Object.keys(extra).forEach(function (key) {
options[key] = extra[key];
});
}
return options;
}
/* ------------------------------------------------------- sparklines */
function drawSparklines() {
var canvases = document.querySelectorAll("canvas.sparkline");
for (var i = 0; i < canvases.length; i++) {
(function (canvas) {
var labels = JSON.parse(canvas.getAttribute("data-labels") || "[]");
var values = JSON.parse(canvas.getAttribute("data-values") || "[]");
var points = [];
for (var j = 0; j < values.length; j++) {
if (values[j] !== null && values[j] !== undefined) {
points.push({ x: labels[j], y: values[j] });
}
}
if (!points.length) { return; }
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
datasets: [{
data: points,
borderColor: ACCENT_BRIGHT,
backgroundColor: withAlpha(ACCENT, 0.18),
borderWidth: 2,
pointRadius: points.length > 12 ? 0 : 2,
fill: true,
tension: 0.3
}]
},
options: baseOptions({
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) { return ctx.parsed.y + " (" + ctx.parsed.x + ")"; }
}
}
},
scales: {
x: { display: false, grid: { display: false } },
y: { display: false, grid: { display: false } }
}
})
});
})(canvases[i]);
}
}
/* --------------------------------------------------- run detail chart */
function drawRunChart() {
var canvas = document.getElementById("run-chart");
if (!canvas) { return; }
// The run id comes from a data attribute rather than a global, so the
// page needs no inline script (a strict CSP forbids inline scripts).
var runId = canvas.getAttribute("data-run-id");
if (!runId) { return; }
fetch("/api/runs/" + encodeURIComponent(runId), {
headers: { "Accept": "application/json" }
})
.then(function (response) {
if (!response.ok) { throw new Error("run fetch failed"); }
return response.json();
})
.then(function (run) {
var points = [];
(run.readings || []).forEach(function (r) {
if (r.sg !== null && r.sg !== undefined) {
points.push({ x: r.date, y: r.sg });
}
});
if (!points.length) { return; }
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
datasets: [{
label: "Gravity",
data: points,
borderColor: ACCENT,
backgroundColor: withAlpha(ACCENT, 0.12),
borderWidth: 2,
pointRadius: 3,
pointBackgroundColor: ACCENT,
fill: true,
tension: 0.25
}]
},
options: baseOptions({
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) {
return ctx.parsed.y + " points (" + ctx.parsed.x + ")";
}
}
}
},
scales: {
y: {
reverse: true,
title: { display: true, text: "Specific gravity (points)" }
}
}
})
});
})
.catch(function (err) { console.warn(err); });
}
/* ------------------------------------------------------ history charts */
function drawAbvChart() {
var canvas = document.getElementById("abv-chart");
if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) {
// Only finished brews have a measured ABV. An unfinished run
// has a projection, not a measurement, so it is not plotted
// here.
var completed = runs.filter(function (r) {
return r.abv !== null && r.abv !== undefined;
});
if (!completed.length) { return; }
new Chart(canvas.getContext("2d"), {
type: "bar",
data: {
labels: completed.map(function (r) {
return r.canonical_name || ("Run " + r.beer_run_id);
}),
datasets: [{
label: "ABV %",
data: completed.map(function (r) { return r.abv; }),
backgroundColor: withAlpha(ACCENT, 0.65),
borderColor: ACCENT,
borderWidth: 1,
borderRadius: 4
}]
},
options: baseOptions({
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function (ctx) { return ctx.parsed.y.toFixed(2) + "% ABV"; }
}
}
},
scales: {
y: { beginAtZero: true, title: { display: true, text: "% ABV" } },
x: { ticks: { maxRotation: 45, minRotation: 0 } }
}
})
});
})
.catch(function (err) { console.warn(err); });
}
function drawMonthsChart() {
var canvas = document.getElementById("months-chart");
if (!canvas) { return; }
fetch("/api/runs", { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (runs) {
var counts = {};
runs.forEach(function (r) {
var stamp = r.final_date || r.first_date;
if (!stamp) { return; }
var month = stamp.slice(0, 7);
counts[month] = (counts[month] || 0) + 1;
});
var months = Object.keys(counts).sort();
if (!months.length) { return; }
// Include the months with no brews, with a zero. Leaving the
// gaps out would space the busy months evenly on the axis and
// make the chart read as continuous activity.
var filled = [];
var cursor = months[0];
var last = months[months.length - 1];
while (cursor <= last) {
filled.push(cursor);
var year = parseInt(cursor.slice(0, 4), 10);
var month = parseInt(cursor.slice(5, 7), 10);
month += 1;
if (month > 12) { month = 1; year += 1; }
cursor = year + "-" + (month < 10 ? "0" + month : month);
}
months = filled;
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
labels: months,
datasets: [{
label: "Brews",
data: months.map(function (m) { return counts[m]; }),
borderColor: ACCENT,
backgroundColor: withAlpha(ACCENT, 0.12),
borderWidth: 2,
pointRadius: 3,
pointBackgroundColor: ACCENT,
fill: true,
tension: 0.3
}]
},
options: baseOptions({
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } }
}
})
});
})
.catch(function (err) { console.warn(err); });
}
function init() {
drawSparklines();
drawRunChart();
drawAbvChart();
drawMonthsChart();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
View File
-90
View File
@@ -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
+131
View File
@@ -0,0 +1,131 @@
{% extends 'base.html' %}
{% block title %}Active Beers — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Active Beers</h1>
<p class="page__sub">Everything currently in the fermenter, with its tap and projected strength.</p>
</div>
<div class="btn-row">
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Record a reading</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.history') }}">History</a>
</div>
</div>
<div class="grid grid--3 mb-lg">
<div class="stat">
<div class="stat__label">Active brews</div>
<div class="stat__value">{{ totals.count }}</div>
<div class="stat__meta">in the fermenter now</div>
</div>
<div class="stat">
<div class="stat__label">Average age</div>
<div class="stat__value">{{ totals.avg_days }}</div>
<div class="stat__meta">day{{ '' if totals.avg_days == '1' else 's' }} since first reading</div>
</div>
<div class="stat">
<div class="stat__label">Longest running</div>
<div class="stat__value">{{ totals.longest_days if totals.longest_days is not none else '—' }}</div>
<div class="stat__meta">day{{ '' if totals.longest_days == 1 else 's' }} in the fermenter</div>
</div>
</div>
{% if cards %}
<div class="grid grid--cards">
{% for run in cards %}
<article class="brew-card">
<div class="brew-card__head">
<div>
<h2 class="brew-card__name">
<a href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">{{ run.canonical_name }}</a>
</h2>
<div class="brew-card__meta">
Run {{ run.beer_run_id }}
{% if run.first_date %} &middot; since {{ run.first_date }}{% endif %}
{% if run.reading_count == 1 %} &middot; 1 reading{% else %} &middot; {{ run.reading_count }} readings{% endif %}
</div>
</div>
{% if run.tap_number is not none %}
<span class="chip chip--tap">Tap {{ run.tap_number }}</span>
{% else %}
<span class="chip chip--active">Active</span>
{% endif %}
</div>
<div class="brew-card__stats">
<div class="brew-card__stat">
<div class="stat__label">Gravity</div>
<div class="stat__value">
{% if run.min_sg is not none %}{{ run.min_sg_decimal }}{% else %}—{% endif %}
</div>
{% if run.min_sg is not none %}
<div class="stat__meta">{{ run.min_sg_point_scale }} points</div>
{% endif %}
</div>
<div class="brew-card__stat">
<div class="stat__label">Est. ABV</div>
<div class="stat__value">
{% if run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if run.projected_abv is not none %}projected{% else %}needs 2nd reading{% endif %}
</div>
</div>
<div class="brew-card__stat">
<div class="stat__label">Days</div>
<div class="stat__value">
{% if run.days_in_fermenter is not none %}{{ run.days_in_fermenter }}{% else %}—{% endif %}
</div>
<div class="stat__meta">day{{ '' if run.days_in_fermenter == 1 else 's' }} brewing</div>
</div>
</div>
{% if run.readings | length > 1 %}
<div class="brew-card__spark">
<canvas class="sparkline"
data-labels='{{ run.readings | map(attribute="date") | list | tojson }}'
data-values='{{ run.readings | map(attribute="sg") | list | tojson }}'></canvas>
</div>
{% else %}
<div class="brew-card__spark brew-card__spark--empty faint tiny">
One reading so far — the trend appears after the next one.
</div>
{% endif %}
{% if run.last_comment %}
<div class="small muted">
<strong class="faint tiny">Latest:</strong> {{ run.last_comment }}
</div>
{% endif %}
<div class="btn-row">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open run</a>
<a class="btn btn--sm" href="{{ url_for('brews.index') }}">Add reading</a>
</div>
</article>
{% endfor %}
{# A tile that fills the last slot and gives the page its missing
affordance: there is currently no way to start a brew from here. #}
<a class="brew-card brew-card--new" href="{{ url_for('brews.index') }}">
<span class="brew-card__new-mark" aria-hidden="true">+</span>
<span class="brew-card__new-text">
<strong>Start a new beer</strong>
<span class="faint tiny">Record its first reading</span>
</span>
</a>
</div>
{% else %}
<div class="card">
<div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<p><strong>Nothing in the fermenter.</strong></p>
<p class="small">Start a run by recording its first reading on the
<a href="{{ url_for('brews.index') }}">brew process page</a>.</p>
</div>
</div>
{% endif %}
{% endblock %}
+49 -19
View File
@@ -1,26 +1,56 @@
<!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>
<a class="nav__link {% if current_path == '/active' %}is-active{% endif %}"
href="{{ url_for('analysis.active') }}">Active Beers</a>
<a class="nav__link {% if current_path == '/history' %}is-active{% endif %}"
href="{{ url_for('analysis.history') }}">History</a>
</nav>
{{ bootstrap.load_css() }} <form method="post" action="{{ url_for('auth.logout') }}" class="topbar__logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span class="topbar__user" title="Signed in as {{ session.get('username', '') }}">{{ session.get('username', '') }}</span>
<button class="btn btn--ghost btn--sm" type="submit">Sign out</button>
</form>
</div>
</header>
{% endif %}
<style> <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/chart.umd.js') }}"></script>
{{ bootstrap.load_js() }} <script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body> <script src="{{ url_for('static', filename='js/charts.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html> </html>
+193
View File
@@ -0,0 +1,193 @@
{% extends 'base.html' %}
{% block title %}Brew Process — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">Brew Process</h1>
<p class="page__sub">Record a gravity reading, then review where each brew is up to.</p>
</div>
</div>
<div class="grid grid--sidebar">
<!-- ------------------------------------------------ record a reading -->
<section class="card">
<div class="card__head">
<h2 class="card__title">New reading</h2>
<span class="card__hint">Gravity in points (1039 = 1.039)</span>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field-row">
<div class="field">
<label class="field__label" for="beer_run_id">Run number</label>
<input type="number" id="beer_run_id" name="beer_run_id"
min="1" step="1" required>
</div>
<div class="field">
<label class="field__label" for="Date">Reading date</label>
<input type="date" id="Date" name="Date" required>
</div>
</div>
<div class="field">
<label class="field__label" for="beer_run_type">Beer / cider name</label>
<input type="text" id="beer_run_type" name="beer_run_type"
maxlength="120" placeholder="e.g. Necatron">
<div class="field__hint">The name is shown on the run summary.</div>
</div>
<div class="field">
<label class="field__label" for="sg">Gravity reading (points)</label>
<input type="number" id="sg" name="sg" min="900" max="1300"
step="1" required placeholder="1045">
<div class="field__hint">Drop the leading "1." — 1045 means 1.045.</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="final_reading" value="y">
<span>This is the final reading (closes the run)</span>
</label>
</div>
<div class="field">
<label class="field__label" for="comment">Comment</label>
<textarea id="comment" name="comment" maxlength="2000"
placeholder="Hops in, bottling, kegging…"></textarea>
</div>
<button class="btn btn--block" type="submit">Save reading</button>
</form>
</div>
</section>
<!-- --------------------------------------------------- run summaries -->
<div class="stack">
<section class="card">
<div class="card__head">
<h2 class="card__title">In the fermenter</h2>
<span class="card__hint">{{ active_runs | length }} active</span>
</div>
<div class="card__body card__body--flush">
{% if active_runs %}
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Gravity</th>
<th class="num">Est. ABV</th>
<th>Comment</th>
<th></th>
</tr>
</thead>
<tbody>
{% for run in active_runs %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td class="num">
{% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div>
{% else %}—{% endif %}
</td>
<td class="num">
{% if run.projected_abv is not none %}
{{ '%.2f' % run.projected_abv }}%
{% else %}
<span class="faint" title="Record another reading, or set the original gravity, to get an estimate">—</span>
{% endif %}
</td>
<td class="comment">
{% if run.comments %}
{{ run.comments[-1] }}
{% else %}
<span class="faint">—</span>
{% endif %}
</td>
<td class="right">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty">
<span class="empty__icon" aria-hidden="true">&#127866;</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 %}
+14
View File
@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}{{ code }} — Brew Log{% endblock %}
{% block content %}
<div class="card mt-lg">
<div class="empty">
<img class="empty__icon" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="40" height="40">
<h1>{{ code }}</h1>
<p>{{ message }}</p>
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Back to the brew log</a>
</div>
</div>
{% endblock %}
+207
View File
@@ -0,0 +1,207 @@
{% extends 'base.html' %}
{% block title %}History — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">History</h1>
<p class="page__sub">How every brew has gone, and what it turned out like.</p>
</div>
<div class="btn-row">
<a class="btn btn--ghost btn--sm" href="{{ url_for('analysis.active') }}">Active beers</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">Brew process</a>
</div>
</div>
<div class="grid grid--4 mb-lg">
<div class="stat">
<div class="stat__label">Total brews</div>
<div class="stat__value">{{ stats.total_runs }}</div>
<div class="stat__meta">{{ stats.completed }} finished &middot; {{ stats.active }} active</div>
</div>
<div class="stat">
<div class="stat__label">Average ABV</div>
<div class="stat__value">{{ '%.2f' % stats.avg_abv }}%</div>
<div class="stat__meta">across finished brews</div>
</div>
<div class="stat">
<div class="stat__label">Strongest</div>
<div class="stat__value">
{% if stats.best %}{{ '%.2f' % stats.best.abv }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.best %}{{ stats.best.name }}{% else %}—{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Lightest</div>
<div class="stat__value">
{% if stats.worst %}{{ '%.2f' % stats.worst.abv }}%{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if stats.worst %}{{ stats.worst.name }}{% else %}—{% endif %}
</div>
</div>
</div>
<div class="grid grid--2 mb-lg">
<section class="card">
<div class="card__head">
<h2 class="card__title">Alcohol by brew</h2>
<span class="card__hint">Standard formula (OG − FG) × 131.25</span>
</div>
<div class="card__body">
<canvas id="abv-chart" height="120"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Brews over time</h2>
<span class="card__hint">Runs started per month (active runs counted at their first reading)</span>
</div>
<div class="card__body">
<canvas id="months-chart" height="120"></canvas>
</div>
</section>
</div>
{% if outliers %}
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Readings worth a second look</h2>
<span class="card__hint">{{ outliers | length }} outside the usual hydrometer range</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Date</th>
<th class="num">Reading</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{% for row in outliers %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="mono nowrap">{{ row.date or '—' }}</td>
<td class="num"><span class="chip chip--warn">{{ row.sg }}</span></td>
<td class="comment">{{ row.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Every brew</h2>
<span class="card__hint">{{ summaries | length }} runs</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th>Status</th>
<th class="num">OG</th>
<th class="num">FG</th>
<th class="num">Drop</th>
<th class="num">ABV</th>
<th class="num">Legacy</th>
<th>Finished</th>
<th>Comment</th>
<th></th>
</tr>
</thead>
<tbody>
{% for run in summaries %}
<tr>
<td class="mono">{{ run.beer_run_id }}</td>
<td>{{ run.canonical_name }}</td>
<td>
{% if run.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</td>
<td class="num">{{ run.og_points or '—' }}</td>
<td class="num">{{ run.fg_points or '—' }}</td>
<td class="num">{{ run.drop_points if run.drop_points is not none else '—' }}</td>
<td class="num">
{% if run.abv is not none %}<strong>{{ '%.2f' % run.abv }}%</strong>
{% elif run.projected_abv is not none %}{{ '%.2f' % run.projected_abv }}%*
{% else %}—{% endif %}
</td>
<td class="num muted">{{ run.legacy_abv if run.legacy_abv is not none else '—' }}</td>
<td class="mono nowrap">{{ run.final_date or '—' }}</td>
<td class="comment">
{% if run.comments %}{{ run.comments[-1] }}{% else %}<span class="faint">—</span>{% endif %}
</td>
<td class="right">
<a class="btn btn--ghost btn--sm"
href="{{ url_for('brews.run_detail', run_id=run.beer_run_id) }}">Open</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Standard vs legacy alcohol figures</h2>
<span class="card__hint">The old app used (max − min) ÷ 7.36 + 0.5, which is not a recognised formula</span>
</div>
<div class="card__body card__body--flush">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>Run</th>
<th>Name</th>
<th class="num">Standard ABV</th>
<th class="num">Legacy ABV</th>
<th class="num">Overstated by</th>
<th>Finished</th>
</tr>
</thead>
<tbody>
{% for row in abv_rows %}
<tr>
<td class="mono">{{ row.beer_run_id }}</td>
<td>{{ row.name }}</td>
<td class="num">{{ '%.2f' % row.abv }}%</td>
<td class="num muted">{{ '%.2f' % row.legacy_abv }}%</td>
<td class="num">
{% if row.delta > 0 %}
<span class="chip chip--warn">+{{ '%.2f' % row.delta }}</span>
{% else %}
<span class="faint">{{ '%.2f' % row.delta }}</span>
{% endif %}
</td>
<td class="mono nowrap">{{ row.final_date or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
-37
View File
@@ -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 %}
+32
View File
@@ -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 %}
+167
View File
@@ -0,0 +1,167 @@
{% extends 'base.html' %}
{% block title %}Run {{ summary.beer_run_id }} — Brew Log{% endblock %}
{% block content %}
<div class="page__head">
<div>
<h1 class="page__title">
{{ summary.canonical_name }}
{% if summary.active %}
<span class="chip chip--active">Active</span>
{% else %}
<span class="chip chip--done">Finished</span>
{% endif %}
</h1>
<p class="page__sub">
Run <span class="mono">{{ summary.beer_run_id }}</span>
{% if summary.first_date %} &middot; started {{ summary.first_date }}{% endif %}
{% if summary.final_date %} &middot; finished {{ summary.final_date }}{% endif %}
</p>
</div>
<a class="btn btn--ghost btn--sm" href="{{ url_for('brews.index') }}">&larr; Back</a>
</div>
<div class="grid grid--sidebar mb-lg">
<!-- ------------------------------------------------- run metadata -->
<section class="card">
<div class="card__head">
<h2 class="card__title">Run details</h2>
</div>
<div class="card__body">
<form method="post" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="field">
<label class="field__label" for="name">Beer / cider name</label>
<input type="text" id="name" name="name" maxlength="120"
value="{{ meta_form.name.data or '' }}">
<div class="field__hint">Overrides the drifting name on each reading.</div>
</div>
<div class="field-row">
<div class="field">
<label class="field__label" for="tap_number">Tap number</label>
<input type="number" id="tap_number" name="tap_number"
min="1" max="99" step="1"
value="{{ meta_form.tap_number.data if meta_form.tap_number.data is not none else '' }}">
</div>
<div class="field">
<label class="field__label" for="og_points">Original gravity</label>
<input type="number" id="og_points" name="og_points"
min="900" max="1300" step="1"
value="{{ meta_form.og_points.data if meta_form.og_points.data is not none else '' }}">
<div class="field__hint">Points, e.g. 1045.</div>
</div>
</div>
<div class="field">
<label class="field__label" for="notes">Brew notes</label>
<textarea id="notes" name="notes" maxlength="4000">{{ meta_form.notes.data or '' }}</textarea>
</div>
<button class="btn btn--block" type="submit">Save run details</button>
</form>
</div>
</section>
<div class="grid grid--4">
<div class="stat">
<div class="stat__label">Original gravity</div>
<div class="stat__value">
{% if summary.og_points is not none %}{{ summary.og_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.og_points is not none %}
<div class="stat__meta">{{ summary.og_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">
{% if summary.active %}Current gravity{% else %}Final gravity{% endif %}
</div>
<div class="stat__value">
{% if summary.fg_points is not none %}{{ summary.fg_decimal_str }}{% else %}—{% endif %}
</div>
{% if summary.fg_points is not none %}
<div class="stat__meta">{{ summary.fg_point_scale }} points</div>
{% endif %}
</div>
<div class="stat">
<div class="stat__label">Alcohol</div>
<div class="stat__value">
{% if summary.abv is not none %}
{{ '%.2f' % summary.abv }}%
{% elif summary.projected_abv is not none %}
{{ '%.2f' % summary.projected_abv }}%
{% else %}—{% endif %}
</div>
<div class="stat__meta">
{% if summary.abv is not none %}
measured
{% elif summary.projected_abv is not none %}
projected
{% else %}
needs 2nd reading
{% endif %}
</div>
</div>
<div class="stat">
<div class="stat__label">Days brewing</div>
<div class="stat__value">
{% if summary.days_in_fermenter is not none %}{{ summary.days_in_fermenter }}{% else %}—{% endif %}
</div>
<div class="stat__meta">{{ summary.reading_count }} reading{{ '' if summary.reading_count == 1 else 's' }}</div>
</div>
</div>
</div>
<section class="card mb-lg">
<div class="card__head">
<h2 class="card__title">Gravity over time</h2>
<span class="card__hint">Standard ABV: (OG &minus; FG) &times; 131.25</span>
</div>
<div class="card__body">
<canvas id="run-chart" data-run-id="{{ summary.beer_run_id }}" height="110"></canvas>
</div>
</section>
<section class="card">
<div class="card__head">
<h2 class="card__title">Readings</h2>
<span class="card__hint">{{ summary.reading_count }} total</span>
</div>
<div class="card__body">
<ul class="reading-list">
{% for reading in summary.readings %}
<li class="reading-list__item">
<span class="reading-list__date">{{ reading.date or '—' }}</span>
<span class="reading-list__sg">
{% if reading.sg is not none %}{{ reading.sg }}{% else %}—{% endif %}
</span>
<div class="reading-list__body">
{% if reading.comment %}
<div class="reading-list__comment">{{ reading.comment }}</div>
{% else %}
<div class="faint tiny">No comment</div>
{% endif %}
</div>
{% if reading.final_reading %}
<span class="chip chip--done">Final</span>
<form method="post" action="{{ url_for('brews.unfinal', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Un-set</button>
</form>
{% else %}
<form method="post" action="{{ url_for('brews.mark_final', run_id=summary.beer_run_id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="date" value="{{ reading.date }}">
<button class="btn btn--ghost btn--sm" type="submit">Set final</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}
-67
View File
@@ -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 %}
-5
View File
@@ -1,5 +0,0 @@
import table.table_builder as table_builder
test = table_builder.TableBuilder()
print(test.table_build())
+1
View File
@@ -0,0 +1 @@
"""Blueprints for the beer_data app."""
+181
View File
@@ -0,0 +1,181 @@
"""The analysis pages: what is active, and how the history has gone."""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from flask import Blueprint, redirect, render_template, request, session, url_for
from brewing import Run, is_plausible_sg, run_summary
bp = Blueprint("analysis", __name__)
def repo():
"""The request-scoped repository (one Mongo client per request)."""
from flask import current_app
return current_app.extensions["beer_repo"]()
def login_required(fn):
"""Redirect to the login page when there is no session."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("auth.login", next=request.path))
return fn(*args, **kwargs)
return wrapper
def _month_range(first: str, last: str) -> list[str]:
"""Every ``YYYY-MM`` from *first* to *last* inclusive.
Used to include the months with no brews in the chart, so a quiet period
shows as a gap rather than being compressed out of the axis.
"""
import calendar
year, month = int(first[:4]), int(first[5:7])
end_year, end_month = int(last[:4]), int(last[5:7])
months = []
while (year, month) <= (end_year, end_month):
months.append(f"{year:04d}-{month:02d}")
month += 1
if month > 12:
month = 1
year += 1
return months
def _fmt_days(value: float) -> str:
"""Format a day count: a whole number stays whole, a fraction keeps one
decimal place."""
if float(value).is_integer():
return str(int(value))
return f"{value:.1f}"
def _decorate(run: Run, metadata: dict) -> dict:
"""Attach the stored metadata to a run summary."""
meta = metadata.get(run.beer_run_id, {})
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["notes"] = meta.get("notes", "")
return summary
@bp.route("/active")
@login_required
def active():
"""The active beers page: what is in the fermenter right now."""
runs = repo().runs()
metadata = repo().all_run_metadata()
today = date.today()
cards = []
for run in runs:
if not run.is_active():
continue
summary = _decorate(run, metadata)
summary["days_in_fermenter"] = run.days_in_fermenter(today)
cards.append(summary)
# Most recently started first — that is what is fermenting right now.
cards.sort(key=lambda s: (s["first_date"] or "0000-00-00"), reverse=True)
ages = [c["days_in_fermenter"] for c in cards if c["days_in_fermenter"] is not None]
return render_template(
"active.html",
cards=cards,
today=today,
totals={
"count": len(cards),
"avg_days": _fmt_days(sum(ages) / len(ages)) if ages else 0,
"longest_days": max(ages) if ages else None,
},
)
@bp.route("/history")
@login_required
def history():
"""Historical brews and how they performed."""
runs = repo().runs()
metadata = repo().all_run_metadata()
summaries = [_decorate(run, metadata) for run in runs]
completed = [s for s in summaries if not s["active"]]
# --- ABV comparison: standard formula vs the legacy one ---------------- #
abv_rows = []
for s in completed:
if s["abv"] is None:
continue
abv_rows.append({
"beer_run_id": s["beer_run_id"],
"name": s["canonical_name"],
"final_date": s["final_date"],
"abv": s["abv"],
"legacy_abv": s["legacy_abv"],
"delta": round((s["legacy_abv"] or 0) - s["abv"], 2),
})
abv_rows.sort(key=lambda r: (r["final_date"] or "0000-00-00"))
# --- brews per month --------------------------------------------------- #
# Every month between the first and the last brew is included, with zero
# for the months with no brews. Leaving the gaps out would space the
# remaining months evenly and make the chart read as continuous activity.
per_month: dict[str, int] = defaultdict(int)
for s in summaries:
stamp = s["final_date"] or s["first_date"]
if stamp:
per_month[stamp[:7]] += 1
months = []
if per_month:
first, last = min(per_month), max(per_month)
for month in _month_range(first, last):
months.append((month, per_month.get(month, 0)))
# --- outliers ---------------------------------------------------------- #
outliers = []
for s in summaries:
for reading in s["readings"]:
sg = reading["sg"]
if sg is not None and not is_plausible_sg(sg):
outliers.append({
"beer_run_id": s["beer_run_id"],
"name": s["canonical_name"],
"date": reading["date"],
"sg": sg,
"comment": reading["comment"],
})
avg_abv = round(sum(r["abv"] for r in abv_rows) / len(abv_rows), 2) if abv_rows else 0
best = max(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
worst = min(abv_rows, key=lambda r: r["abv"]) if abv_rows else None
return render_template(
"history.html",
summaries=summaries,
completed=completed,
abv_rows=abv_rows,
months=months,
outliers=outliers,
stats={
"total_runs": len(summaries),
"completed": len(completed),
"active": len(summaries) - len(completed),
"avg_abv": avg_abv,
"best": best,
"worst": worst,
},
)
+45
View File
@@ -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"))
+236
View File
@@ -0,0 +1,236 @@
"""The brew-process pages: record readings and review the runs."""
from __future__ import annotations
from flask import (Blueprint, flash, jsonify, redirect, render_template,
request, session, url_for)
from flask_wtf import FlaskForm
from wtforms import (BooleanField, DateField, IntegerField, StringField,
SubmitField, TextAreaField)
from wtforms.validators import DataRequired, Length, NumberRange, Optional
from brewing import is_plausible_sg, run_summary
bp = Blueprint("brews", __name__)
def repo():
"""The request-scoped repository (one Mongo client per request)."""
from flask import current_app
return current_app.extensions["beer_repo"]()
def login_required(fn):
"""Redirect to the login page when there is no session."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("logged_in"):
return redirect(url_for("auth.login", next=request.path))
return fn(*args, **kwargs)
return wrapper
def _last_reading_date(run) -> str | None:
"""The ISO date of the most recent reading in a run."""
last = run.last_reading()
if last is None or last.date is None:
return None
return last.date.isoformat()
class ReadingForm(FlaskForm):
"""Add or update a gravity reading."""
beer_run_id = IntegerField("Run number", validators=[DataRequired()])
beer_run_type = StringField("Beer / cider name",
validators=[Optional(), Length(max=120)])
Date = DateField("Reading date", validators=[DataRequired()])
sg = IntegerField("Gravity reading (points)",
validators=[DataRequired(), NumberRange(min=900, max=1500)])
final_reading = BooleanField("This is the final reading")
comment = TextAreaField("Comment", validators=[Optional(), Length(max=2000)])
submit = SubmitField("Save reading")
class RunMetaForm(FlaskForm):
"""Edit the canonical metadata of a run.
These are the details that cannot be derived from the readings: the name
the brew is actually known by, which tap it is on, and its original
gravity.
"""
name = StringField("Beer / cider name", validators=[Optional(), Length(max=120)])
tap_number = IntegerField("Tap number",
validators=[Optional(), NumberRange(min=1, max=99)])
og_points = IntegerField("Original gravity (points)",
validators=[Optional(), NumberRange(min=900, max=1500)])
notes = TextAreaField("Brew notes", validators=[Optional(), Length(max=4000)])
submit = SubmitField("Save run")
@bp.route("/brews", methods=["GET", "POST"])
@login_required
def index():
"""The brew-process page: record a reading, then review the runs."""
form = ReadingForm()
if form.validate_on_submit():
sg_points = int(form.sg.data)
if not is_plausible_sg(sg_points):
flash(
f"{sg_points} is outside the usual hydrometer range "
"(990-1200). It was saved anyway — double-check the reading.",
"warning",
)
repo().upsert_reading(
beer_run_id=str(form.beer_run_id.data),
beer_type=(form.beer_run_type.data or "").strip(),
sg=sg_points,
reading_date=form.Date.data.isoformat(),
final_reading=bool(form.final_reading.data),
comment=(form.comment.data or "").strip(),
)
flash("Reading saved.", "success")
return redirect(url_for("brews.index"))
runs = repo().runs()
metadata = repo().all_run_metadata()
summaries = []
for run in runs:
meta = metadata.get(run.beer_run_id, {})
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["notes"] = meta.get("notes", "")
summary["last_date"] = _last_reading_date(run)
summaries.append(summary)
# Active runs: most recent activity first — that is what is fermenting now.
active = sorted([s for s in summaries if s["active"]],
key=lambda s: s["last_date"] or "", reverse=True)
# Finished runs: most recently finished first.
completed = sorted([s for s in summaries if not s["active"]],
key=lambda s: s["final_date"] or "", reverse=True)
return render_template(
"brews.html",
form=form,
summaries=summaries,
active_runs=active,
completed_runs=completed,
next_tap=repo().next_free_tap_number(),
)
@bp.route("/runs/<run_id>", methods=["GET", "POST"])
@login_required
def run_detail(run_id: str):
"""One run in detail, with its readings and its editable metadata."""
run = repo().run(run_id)
if run is None:
flash(f"No run numbered {run_id}.", "error")
return redirect(url_for("brews.index"))
meta = repo().run_metadata(run_id)
meta_form = RunMetaForm()
if meta_form.validate_on_submit():
repo().upsert_run_metadata(
run_id,
name=(meta_form.name.data or "").strip() or None,
tap_number=meta_form.tap_number.data,
og_points=meta_form.og_points.data,
notes=(meta_form.notes.data or "").strip() or None,
)
flash("Run details saved.", "success")
return redirect(url_for("brews.run_detail", run_id=run_id))
if not meta_form.is_submitted():
meta_form.name.data = meta.get("name") or run.display_name()
meta_form.tap_number.data = meta.get("tap_number")
meta_form.og_points.data = meta.get("og_points")
meta_form.notes.data = meta.get("notes", "")
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["notes"] = meta.get("notes", "")
return render_template("run_detail.html", run=run, summary=summary,
meta_form=meta_form, meta=meta)
@bp.route("/api/runs")
@login_required
def api_runs():
"""JSON feed of the run summaries, used by the charts on the analysis
pages.
The charts fetch this rather than having the data inlined into the page,
so one fetch serves every chart on a page.
"""
runs = repo().runs()
metadata = repo().all_run_metadata()
payload = []
for run in runs:
meta = metadata.get(run.beer_run_id, {})
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
summary["last_date"] = _last_reading_date(run)
payload.append(summary)
return jsonify(payload)
@bp.route("/api/runs/<run_id>")
@login_required
def api_run(run_id: str):
"""JSON detail of a single run."""
run = repo().run(run_id)
if run is None:
return jsonify({"error": "not found"}), 404
meta = repo().run_metadata(run_id)
summary = run_summary(run, og_override=meta.get("og_points"))
summary["canonical_name"] = meta.get("name") or run.display_name()
summary["tap_number"] = meta.get("tap_number")
return jsonify(summary)
@bp.route("/runs/<run_id>/final", methods=["POST"])
@login_required
def mark_final(run_id: str):
"""Flag a reading as the final one for its run."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
changed = repo().set_final_reading(run_id, reading_date, True)
if changed:
flash("Final reading recorded.", "success")
else:
flash("That reading could not be updated.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
@bp.route("/runs/<run_id>/unfinal", methods=["POST"])
@login_required
def unfinal(run_id: str):
"""Un-flag a final reading, putting the run back into the active list."""
reading_date = request.form.get("date", "")
if not reading_date:
flash("No reading date supplied.", "error")
return redirect(url_for("brews.run_detail", run_id=run_id))
repo().set_final_reading(run_id, reading_date, False)
flash("Reading is no longer marked final.", "info")
return redirect(url_for("brews.run_detail", run_id=run_id))
+73
View File
@@ -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
+641
View File
@@ -0,0 +1,641 @@
"""Tests for the web layer: login, the brew-process page and the API."""
from __future__ import annotations
import pytest
from conftest import seed_readings
@pytest.fixture()
def logged_in(client, repo):
"""A client with an authenticated session and a user in the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
assert response.status_code == 200
return client
# ------------------------------------------------------------------ public #
def test_healthz_is_public(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json == {"status": "ok"}
def test_readyz_checks_the_database(client):
response = client.get("/readyz")
assert response.status_code == 200
assert response.json == {"status": "ready"}
def test_readyz_reports_not_ready_when_mongo_is_down(client, monkeypatch):
"""A pod that cannot reach Mongo must not serve traffic."""
from pymongo.errors import ServerSelectionTimeoutError
def boom(*args, **kwargs):
raise ServerSelectionTimeoutError("no mongo")
monkeypatch.setattr("mongo.repository.BeerRepository.__init__",
lambda self, **kw: (_ for _ in ()).throw(ServerSelectionTimeoutError("no mongo")))
response = client.get("/readyz")
assert response.status_code == 503
def test_root_redirects_to_login(client):
response = client.get("/")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_login_page_renders(client):
response = client.get("/login")
assert response.status_code == 200
assert b"Brew Log" in response.data
def test_login_with_bad_credentials_is_rejected(client, repo):
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
response = client.post("/login", data={"username": "beer", "password": "wrong"},
follow_redirects=True)
assert response.status_code == 200
assert b"not recognised" in response.data
def test_protected_pages_redirect_when_logged_out(client):
for path in ("/brews", "/runs/1"):
response = client.get(path)
assert response.status_code == 302, path
assert "/login" in response.headers["Location"], path
# ------------------------------------------------------------- brew pages #
def test_brews_page_lists_runs(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
("2", "Ginger Beer", "1045", "2026-02-01", False, ""),
])
response = logged_in.get("/brews")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Ginger Beer" in body
# The comment on the reading should be surfaced.
assert "Keghing" in body
def test_run_detail_page_renders(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, "start"),
("1", "Pale Ale", "1010", "2026-01-20", True, "Keghing"),
])
response = logged_in.get("/runs/1")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "Keghing" in body
assert "5.25%" in body
# ------------------------------------------------------------- write paths #
def test_recording_a_reading_stores_it(logged_in, repo):
response = logged_in.post("/brews", data={
"beer_run_id": "5",
"beer_run_type": "Test Porter",
"Date": "2026-03-04",
"sg": "1045",
"comment": "Hops in",
}, follow_redirects=True)
assert response.status_code == 200
doc = repo.readings.find_one({"beer_run_id": "5"})
assert doc is not None
assert doc["sg"] == "1045"
assert doc["beer_type"] == "Test Porter"
assert doc["comment"] == "Hops in"
assert doc["final_reading"] == "False"
def test_recording_a_reading_twice_updates_in_place(logged_in, repo):
"""The unique key is (date, beer_run_id) — no duplicate rows."""
for sg in ("1045", "1035"):
logged_in.post("/brews", data={
"beer_run_id": "6",
"beer_run_type": "Test",
"Date": "2026-03-04",
"sg": sg,
})
count = repo.readings.count_documents({"beer_run_id": "6"})
doc = repo.readings.find_one({"beer_run_id": "6"})
assert count == 1
assert doc["sg"] == "1035"
def test_setting_a_final_reading_closes_the_run(logged_in, repo):
seed_readings(repo, [("7", "Ale", "1045", "2026-03-01", False, "")])
response = logged_in.post("/runs/7/final", data={"date": "2026-03-01"},
follow_redirects=True)
assert response.status_code == 200
doc = repo.readings.find_one({"beer_run_id": "7"})
assert doc["final_reading"] == "True"
def test_unsetting_a_final_reading_reactivates_the_run(logged_in, repo):
seed_readings(repo, [
("7", "Ale", "1045", "2026-03-01", False, ""),
("7", "Ale", "1010", "2026-03-20", True, ""),
])
logged_in.post("/runs/7/unfinal", data={"date": "2026-03-20"}, follow_redirects=True)
doc = repo.readings.find_one({"beer_run_id": "7", "date": "2026-03-20"})
assert doc["final_reading"] == "False"
# -------------------------------------------------------------------- API #
# ----------------------------------------------------------------- misc #
def test_the_chart_library_is_loaded_exactly_once(logged_in):
"""Regression: the chart script was included both in the base template
and again by each page, so every chart was drawn twice."""
for path in ("/brews", "/active", "/history"):
body = logged_in.get(path).data.decode()
assert body.count("js/charts.js") == 1, path
assert body.count("chart.umd.js") == 1, path
def test_static_assets_are_served(client):
for path in ("/static/js/app.js", "/static/css/app.css",
"/static/img/logo.svg"):
response = client.get(path)
assert response.status_code == 200, path
assert len(response.data) > 100, path
def test_logout_clears_the_session(logged_in):
response = logged_in.post("/logout", follow_redirects=True)
assert response.status_code == 200
# After logging out the protected page bounces back to the login page.
response = logged_in.get("/brews")
assert response.status_code == 302
assert "/login" in response.headers["Location"]
def test_out_of_range_reading_is_saved_but_warned(logged_in, repo):
response = logged_in.post("/brews", data={
"beer_run_id": "12",
"beer_run_type": "Odd",
"Date": "2026-05-01",
"sg": "950",
}, follow_redirects=True)
assert response.status_code == 200
assert b"outside the usual hydrometer range" in response.data
# It is still stored — the warning is advisory, not a rejection.
assert repo.readings.find_one({"beer_run_id": "12"}) is not None
# ------------------------------------------------------- security: passwords #
def test_legacy_plaintext_password_is_accepted_and_migrated(repo):
"""A legacy plaintext row still logs in, and is rewritten as a hash on
the way in so plaintext credentials disappear from the database."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check("beer", "beer_pass") is True
doc = repo.users.find_one({"username": "beer"})
assert doc["password"] != "beer_pass"
assert "$" in doc["password"] # a werkzeug hash
assert "password_migrated_at" in doc
def test_a_failed_password_migration_still_lets_the_login_succeed(repo, caplog):
"""If the upgrade write fails, the login must still work — but the
failure is logged so the operator knows the password is still plaintext."""
import logging
from pymongo.errors import PyMongoError
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
def boom(*args, **kwargs):
raise PyMongoError("write failed")
original = repo.users.update_one
repo.users.update_one = boom
with caplog.at_level(logging.WARNING):
assert repo.user_check("beer", "beer_pass") is True
repo.users.update_one = original
assert any("plain text" in rec.message for rec in caplog.records)
def test_hashed_password_verifies(repo):
from mongo.repository import hash_password
repo.users.insert_one({"username": "beer", "password": hash_password("beer_pass")})
assert repo.user_check("beer", "beer_pass") is True
assert repo.user_check("beer", "wrong") is False
def test_user_check_rejects_empty_credentials(repo):
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check("", "") is False
assert repo.user_check("beer", "") is False
assert repo.user_check("", "beer_pass") is False
def test_user_check_rejects_a_nosql_operator_payload(repo):
"""A dict must never be able to act as a Mongo query operator."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
assert repo.user_check({"$ne": ""}, "anything") is False
assert repo.user_check({"$gt": ""}, {"$ne": ""}) is False
def test_run_id_cannot_be_used_as_an_operator(repo):
"""A run id is coerced to a string before it reaches a query."""
from mongo.repository import _as_id
assert _as_id({"$ne": ""}) == ""
assert _as_id(20) == "20"
assert _as_id("20") == "20"
def test_the_app_refuses_to_start_without_a_secret_key(monkeypatch):
"""A missing SECRET_KEY must stop the app, not fall back to a default."""
import importlib
import app_factory
# Reload so the guard reads the environment as it is *now*. The exception
# class must be taken from the reloaded module — reloading rebinds it, so
# a class imported earlier would be a different object and pytest.raises
# would not match it.
monkeypatch.delenv("SECRET_KEY", raising=False)
importlib.reload(app_factory)
with pytest.raises(app_factory.ImproperlyConfigured):
app_factory.create_app()
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
importlib.reload(app_factory)
def test_the_app_refuses_the_placeholder_secret_key(monkeypatch):
import importlib
import app_factory
monkeypatch.setenv("SECRET_KEY", "change-me-in-the-deployment")
importlib.reload(app_factory)
with pytest.raises(app_factory.ImproperlyConfigured):
app_factory.create_app()
monkeypatch.setenv("SECRET_KEY", "test-secret-key-not-a-real-secret")
importlib.reload(app_factory)
# ------------------------------------------------- active beers and metadata #
def test_active_page_shows_only_unfinished_runs(logged_in, repo):
seed_readings(repo, [
("1", "Finished Beer", "1050", "2026-01-01", False, ""),
("1", "Finished Beer", "1010", "2026-01-20", True, ""),
("2", "Still Brewing", "1045", "2026-02-01", False, ""),
])
response = logged_in.get("/active")
assert response.status_code == 200
body = response.data.decode()
assert "Still Brewing" in body
assert "Finished Beer" not in body
def test_active_page_shows_the_tap_number(logged_in, repo):
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
repo.upsert_run_metadata("2", tap_number=4)
body = logged_in.get("/active").data.decode()
assert "Tap 4" in body
def test_active_page_shows_the_projected_alcohol(logged_in, repo):
"""With two readings the first is the OG, so the estimate is meaningful."""
seed_readings(repo, [
("2", "Still Brewing", "1050", "2026-02-01", False, ""),
("2", "Still Brewing", "1020", "2026-02-10", False, ""),
])
body = logged_in.get("/active").data.decode()
# 30 point drop = 3.94%
assert "3.94%" in body
def test_active_page_shows_a_dash_when_no_og_is_known(logged_in, repo):
"""A single reading with no recorded OG has no reliable estimate."""
seed_readings(repo, [("2", "Still Brewing", "1045", "2026-02-01", False, "")])
body = logged_in.get("/active").data.decode()
assert "needs 2nd reading" in body
def test_active_page_shows_the_latest_comment(logged_in, repo):
seed_readings(repo, [
("2", "Still Brewing", "1050", "2026-02-01", False, "Hops in"),
("2", "Still Brewing", "1030", "2026-02-10", False, "Almost there"),
])
body = logged_in.get("/active").data.decode()
assert "Almost there" in body
def test_run_metadata_is_saved_and_surfaced(logged_in, repo):
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
logged_in.post("/runs/8", data={
"name": "Canonical Name",
"tap_number": "3",
"og_points": "1050",
"notes": "Starter was a 50F mess.",
}, follow_redirects=True)
meta = repo.run_metadata("8")
assert meta["name"] == "Canonical Name"
assert meta["tap_number"] == 3
assert meta["og_points"] == 1050
assert meta["notes"] == "Starter was a 50F mess."
# The canonical name must appear on the brew-process page and the active page.
assert "Canonical Name" in logged_in.get("/brews").data.decode()
assert "Canonical Name" in logged_in.get("/active").data.decode()
def test_run_metadata_can_be_cleared(logged_in, repo):
seed_readings(repo, [("8", "Pale Ale", "1045", "2026-03-01", False, "")])
repo.upsert_run_metadata("8", name="Set Name", tap_number=2)
logged_in.post("/runs/8", data={"name": "", "tap_number": "", "og_points": "", "notes": ""},
follow_redirects=True)
meta = repo.run_metadata("8")
# The name was cleared, so it falls back to the reading-derived name.
assert not meta.get("name")
assert meta.get("tap_number") is None
def test_the_stored_name_overrides_the_drifting_reading_name(logged_in, repo):
seed_readings(repo, [
("9", "Old Name", "1050", "2026-03-01", False, ""),
("9", "Newer Name", "1040", "2026-03-10", False, ""),
])
repo.upsert_run_metadata("9", name="The Name I Want")
body = logged_in.get("/brews").data.decode()
assert "The Name I Want" in body
def test_next_free_tap_number_skips_the_ones_in_use(repo):
assert repo.next_free_tap_number() == 1
repo.upsert_run_metadata("1", tap_number=1)
assert repo.next_free_tap_number() == 2
repo.upsert_run_metadata("2", tap_number=2)
assert repo.next_free_tap_number() == 3
# A gap is filled before extending the range.
repo.upsert_run_metadata("3", tap_number=4)
assert repo.next_free_tap_number() == 3
def test_history_page_shows_completed_brews(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, "done"),
])
response = logged_in.get("/history")
assert response.status_code == 200
body = response.data.decode()
assert "Pale Ale" in body
assert "5.25%" in body # standard ABV for a 40 point drop
assert "Legacy" in body # the comparison table is present
def test_history_page_flags_an_implausible_reading(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "900", "2026-01-20", True, "typo?"),
])
body = logged_in.get("/history").data.decode()
assert "worth a second look" in body
assert "typo?" in body
def test_partial_metadata_update_leaves_other_fields_alone(repo):
"""The backfill uses this: it must not wipe a tap number the operator
has already set."""
repo.upsert_run_metadata("1", name="A Name", tap_number=3, notes="Some notes")
repo.upsert_run_metadata_partial("1", name="A Better Name")
meta = repo.run_metadata("1")
assert meta["name"] == "A Better Name"
assert meta["tap_number"] == 3
assert meta["notes"] == "Some notes"
def test_partial_metadata_update_ignores_none(repo):
repo.upsert_run_metadata("1", name="A Name", tap_number=3)
repo.upsert_run_metadata_partial("1", name=None, tap_number=None)
meta = repo.run_metadata("1")
assert meta["name"] == "A Name"
assert meta["tap_number"] == 3
# ---------------------------------------------------------- security headers #
def test_security_headers_are_set(logged_in):
"""The app serves only its own vendored assets, so a strict CSP of
'self' is enforceable."""
response = logged_in.get("/brews")
headers = response.headers
assert headers.get("X-Content-Type-Options") == "nosniff"
assert headers.get("X-Frame-Options") == "DENY"
assert headers.get("Referrer-Policy") == "same-origin"
csp = headers.get("Content-Security-Policy")
assert csp is not None
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "object-src 'none'" in csp
assert "frame-ancestors 'none'" in csp
# No CDN is used, so nothing external should be allowed.
assert "https://" not in csp
# 'unsafe-inline' would defeat the point of the policy.
assert "unsafe-inline" not in csp
assert "unsafe-eval" not in csp
def test_security_headers_are_set_on_the_login_page(client):
response = client.get("/login")
assert response.headers.get("X-Content-Type-Options") == "nosniff"
assert response.headers.get("Content-Security-Policy") is not None
# ------------------------------------------------------------------ indexes #
def test_the_repository_creates_its_indexes(repo):
"""The run metadata is always looked up by beer_run_id, and the readings
by (beer_run_id, date) — both need an index."""
index_names = repo.run_meta.index_information()
assert "beer_run_id_1" in index_names
reading_indexes = repo.readings.index_information()
assert "run_date_1" in reading_indexes
def test_no_template_has_an_inline_script_or_style(client):
"""The strict CSP (script-src 'self'; style-src 'self') blocks inline
scripts and style attributes, so the templates must have neither."""
import glob
import os
import re
template_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"src", "flask", "templates",
)
for path in glob.glob(os.path.join(template_dir, "*.html")):
with open(path) as fh:
body = fh.read()
# An inline <script> is one with content rather than a src attribute.
for match in re.finditer(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>",
body, re.S):
assert match.group(1).strip() == "", (
f"{os.path.basename(path)} has an inline <script>, which the "
"CSP blocks"
)
assert not re.search(r"\sstyle\s*=", body), (
f"{os.path.basename(path)} has an inline style attribute, which "
"the CSP blocks"
)
# -------------------------------------------------------------------- API #
def test_api_runs_returns_json(logged_in, repo):
seed_readings(repo, [
("1", "Pale Ale", "1050", "2026-01-01", False, ""),
("1", "Pale Ale", "1010", "2026-01-20", True, ""),
])
response = logged_in.get("/api/runs")
assert response.status_code == 200
payload = response.get_json()
assert len(payload) == 1
assert payload[0]["abv"] == 5.25
assert payload[0]["active"] is False
def test_api_run_detail_returns_json(logged_in, repo):
seed_readings(repo, [("11", "Cider", "1045", "2026-04-01", False, "first")])
response = logged_in.get("/api/runs/11")
assert response.status_code == 200
payload = response.get_json()
assert payload["beer_run_id"] == "11"
assert payload["name"] == "Cider"
assert len(payload["readings"]) == 1
def test_api_run_detail_404s_for_an_unknown_run(logged_in):
response = logged_in.get("/api/runs/does-not-exist")
assert response.status_code == 404
def test_the_api_is_protected(client):
"""The charts fetch from the API, so it must not be public."""
for path in ("/api/runs", "/api/runs/1"):
response = client.get(path)
assert response.status_code == 302, path
assert "/login" in response.headers["Location"], path
def test_api_runs_includes_the_canonical_name_and_tap(logged_in, repo):
seed_readings(repo, [("12", "Drifting Name", "1045", "2026-05-01", False, "")])
repo.upsert_run_metadata("12", name="Canonical", tap_number=7)
payload = logged_in.get("/api/runs").get_json()
entry = next(r for r in payload if r["beer_run_id"] == "12")
assert entry["canonical_name"] == "Canonical"
assert entry["tap_number"] == 7
def test_the_months_chart_includes_the_gaps(logged_in, repo):
"""Months with no brews must appear with a zero, or the chart spaces the
busy months evenly and reads as continuous activity."""
seed_readings(repo, [
("1", "Spring", "1050", "2026-01-05", False, ""),
("1", "Spring", "1010", "2026-01-20", True, ""),
("2", "Autumn", "1050", "2026-06-05", False, ""),
("2", "Autumn", "1010", "2026-06-20", True, ""),
])
from views.analysis import _month_range
months = _month_range("2026-01", "2026-06")
assert months == ["2026-01", "2026-02", "2026-03", "2026-04",
"2026-05", "2026-06"]
def test_the_months_chart_counts_zero_for_a_quiet_month(logged_in, repo):
"""The chart is fed by the API, so the quiet months are visible there."""
seed_readings(repo, [
("1", "Spring", "1050", "2026-01-05", False, ""),
("1", "Spring", "1010", "2026-01-20", True, ""),
("2", "Autumn", "1050", "2026-06-05", False, ""),
("2", "Autumn", "1010", "2026-06-20", True, ""),
])
import json
import re
body = logged_in.get("/history").data.decode()
# The page embeds the month labels for the chart.
assert "2026-06" in body
def test_month_range_covers_a_year_boundary():
from views.analysis import _month_range
assert _month_range("2025-11", "2026-02") == [
"2025-11", "2025-12", "2026-01", "2026-02",
]
assert _month_range("2026-05", "2026-05") == ["2026-05"]
+372
View File
@@ -0,0 +1,372 @@
"""Unit tests for the pure brewing logic in ``brewing.py``.
These are the tests that matter most: they pin the gravity parsing, the ABV
formula and the run-aggregation rules that the whole UI is built on.
"""
from __future__ import annotations
from datetime import date
import pytest
from brewing import (ABV_FACTOR, Reading, Run, abv_percent, assumed_og_abv_percent,
days_between, final_reading, first_reading,
is_plausible_sg, is_active, last_reading,
legacy_abv_percent, parse_date, parse_sg_points,
run_summary, sg_points_to_decimal)
# --------------------------------------------------------------- parsing #
@pytest.mark.parametrize("raw,expected", [
("1039", 1039),
(1039, 1039),
(1039.0, 1039),
(" 1045 ", 1045),
("1.039", 1039),
(1.045, 1045),
("None", None),
("", None),
(None, None),
("abc", None),
(True, None),
])
def test_parse_sg_points(raw, expected):
assert parse_sg_points(raw) == expected
def test_sg_points_to_decimal():
assert sg_points_to_decimal(1039) == 1.039
assert sg_points_to_decimal(1012) == 1.012
assert sg_points_to_decimal(None) is None
@pytest.mark.parametrize("raw,expected", [
("2026-11-08", date(2026, 11, 8)),
(date(2026, 11, 8), date(2026, 11, 8)),
("08/11/2026", date(2026, 11, 8)),
("", None),
(None, None),
("not a date", None),
])
def test_parse_date(raw, expected):
assert parse_date(raw) == expected
# ------------------------------------------------------------------- ABV #
def test_abv_uses_the_standard_formula():
"""ABV% = (OG - FG) * 131.25, i.e. points drop * 0.13125."""
# 1.050 -> 1.010 is a 40 point drop = 5.25% ABV.
assert abv_percent(40) == 5.25
assert abv_percent(40) == pytest.approx(40 * ABV_FACTOR)
assert abv_percent(0) == 0.0
assert abv_percent(None) is None
def test_abv_matches_worked_examples():
# Session IPA: 1.050 -> 1.010 = 5.25%
assert abv_percent(50 - 10) == 5.25
# 1.060 -> 1.012 = 6.29%
assert abv_percent(60 - 12) == pytest.approx(6.29, abs=0.01)
def test_legacy_formula_is_kept_for_comparison():
"""The old formula is preserved verbatim so history can show the delta."""
assert legacy_abv_percent(1050, 1010) == round(((1050 - 1010) / 7.36) + 0.5, 2)
# The legacy formula claims 0.5% even with no drop at all.
assert legacy_abv_percent(1010, 1010) == 0.5
assert legacy_abv_percent(None, 1010) is None
def test_legacy_formula_overstates_the_real_figure():
"""The reason the history page compares the two at all."""
standard = abv_percent(50 - 10)
legacy = legacy_abv_percent(1050, 1010)
assert legacy > standard
def test_assumed_og_abv():
assert assumed_og_abv_percent(1012) == pytest.approx(0.0)
assert assumed_og_abv_percent(1010) == pytest.approx(0.26)
# --------------------------------------------------------------- plausibility #
@pytest.mark.parametrize("points,plausible", [
(1010, True),
(990, True),
(1200, True),
(989, False),
(1201, False),
(900, False),
(None, False),
])
def test_is_plausible_sg(points, plausible):
assert is_plausible_sg(points) is plausible
# --------------------------------------------------------------- readings #
def _doc(run_id="1", beer_type="Pale Ale", sg="1045", date="2026-01-01",
final="False", comment=""):
return {
"_id": f"id-{run_id}-{date}-{sg}",
"beer_run_id": run_id,
"beer_type": beer_type,
"sg": sg,
"date": date,
"final_reading": final,
"comment": comment,
}
def test_reading_from_doc_accepts_string_and_bool_flags():
assert Reading.from_doc(_doc(final="True")).final_reading is True
assert Reading.from_doc(_doc(final="False")).final_reading is False
assert Reading.from_doc(_doc(final=True)).final_reading is True
assert Reading.from_doc(_doc(final=False)).final_reading is False
def test_reading_from_doc_cleans_whitespace():
reading = Reading.from_doc(_doc(beer_type=" Pale Ale ", comment=" hops in "))
assert reading.beer_type == "Pale Ale"
assert reading.comment == "hops in"
def test_first_and_last_reading():
readings = [
Reading.from_doc(_doc(date="2026-03-01", sg="1050")),
Reading.from_doc(_doc(date="2026-01-01", sg="1060")),
Reading.from_doc(_doc(date="2026-02-01", sg="1040")),
]
assert first_reading(readings).date == date(2026, 1, 1)
assert last_reading(readings).date == date(2026, 3, 1)
def test_days_between():
assert days_between(date(2026, 1, 1), date(2026, 1, 8)) == 7
# ------------------------------------------------------------------- runs #
def test_run_orders_readings_chronologically():
run = Run.from_docs("1", [
_doc(date="2026-03-01", sg="1010"),
_doc(date="2026-01-01", sg="1060"),
])
assert [r.date for r in run.readings] == [date(2026, 1, 1), date(2026, 3, 1)]
def test_run_original_and_final_gravity():
run = Run.from_docs("1", [
_doc(date="2026-01-01", sg="1050"),
_doc(date="2026-01-10", sg="1030"),
_doc(date="2026-01-20", sg="1010", final="True"),
])
assert run.original_gravity() == 1050
assert run.final_gravity() == 1010
assert run.gravity_drop_points() == 40
assert run.abv_percent() == 5.25
def test_run_is_active_until_a_final_reading_exists():
active = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
assert active.is_active() is True
assert is_active(active.readings) is True
finished = Run.from_docs("2", [
_doc(run_id="2", date="2026-01-01", sg="1050"),
_doc(run_id="2", date="2026-01-20", sg="1010", final="True"),
])
assert finished.is_active() is False
assert final_reading(finished.readings) is not None
def test_run_display_name_prefers_the_name_on_the_final_reading():
"""The stored beer_type drifts across readings. The name on the final
reading is the one the operator settled on."""
run = Run.from_docs("4", [
_doc(date="2024-11-23", beer_type="Necatron-Christmas Beer"),
_doc(date="2024-11-28", beer_type="Nocturna the Christmas beer"),
_doc(date="2024-11-30", beer_type="Nocturna Christmas beer", final="True"),
])
assert run.display_name() == "Nocturna Christmas beer"
def test_run_display_name_falls_back_to_the_most_used_label():
"""Without a final reading, the most-used name wins, breaking ties
towards the most recent."""
run = Run.from_docs("5", [
_doc(date="2024-12-03", beer_type="Going Nowhere Fast"),
_doc(date="2024-12-08", beer_type="Going Nowhere fast", final="True"),
])
# The final reading's name wins.
assert run.display_name() == "Going Nowhere fast"
run2 = Run.from_docs("6", [
_doc(date="2024-12-03", beer_type="Balter"),
_doc(date="2024-12-06", beer_type="Pale ale blayer"),
_doc(date="2024-12-10", beer_type="Balter"),
])
# No final reading; "Balter" was used twice.
assert run2.display_name() == "Balter"
def test_run_display_name_falls_back_to_the_run_number():
run = Run.from_docs("7", [_doc(run_id="7", beer_type="", date="2026-01-01")])
assert run.display_name() == "Run 7"
def test_run_comments_are_collected_oldest_first():
run = Run.from_docs("1", [
_doc(date="2026-01-01", comment="first"),
_doc(date="2026-01-10", comment=""),
_doc(date="2026-01-20", comment="last"),
])
assert run.comments() == ["first", "last"]
def test_run_outliers_are_flagged():
run = Run.from_docs("1", [
_doc(date="2026-01-01", sg="1045"),
_doc(date="2026-01-10", sg="900"), # implausible
_doc(date="2026-01-20", sg="1010"),
])
assert [r.sg_points for r in run.outlier_readings()] == [900]
def test_run_days_in_fermenter_for_a_finished_run():
run = Run.from_docs("1", [
_doc(date="2026-01-01", sg="1050"),
_doc(date="2026-01-15", sg="1010", final="True"),
])
assert run.days_in_fermenter() == 14
def test_run_days_in_fermenter_for_an_active_run_uses_today():
run = Run.from_docs("1", [_doc(date="2026-01-01", sg="1050")])
assert run.days_in_fermenter(today=date(2026, 1, 11)) == 10
def test_projected_abv_is_never_negative():
"""Regression: the legacy assumed-OG of 1012 produced negative alcohol
whenever the current reading was above 1012 (a normal OG)."""
run = Run.from_docs("20", [
_doc(run_id="20", beer_type="Out on patio", date="2026-11-08", sg="1039"),
])
projected = run.projected_abv_percent()
# No OG is known for a single-reading run, so there is no estimate —
# and certainly no negative one.
assert projected is None
run2 = Run.from_docs("20b", [
_doc(run_id="20b", date="2026-11-08", sg="1039"),
_doc(run_id="20b", date="2026-11-15", sg="1020"),
])
assert run2.projected_abv_percent() >= 0.0
def test_projected_abv_uses_the_first_reading_as_the_og():
run = Run.from_docs("21", [
_doc(run_id="21", date="2026-11-08", sg="1045"),
_doc(run_id="21", date="2026-11-15", sg="1030"),
])
# 15 point drop = 1.97%
assert run.projected_abv_percent() == pytest.approx(1.97)
def test_projected_abv_is_unknown_for_a_single_reading_with_no_og():
"""One reading and no recorded OG: there is no reliable estimate, so the
figure is None (the UI shows a dash) rather than a misleading number."""
run = Run.from_docs("22", [_doc(run_id="22", date="2026-11-08", sg="1005")])
assert run.projected_abv_percent() is None
def test_projected_abv_uses_a_recorded_og_override():
"""A run with a single reading can still be estimated once an OG has
been recorded against it."""
run = Run.from_docs("23", [_doc(run_id="23", date="2026-11-08", sg="1010")])
assert run.projected_abv_percent(og_override=1045) == pytest.approx(4.59)
def test_projected_abv_is_none_when_gravity_rises():
"""Gravity rising during fermentation is an anomaly, not alcohol."""
run = Run.from_docs("24", [
_doc(run_id="24", date="2026-11-08", sg="1010"),
_doc(run_id="24", date="2026-11-15", sg="1015"),
])
assert run.projected_abv_percent() is None
def test_run_summary_shape():
run = Run.from_docs("9", [
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-01", sg="1050"),
_doc(run_id="9", beer_type="Pale Ale", date="2026-01-20", sg="1010",
final="True", comment="Keghing"),
])
summary = run_summary(run)
assert summary["beer_run_id"] == "9"
assert summary["name"] == "Pale Ale"
assert summary["active"] is False
assert summary["abv"] == 5.25
assert summary["legacy_abv"] == legacy_abv_percent(1050, 1010)
assert summary["og_points"] == 1050
assert summary["fg_points"] == 1010
assert summary["comments"] == ["Keghing"]
assert len(summary["readings"]) == 2
assert summary["readings"][0]["date"] == "2026-01-01"
def test_run_summary_handles_an_empty_run():
summary = run_summary(Run.from_docs("99", []))
assert summary["abv"] is None
assert summary["reading_count"] == 0
assert summary["name"] == "Run 99"
def test_point_scale_is_the_digits_after_the_decimal():
"""In brewing convention 1.039 is 39 points. The stored 1039 is the full
reading and must not be labelled as a point count."""
run = Run.from_docs("1", [
_doc(date="2026-01-01", sg="1039"),
])
summary = run_summary(run)
assert summary["min_sg_decimal"] == "1.039"
assert summary["min_sg_point_scale"] == "39"
def test_point_scale_for_a_round_reading():
run = Run.from_docs("2", [_doc(date="2026-01-01", sg="1010")])
summary = run_summary(run)
assert summary["min_sg_point_scale"] == "10"
assert summary["min_sg_decimal"] == "1.010"
def test_an_unfinished_run_has_no_measured_abv():
"""A run still in the fermenter has a projection, not a measurement.
Reporting a "measured" ABV for it would be wrong."""
run = Run.from_docs("20", [
_doc(run_id="20", date="2026-11-01", sg="1045"),
_doc(run_id="20", date="2026-11-08", sg="1039"),
])
assert run.is_active() is True
assert run.abv_percent() is None
assert run.projected_abv_percent() is not None
summary = run_summary(run)
assert summary["abv"] is None
assert summary["projected_abv"] is not None
assert summary["legacy_abv"] is None
def test_a_finished_run_still_has_a_measured_abv():
run = Run.from_docs("21", [
_doc(run_id="21", date="2026-11-01", sg="1050"),
_doc(run_id="21", date="2026-11-08", sg="1010", final="True"),
])
assert run.abv_percent() == 5.25
summary = run_summary(run)
assert summary["abv"] == 5.25
assert summary["legacy_abv"] is not None