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