[PATCH coffee-flask v3 0/6] Event logging

Tak ještě jedna verze. Upravil jsem ten main.js a ještě drobně změnil grafiku. Pokud nikdo nebudete nic namítat, tak bych to tam zítra (až budu fyzicky přítomen) zkusil nahodit. Michal Sojka (6): Add humanize filter for Jinja2 templates Extend database schema for event logging Add event logging Move Events block before user rename/id block Fix most of flake8 warnings js: Make the source of error messages more evident app.py | 76 +++++++++++++++++++++++++++++++---- coffee_db.py | 49 ++++++++++++++++++++--- coffee_db.sql | 22 ++++++++++ templates/main.js | 25 +++++++++++- templates/user.html | 98 ++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 249 insertions(+), 21 deletions(-) -- 2.28.0.rc2

This filter converts date/time values into relative time strings such as "an hour ago". It will be used in later commits. --- app.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index b4d7e41..0e0564a 100644 --- a/app.py +++ b/app.py @@ -11,10 +11,11 @@ from io import BytesIO import coffee_db as db import time import sys -from datetime import date, timedelta +from datetime import date, timedelta, datetime, timezone from json import loads from requests import post +import jinja2 db.init_db() app = Flask(__name__) @@ -22,6 +23,53 @@ CORS(app, supports_credentials=True) app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' +# Inspired by https://shubhamjain.co/til/how-to-render-human-readable-time-in-jinja/, +# updated to our needs +def humanize_ts(time): + """ + Convert date in ISO format to relative, human readable string + like 'an hour ago', 'Yesterday', '3 months ago', + 'just now', etc + """ + if jinja2.is_undefined(time): + return time + now = datetime.now(timezone.utc) + if time[-1] == 'Z': # Convert Zulu time zone to datetime compatible format + time = time[0:-1] + '+00:00' + diff = now - datetime.fromisoformat(time) + second_diff = diff.seconds + day_diff = diff.days + + if day_diff < 0: + return '' + + if day_diff == 0: + if second_diff < 10: + return "just now" + if second_diff < 60: + return str(int(second_diff)) + " seconds ago" + if second_diff < 120: + return "a minute ago" + if second_diff < 3600: + return str(int(second_diff / 60)) + " minutes ago" + if second_diff < 7200: + return "an hour ago" + if second_diff < 86400: + return str(int(second_diff / 3600)) + " hours ago" + if day_diff == 1: + return "Yesterday" + if day_diff < 7: + return str(day_diff) + " days ago" + if day_diff < 31: + return str(int(day_diff / 7)) + " weeks ago" + if day_diff < 365: + return str(int(day_diff / 30)) + " months ago" + return str(int(day_diff / 365)) + " years ago" + + +app.jinja_env.filters['humanize'] = humanize_ts + + @app.route('/') def hello(): if "uid" in session: -- 2.28.0.rc2

