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.
This commit is contained in:
2026-11-09 10:21:44 +10:00
parent fb65ba4ee0
commit 24cdd452c5
37 changed files with 2943 additions and 725 deletions
+28 -135
View File
@@ -1,140 +1,33 @@
from bokeh.core.enums import SpatialUnitsType
import mongo.build_db as beer_database
import mongo.query_db as beer_database_query
from table import table_builder
"""Entry point for the beer_data web application.
from flask import Flask, render_template, request, jsonify, redirect, session
from flask_wtf import FlaskForm, CSRFProtect
from flask_bootstrap import Bootstrap5
from wtforms import StringField, SubmitField, DateField, IntegerField, PasswordField, DecimalField, RadioField, TextAreaField, BooleanField
from wtforms.validators import DataRequired, Length, Optional
from waitress import serve
from bokeh.models.layouts import HBox
from bokeh.plotting import column
from charts import BeerCharts
from bokeh.io import output_file, show
from bokeh.layouts import row
Run in development:
python -m beer_data
Run in production (what the container does):
waitress-serve --listen=0.0.0.0:5000 beer_data:app
"""
from __future__ import annotations
import os
from app_factory import create_app
app = create_app()
# The WSGI callable used by waitress / gunicorn.
application = app
app = Flask(__name__)
app.secret_key = 'testsecret' #this value will change
bootstrap = Bootstrap5(app)
csrf = CSRFProtect(app)
# used to configure the bokeh plot for graphs
output_file("/beer_data/src/flask/static/data_plot.html")
def create_graphs():
chart = BeerCharts.BeerCharts()
sg = chart.line_chart("SG", "sg", 30)
show(column(sg))
def data_frame_to_table(df) -> str:
tr_replace_string = '<tr align="center" style="border-bottom:1pt solid black;">'
return_html = df.to_html(col_space='75px', index=False,
justify='center', border=3).replace('<tr>', tr_replace_string)
return return_html
class userForm(FlaskForm):
username = StringField("User Name?", validators=[DataRequired()])
password = PasswordField("Password?")
submit = SubmitField("Letsa GO!")
class dataForm(FlaskForm):
beer_run_id = IntegerField("Beer Run ID")
beer_run_type = TextAreaField("Beer Type")
Date = DateField("Date:")
sg = IntegerField("SG Reading")
final_run = BooleanField("Final Reading?")
comment = TextAreaField("Any Comments?", validators=[Optional()])
submit = SubmitField("Write it, Write it REAAAAAAL GOOOD")
@app.route("/", methods=["GET","POST"])
def index():
form = userForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
db = beer_database_query.pool_query()
if db.user_check(username, password):
session['logged_in'] = True
return redirect("/updater")
else:
return render_template("index.html", try_again=True, form=form)
else:
return render_template("index.html", try_again=False, form=form)
@app.route("/updater", methods=["GET", "POST"])
def updater():
if 'logged_in' not in session:
return redirect("/")
table_Data = table_builder.TableBuilder()
predicted_alc_table = table_Data.done_runs_build()
current_runs_table = table_Data.current_runs_build()
beer_html = data_frame_to_table(predicted_alc_table)
current_html = data_frame_to_table(current_runs_table)
query_db = beer_database_query.pool_query()
query = query_db.get_top(10, "sg")
form = dataForm()
def main() -> None:
"""Run the development server."""
host = os.getenv("HOST", "0.0.0.0")
port = int(os.getenv("PORT", "5000"))
debug = os.getenv("FLASK_DEBUG", "0") == "1"
app.run(host=host, port=port, debug=debug)
if form.validate_on_submit():
database = beer_database.beer_data()
new_record = {
"date": f'{form.Date.data}',
"beer_run_id": f'{form.beer_run_id.data}',
"beer_type": f'{form.beer_run_type.data}',
"sg" : f'{form.sg.data}',
"final_reading" : F'{form.final_run.data}',
"comment": f'{form.comment.data}'
}
if database.record_exists(new_record["date"], new_record["beer_run_id"]):
for field in database.existing_record:
for new_field in new_record:
if field == new_field:
if database.existing_record[field] != new_record[field]:
database.update_re_record(database.existing_record["_id"], field, new_record[field])
else:
if new_record["final_reading"] == "True":
final_run_value = True
else:
final_run_value = False
database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"],
new_record["date"], final_run_value, new_record["comment"])
return render_template("updater.html", beer_data = beer_html, current_data = current_html
, list=query, form=form, success=True, updater_name = "Saucy Beer Maker")
else:
return render_template("updater.html", beer_data = beer_html, current_data = current_html
, list=query, form=form, sucess=False)
# @app.route("/update_db", methods=["POST"])
# def beer_data_update():
# database = beer_database.beer_data()
# new_record = request.json
# if database.record_exists(new_record["date"], new_record["test_user"]):
# for field in database.existing_record:
# for new_field in new_record:
# if field == new_field:
# if database.existing_record[field] != new_record[field]:
# database.update_re_record(database.existing_record["_id"], field, new_record[field])
# else:
# database.create_re_record(new_record["beer_run_id"], new_record["beer_type"], new_record["sg"],
# new_record["date"], new_record["comment"])
# @app.route("/pool_top/<int:return_number>/<string:field>")
# def user_detail(id):
# query_db = beer_database_query.pool_query()
# query = query_db.get_top(return_number, field)
# return jsonify([row.to_json() for row in query])
if __name__ == '__main__':
#app.run(host='0.0.0.0')
serve(app, host='0.0.0.0', port=5000, url_scheme='https')
if __name__ == "__main__":
main()