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(-)
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:
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,
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>
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 %}
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>).
--- 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); } }