Actual event logging will be implemented in the next commits. --- coffee_db.sql | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/coffee_db.sql b/coffee_db.sql index 51f83aa..d9ff410 100644 --- a/coffee_db.sql +++ b/coffee_db.sql @@ -38,6 +38,28 @@ insert or ignore into days values (0),(1),(2),(3),(4),(5),(6) ; +create table if not exists event_types ( + id integer primary key, + name varchar(255) not null +); + +insert or ignore into event_types values + (0, 'COFFEE_PACK_OPENED'), + (1, 'LAST_COFFEE_PACK_OPENED'), -- prepared for later use + (2, 'COFFEE_MACHINE_CLEANED'), + (3, 'MILK_CONTAINER_CLEANED'), + (4, 'MILK_CONTAINER_CLEANED_WITH_TABLET') -- cleaning with tablets implies cleaning of the container +; + +create table if not exists events ( + id integer primary key, + event_type integer not null, + user_id varchar(24) references users(id), + time datetime default current_timestamp not null, + foreign key(event_type) references event_types(id) +); +create index if not exists idx_events on events (event_type, time); + CREATE TABLE if not exists identifiers ( `userid` varchar ( 24 ) NOT NULL, `id` varchar ( 24 ) PRIMARY KEY NOT NULL, -- 2.28.0.rc2

This allows users to log various events such as coffee machine or milk container cleaning. Also, all users can see when was the last occurrence of the particular event. --- app.py | 15 +++++++-- coffee_db.py | 21 +++++++++++++ templates/main.js | 23 +++++++++++++- templates/user.html | 76 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 0e0564a..14a2684 100644 --- a/app.py +++ b/app.py @@ -115,11 +115,14 @@ def user(): counts=counts, identifiers=db.list_user_identifiers(uid), iid=session["iid"], - stamp=time.time() + stamp=time.time(), + last_events=db.last_events() ) # TODO: Replace stamp parameter with proper cache control HTTP # headers in response - return render_template('user.html', stamp=time.time()) + return render_template('user.html', stamp=time.time(), + last_events=db.last_events(), + ) @app.route('/user/rename') @@ -265,6 +268,14 @@ def coffee_add(): return redirect(url_for('user')) +@app.route("/event", methods=["POST"]) +def event_add(): + json = request.json + print("User '%(uid)s' registered event %(event_name)s at %(time)s" % json) + db.add_event(json["uid"], json["event_name"], json["time"]) + return redirect(url_for('user')) + + # TODO: Remove me - unused @app.route("/coffee/count") def coffee_count(): diff --git a/coffee_db.py b/coffee_db.py index 25551f9..bc53024 100644 --- a/coffee_db.py +++ b/coffee_db.py @@ -99,6 +99,15 @@ def add_coffee(uid, flavor, time=None): c.execute("insert or ignore into coffees (id, flavor, time) values (?,?,?)", (uid, flavor, time)) close_db(conn) + +def add_event(uid, event_name, time): + conn, c = open_db() + c.execute("""insert into events (user_id, event_type, time) + values (?, (SELECT id FROM event_types WHERE name = ?), ?)""", + (uid, event_name, time)) + close_db(conn) + + def flavors(): conn, c = open_db() res = list(c.execute("select distinct name, ord from flavors")) @@ -193,3 +202,15 @@ def drink_count(uid=None, start=None, stop=None): "left join identifiers ids on co.id = ids.id where " + " and ".join(clauses) + " group by fl.type " "order by fl.ord asc", args)) + + +def last_events(): + """Return mapping with event names as keys and SQLite time string of + the last event as values. + """ + conn, c = open_db() + res = dict(c.execute("""select name, MAX(time) + from events as e left join event_types as et on e.event_type = et.id + group by name""")) + close_db(conn) + return res diff --git a/templates/main.js b/templates/main.js index 417dc40..67e6db7 100644 --- a/templates/main.js +++ b/templates/main.js @@ -8,6 +8,7 @@ var logoutTimer; var reloadTimer = undefined; var id_user; // ID of the user who is to be accounted for the next coffee var identifier_registration = false; // true if identifier is supposed to be registered for user +var eventMsg = undefined; // Feedback message about the last event performed by the user console.log("hello from flask"); //sendJSON("{\"type\":\"empty\"}"); @@ -46,9 +47,13 @@ function updateUI() return; } + if (eventMsg !== undefined) { + update("eventMsg", eventMsg); + eventMsg = undefined; + } if (id_user !== undefined) { document.getElementById("nextStep").innerHTML = "Now select a beverage on the coffee machine…"; - } else { + } else if (flavorChosen !== undefined) { document.getElementById("nextStep").innerHTML = "Enjoy your " + flavorChosen + "!"; } @@ -171,6 +176,7 @@ function logout() { id_user = undefined; timeToLogout = undefined; identifier_registration = false; + window.scrollTo(0, 0); // Scroll up } function countingTimeLogout(count_time) @@ -212,6 +218,21 @@ function addCoffee(flavor, time = new Date()) { } } + +function addEvent(event_name, action_msg, time = new Date()) { + var data = JSON.stringify({ + time: time.toISOString(), + event_name: event_name, + uid: id_user + }); + if (id_user) { + eventMsg = "You have " + action_msg + ". Thanks!" + ajax("POST", "event", data, "user"); + window.scrollTo(0, 0); // Scroll up + } +} + + function addIdentifier_start() { identifier_registration = true; document.getElementById("addIdentifier").disabled = true; diff --git a/templates/user.html b/templates/user.html index 5ad7b90..0a8597b 100644 --- a/templates/user.html +++ b/templates/user.html @@ -1,3 +1,36 @@ +<style> +.events { + margin: 0.8em; + margin-bottom: 1.5em; + padding: 2px; + border-spacing: 2em 0; +} + +.events h3 { + display: inline-block; + margin-top: 1ex; + margin-right: 0.7em; +} + +.events-box { + border: 1px solid black; + padding: 0.5em; + display: inline-block; + vertical-align: top; +} + +.events-box h4 { + margin: 0.5ex; +} +.events-box .btnline { + text-align: left; +} +.events-box input { + padding: 1.5ex; + margin: 0.5ex; +} +</style> + {% if name %} <form style="position: absolute; right: 15%; width: 15%; height: 15%;"> <button type="button" id="logout_button" onclick="logout()" style="width: 100%; height: 100%;">logout</button> @@ -5,6 +38,7 @@ <h1>Hello, {{ name }}!</h1> + <p id="eventMsg"></p> <p id="nextStep"></p> {% if counts|length > 0 %} @@ -77,3 +111,45 @@ <img src="{{ url_for('coffee_graph_history', _external=True, stamp=stamp) }}"> <img src="{{ url_for('coffee_graph_flavors', _external=True, stamp=stamp, days=7) }}"> {% endif %} + + <br /> + <form> + {%- macro event_box(title, events) -%} + <div class="events-box"> + <h4>{{title | capitalize}}</h4> + {#- The first item in the list is used as button label, last item in the overview -#} + {%- set verb = { + "COFFEE_MACHINE_CLEANED": ["cleaned"], + "COFFEE_PACK_OPENED": ["opened"], + "LAST_COFFEE_PACK_OPENED": ["last opened"], + "MILK_CONTAINER_CLEANED": ["cleaned (water)", "cleaned"], + "MILK_CONTAINER_CLEANED_WITH_TABLET": ["cleaned (tablet)"], + } + -%} + {% if name -%} {# User logged in - show action buttons #} + {%- for event in events %} + <div class="btnline"> + <input type="button" value="{{ verb[event]|first }}" + onclick="addEvent('{{ event }}', '{{ verb[events[0]]|last }} the {{ title }}')" /> + ({{ last_events[event] | humanize if event in last_events else "never" }}) + </div> + {%- endfor -%} + {%- else -%} {# Nobody logged in - show overview with summary times #} + {# Calculate maximum timestamp of all relevant events #} + {%- set when = last_events.items() | selectattr(0, 'in', + events) | map(attribute=1) | max | humanize -%} + {%- if when -%} + {{ verb[events[0]]|last }} {{ when }} + {%- else -%} + never {{ verb[events[0]]|last }} + {%- endif -%} + {%- endif %} + </div> + {%- endmacro %} + <div class="events"> + <h3>{{ ("Record<br />event" if name else "Events") | safe }}:</h3> + {{ event_box('coffee machine', ['COFFEE_MACHINE_CLEANED'] ) }} + {{ event_box('coffee pack', ['COFFEE_PACK_OPENED'] ) }} + {{ event_box('milk container', ['MILK_CONTAINER_CLEANED', 'MILK_CONTAINER_CLEANED_WITH_TABLET'] ) }} + </div> + </form> -- 2.28.0.rc2

Events block will probably be used more often, so this will result in less scrolling. --- templates/user.html | 68 ++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/templates/user.html b/templates/user.html index 0a8597b..f878f0c 100644 --- a/templates/user.html +++ b/templates/user.html @@ -31,6 +31,9 @@ } </style> +{###############} +{# Graphs etc. #} +{###############} {% if name %} <form style="position: absolute; right: 15%; width: 15%; height: 15%;"> <button type="button" id="logout_button" onclick="logout()" style="width: 100%; height: 100%;">logout</button> @@ -77,34 +80,6 @@ </form> {% endif %} </p> - <br /> - <p> - <form> - <label for="username">Username:</label> - <input id="username" type="text" name="name"> - <input type="button" value="rename" onclick="renameUser()"> - </form> - </p> - <form> - <table style="padding: 2px"> - <tr> - <td colspan="4" align="center"><b>Identifiers:</b></td> - </tr> - {% for id in identifiers %} - <tr> - <td><input type="text" id="identifier_name_{{ loop.index }}" value="{{ id[2] }}" /></td> - <td {% if iid == id[1] %} style="font-weight: bold" {% endif %}>#<span id="identifier_{{ loop.index }}">{{ id[1] }}</span></td> - <td><input type="button" value="rename" onclick="renameIdentifier({{ loop.index }})" /></td> - <td><input type="button" value="remove" onclick="disableIdentifier('{{ id[1] }}')" /></td> - </tr> - {% endfor %} - <tr> - <td><input type="button" id="addIdentifier" value="add identifier" onclick="addIdentifier_start()" /></td> - <td colspan="2" style="visibility: hidden" id="labelIdentifier"><b>Use your identifier.</b></td> - <td><input type="button" id="abortIdentifier" value="abort" disabled onclick="addIdentifier_finish()" /></td> - </tr> - </table> - </form> {% else %} <p>Use your card/token to log in...</p> @@ -112,6 +87,9 @@ <img src="{{ url_for('coffee_graph_flavors', _external=True, stamp=stamp, days=7) }}"> {% endif %} +{##########} +{# Events #} +{##########} <br /> <form> {%- macro event_box(title, events) -%} @@ -153,3 +131,37 @@ {{ event_box('milk container', ['MILK_CONTAINER_CLEANED', 'MILK_CONTAINER_CLEANED_WITH_TABLET'] ) }} </div> </form> + +{##################################} +{# User name and chip idnetifiers #} +{##################################} +{% if name %} + <br /> + <p> + <form> + <label for="username">Username:</label> + <input id="username" type="text" name="name"> + <input type="button" value="rename" onclick="renameUser()"> + </form> + </p> + <form> + <table style="padding: 2px"> + <tr> + <td colspan="4" align="center"><b>Identifiers:</b></td> + </tr> + {% for id in identifiers %} + <tr> + <td><input type="text" id="identifier_name_{{ loop.index }}" value="{{ id[2] }}" /></td> + <td {% if iid == id[1] %} style="font-weight: bold" {% endif %}>#<span id="identifier_{{ loop.index }}">{{ id[1] }}</span></td> + <td><input type="button" value="rename" onclick="renameIdentifier({{ loop.index }})" /></td> + <td><input type="button" value="remove" onclick="disableIdentifier('{{ id[1] }}')" /></td> + </tr> + {% endfor %} + <tr> + <td><input type="button" id="addIdentifier" value="add identifier" onclick="addIdentifier_start()" /></td> + <td colspan="2" style="visibility: hidden" id="labelIdentifier"><b>Use your identifier.</b></td> + <td><input type="button" id="abortIdentifier" value="abort" disabled onclick="addIdentifier_finish()" /></td> + </tr> + </table> + </form> +{% endif %} -- 2.28.0.rc2

I deliberately ignored warnings about long lines and a few more. --- app.py | 11 ++++++----- coffee_db.py | 28 ++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app.py b/app.py index 14a2684..81bfbf1 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,7 @@ from flask import Flask, render_template, send_file, request, session, redirect, url_for, make_response from flask_cors import CORS -import numpy as np import matplotlib -matplotlib.use('Agg') import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator from io import BytesIO @@ -17,6 +15,8 @@ from json import loads from requests import post import jinja2 +matplotlib.use('Agg') + db.init_db() app = Flask(__name__) CORS(app, supports_credentials=True) @@ -158,13 +158,13 @@ def user_disable_identifier(): json = request.json if "uid" in session and "id" in json: db.disable_user_identifier(session["uid"], json["id"]) - return logout() # force logout + return logout() # force logout @app.route("/coffee/graph_flavors") def coffee_graph_flavors(): - days = request.args.get('days', default = 0, type = int) - start = request.args.get('start', default = 0, type = int) + days = request.args.get('days', default=0, type=int) + start = request.args.get('start', default=0, type=int) b = BytesIO() if "uid" in session: @@ -299,6 +299,7 @@ def log(): return data return "nope" + @app.route("/tellCoffeebot", methods=["POST"]) def tell_coffeebot(): err = "Don't worry now! There is a NEW HOPE Tonda is buying NEW PACK!" diff --git a/coffee_db.py b/coffee_db.py index bc53024..7fddd8e 100644 --- a/coffee_db.py +++ b/coffee_db.py @@ -5,26 +5,31 @@ dbdir = os.path.dirname(__file__) dbdef = os.path.join(dbdir, "coffee_db.sql") dbfile = os.path.join(dbdir, "coffee.db") + def open_db(): conn = sqlite3.connect(dbfile) c = conn.cursor() return conn, c + def close_db(conn): conn.commit() conn.close() + def init_db(): conn, c = open_db() with open(dbdef, "r") as f: c.executescript(f.read()) close_db(conn) + def add_user(uid): conn, c = open_db() c.execute("insert or ignore into users (id) values (?)", (uid,)) close_db(conn) + def add_user_identifier(uid, iid, name): # Check if this identifier is not currently associated with different account if not get_uid(iid): @@ -44,19 +49,22 @@ def add_user_identifier(uid, iid, name): close_db(conn) + def disable_user_identifier(uid, iid): conn, c = open_db() c.execute("update identifiers set active = 0 where userid = ? and id = ?", (uid, iid, )) close_db(conn) + def get_name(uid): conn, c = open_db() - for name, in c.execute("select name from users where id = ?",(uid,)): + for name, in c.execute("select name from users where id = ?", (uid,)): close_db(conn) return name close_db(conn) return None + def get_uid(iid): conn, c = open_db() res = list(c.execute(""" @@ -66,22 +74,26 @@ def get_uid(iid): return res[0][0] if len(res) > 0 else None + def name_user(uid, name): conn, c = open_db() c.execute("update users set name = ? where id = ?", (name, uid)) close_db(conn) + def rename_user_identifier(uid, iid, name): conn, c = open_db() c.execute("update identifiers set name = ? where userid = ? and id = ?", (name, uid, iid, )) close_db(conn) + def list_users(): conn, c = open_db() for row in c.execute("select * from users"): print(row) close_db(conn) + def list_user_identifiers(uid): conn, c = open_db() res = list(c.execute(""" @@ -94,7 +106,7 @@ def list_user_identifiers(uid): def add_coffee(uid, flavor, time=None): conn, c = open_db() if time is None: - c.execute("insert into coffees (id, flavor) values (?,?)", (uid,flavor)) + c.execute("insert into coffees (id, flavor) values (?,?)", (uid, flavor)) else: c.execute("insert or ignore into coffees (id, flavor, time) values (?,?,?)", (uid, flavor, time)) close_db(conn) @@ -114,8 +126,10 @@ def flavors(): close_db(conn) return res + def coffee_flavors(uid=None, days=0, start=0): - """Returns flavor statistics for team/user during selected period/since beginning. + """Returns flavor statistics for team/user during selected + period/since beginning. days -- number of days for computation start -- shift size from the current time @@ -131,7 +145,7 @@ def coffee_flavors(uid=None, days=0, start=0): variables = list() if days is not None and days != 0: - query += " where date(time) between date('now', 'localtime', '-"+ str(days+start-1) +" days') and date('now', 'localtime', '-"+ str(start) +" days')" + query += " where date(time) between date('now', 'localtime', '-" + str(days+start-1) + " days') and date('now', 'localtime', '-" + str(start) + " days')" if uid is not None: query += " and ids.userid = ? and ids.active" @@ -151,6 +165,7 @@ def coffee_flavors(uid=None, days=0, start=0): close_db(conn) return res + def coffee_history(uid=None): conn, c = open_db() @@ -170,12 +185,13 @@ def coffee_history(uid=None): left join (select date(time, 'localtime') as time,flavor from coffees co left join identifiers ids on co.id = ids.id where ids.userid = ? and ids.active) c on d = date(c.time) group by d, c.flavor - """ - , (uid,))) + """, + (uid,))) close_db(conn) return res + def drink_count(uid=None, start=None, stop=None): """Return a list of tuples ('<drink type>', <count>). -- 2.28.0.rc2

--- templates/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/main.js b/templates/main.js index 67e6db7..9add69a 100644 --- a/templates/main.js +++ b/templates/main.js @@ -61,7 +61,7 @@ function updateUI() document.getElementById("logout_button").innerHTML = '<br>logout<br>(' + timeToLogout + ' s)'; } catch (err) { - console.log("Error: ", err); + console.log("updateUI error: ", err); } } -- 2.28.0.rc2
participants (1)
-
Michal Sojka