home · contact · privacy
Add foreign key restraints, expand and fix tests, add deletion and forking.
[misc] / calories.py
index 771b11af8eab34f14e683066b1acf521e409b10e..9951ce5c98896ef91319282143b2925b657ec280 100644 (file)
-from http.server import BaseHTTPRequestHandler, HTTPServer
 import os
 import json
+import datetime
+import jinja2
+from plomlib import PlomDB, PlomException, run_server, PlomHandler 
 
-def build_page(eatable_rows, consumption_rows, eatables_selection, day_rows):
-    return """<html>
-<meta charset="UTF-8">
+db_path = '/home/plom/org/calories_db.json'
+
+server_port = 8081
+
+tmpl = """
 <style>
-table {
-  margin-bottom: 2em;
-}
-td, th {
-  text-align: right;
-}
-</style>""" + f"""
+table { margin-bottom: 2em; }
+th, td { text-align: left; }
+td.number { text-align: right; }
+input[type="number"] { text-align: right; }
+</style>
 <body>
-<form action="/" method="POST">
+<form action="{{homepage}}" method="POST">
 <td><input name="update" type="submit" value="update" /></td>
 <table>
-<tr><th>title</th><th>calories</th><th>sugar (g)</th><th>standard weight (g)</th><th>comments</th><th>delete</th></tr>
-{eatable_rows}
+<tr><th>eatable</th><th>unit count</th><th>unit weight (g)</th><th>calories</th><th>sugar (g)</th></tr>
+{% for c in consumptions %}
 <tr>
-<th>add:</th>
+<input type="hidden" name="keep_visible" value="1"><input name="eatable_key" type="hidden" value="{{c.key|e}}">
+<td class="number"><input class="unit_count number" name="unit_count" type="number" min="0" step="0.1" value="{{c.count}}" /></td>
+<td>{{c.title}}</td>
+<td></td>
+<td class="number">{{c.cals}}</td>
+<td class="number">{{c.sugar}}</td>
 </tr>
+{% endfor %}
 <tr>
-<td><input name="title" type="text" value="" /></td>
-<td><input name="cals" type="number" min="0" step="0.1" value="0" /></td>
-<td><input name="sugar_g" type="number" min="0" step="0.1" value="0" /></td>
-<td><input name="standard_g" type="number" min="1" step="0.1" value="1" /></td>
-<td><input name="comments" type="text" value="" /></td>
+<th>add from DB:</th>
+</tr>
+<tr>
+<input type="hidden" name="keep_visible" value="0">
+<td class="number"><input class="unit_count" name="unit_count" type="number" step="0.1" min="0" value="0" /></td>
+<td><select name="eatable_key">{% for sel in eatables_selection %}
+<option value="{{sel.0|e}}">{{sel.1|e}}</option>
+{% endfor %}</select></td>
+<td></td>
 </tr>
 </table>
 <table>
+<tr><th>today:</th><th></th><th></th><th>archive?</th></tr>
+<td><input name="new_date" size=8 value="{{db.today_date}}" /><td>
+<td class="number"><input name="new_day_cals" type="hidden" value="{{db.today.calories}}" readonly />{{db.today.calories}}</td>
+<td class="number"><input name="new_day_sugar" type="hidden" value="{{db.today.sugar_g}}" readonly />{{db.today.sugar_g}}</td>
+<td><input name="archive_day" type="checkbox" /></td>
+</tr>
 <tr><th>day</th><th>calories</th><th>sugar (g)</th></tr>
-{day_rows}
+{% for d in days %}
+<tr>
+<td><input name="day_date" type="hidden" value="{{d.date|e}}" />{{d.date_short|e}}</td>
+<td class="number"><input name="day_cals" type="hidden" step="0.1" min="0" value="{{d.cals}}" />{{d.cals}}</td>
+<td class="number"><input name="day_sugar" type="hidden" step="0.1" min="0" value="{{d.sugar}}" />{{d.sugar}}</td>
+</tr>
+{% endfor %}
 </table>
 <table>
-<tr><th>eatable</th><th>unit count</th><th>unit weight (g)</th><th>calories</th><th>sugar (g)</th></tr>
-{consumption_rows}
+<tr><th>title</th><th>calories</th><th>sugar (g)</th><th>standard weight (g)</th><th>comments</th><th>delete</th></tr>
+{% for e in eatables %}
 <tr>
-<th>add from DB:</th>
+<input name="eatable_uuid" type="hidden" value="{{e.uuid}}" />
+<td><input name="title" value="{{e.title|e}}" /></td>
+<td class="number"><input name="cals" type="number" step="0.1" min="0" value="{{e.cals}}" /></td>
+<td class="number"><input name="sugar_g" type="number" step="0.1" min="0" value="{{e.sugar_g}}" /></td>
+<td class="number"><input name="standard_g" type="number" step="0.1" min="0" value="{{e.sugar_g}}" /></td>
+<td><input name="comments" value="{{e.comments|e}}" /</td>
+<td><input name="delete" type="checkbox" value="{{e.uuid}}" />
 </tr>
+{% endfor %}
 <tr>
-<td><select name="eatable_key">{eatables_selection}</select></td>
-<td><input name="unit_count" type="number" step="1" min="0" value="0" /></td>
-<td></td>
+<th>add:</th>
+</tr>
+<tr>
+<td><input name="title" type="text" value="" /></td>
+<td class="number"><input name="cals" type="number" min="0" step="0.1" value="0" /></td>
+<td class="number"><input name="sugar_g" type="number" min="0" step="0.1" value="0" /></td>
+<td class="number"><input name="standard_g" type="number" min="1" step="0.1" value="1" /></td>
+<td><input name="comments" type="text" value="" /></td>
 </tr>
 </table>
 </form>
 </body>
-</html>"""
+<script>
+var unit_count_inputs = document.getElementsByClassName("unit_count");
+for (let i = 0; i < unit_count_inputs.length; i++) {
+    let input = unit_count_inputs[i];
+    let button = document.createElement('button');
+    button.innerHTML = '+1';
+    button.onclick = function(event) {
+        event.preventDefault();
+        input.value = parseFloat(input.value) + 1.0;
+    };
+    input.insertAdjacentElement('afterend', button);
+}
+
+</script>
+"""
 
-class LockFileDetected(Exception):
-    pass
 
 class Eatable:
 
-    def __init__(self, title, cals, sugar_g, standard_g=100, comments=""):
+    def __init__(self, title, cals, sugar_g, standard_g=100, comments="", popularity=0):
         self.title = title
         self.cals = cals  # per 100g
         self.sugar_g = sugar_g  # per 100g
         self.standard_g = standard_g  # common unit weight
-        self.comments = comments 
+        self.comments = comments
+        self.popularity = popularity
 
     def to_dict(self):
         return {
@@ -68,21 +117,26 @@ class Eatable:
             "cals": self.cals,
             "sugar_g": self.sugar_g,
             "standard_g": self.standard_g,
-            "comments": self.comments
+            "comments": self.comments,
+            "popularity": self.popularity
         }
 
+
 class Consumption:
 
-    def __init__(self, eatable_key, unit_count=None):
-        self.eatable_key = eatable_key 
+    def __init__(self, eatable_key, unit_count=None, keep_visible=0):
+        self.eatable_key = eatable_key
         self.unit_count = unit_count
+        self.keep_visible = keep_visible
 
     def to_dict(self):
         return {
             "eatable_key": self.eatable_key,
             "unit_count": self.unit_count,
+            "keep_visible": self.keep_visible
         }
 
+
 class Day:
 
     def __init__(self, calories, sugar_g):
@@ -95,20 +149,22 @@ class Day:
             "sugar_g": self.sugar_g,
         }
 
-class Database: 
+
+class CaloriesDB(PlomDB):
 
     def __init__(self, load_from_file=True):
-        db_name = "calories_db"
-        self.db_file = db_name + ".json"
-        self.lock_file = db_name+ ".lock"
+        self.load_from_file = load_from_file
         self.eatables = {}
         self.consumptions = []
         self.days = {}
         self.today = Day(0, 0)
         self.today_date = ""
-        if load_from_file and os.path.exists(self.db_file):
-            with open(self.db_file, "r") as f:
-                self.from_dict(json.load(f))
+        super().__init__(db_path)
+
+    def read_db_file(self, f):
+        if not self.load_from_file:
+            return
+        self.from_dict(json.load(f))
 
     def from_dict(self, d):
         self.set_today_date(d["today_date"])
@@ -133,27 +189,30 @@ class Database:
         eatable = self.eatables[c.eatable_key]
         calories = eatable.cals * c.unit_count
         sugar_g = eatable.sugar_g * c.unit_count
-        # calories = float(eatable.cals / eatable.standard_g) * c.unit_count * c.unit_weight
-        # sugar_g = float(eatable.sugar_g / eatable.standard_g) * c.unit_count * c.unit_weight
         self.today.calories += calories
         self.today.sugar_g += sugar_g
         return {"cals": calories, "sugar": sugar_g }
 
-    def eatables_selection(self, selection=None):
-        html = '' # if selection else '<option value="" />'
-        for k,v in self.eatables.items():
-            selected = ' selected' if k==selection else ''
-            html += '<option value="%s"%s>%s</option>' % (k, selected, v.title)
-        return html
+    def eatables_selection(self):
+        options = []
+        already_selected = [c.eatable_key for c in self.consumptions]
+        for k, v in sorted(self.eatables.items(), key=lambda item: item[1].title):
+            if k in already_selected:
+                continue
+            v = self.eatables[k]
+            options += [(k, v.title)] 
+        return options 
 
     def add_eatable(self, id_, eatable):
         self.eatables[id_] = eatable
 
     def add_consumption(self, consumption):
-        self.consumptions += [consumption] 
+        self.consumptions += [consumption]
 
-    def add_day(self, date, day):
-        self.days[date] = day 
+    def add_day(self, date, day, archives_today=False):
+        if archives_today:
+            date = date + str(datetime.datetime.now())[10:]
+        self.days[date] = day
 
     def set_today_date(self, today_date):
         self.today_date = today_date
@@ -162,27 +221,26 @@ class Database:
         del self.eatables[id_]
 
     def write(self):
-        import shutil
-        if os.path.exists(self.lock_file):
-            raise LockFileDetected
-        if os.path.exists(self.db_file):
-            shutil.copy(self.db_file, self.db_file + ".bak")
-        f = open(self.lock_file, "w+")
-        f.close()
-        with open(self.db_file, "w") as f:
-            json.dump(self.to_dict(), f)
-        os.remove(self.lock_file)
+        self.write_text_to_db(json.dumps(self.to_dict()))
 
 
-class MyServer(BaseHTTPRequestHandler):
+class ConsumptionsHandler(PlomHandler):
+
+    def app_init(self, handler):
+        default_path = '/consumptions'
+        handler.add_route('GET', default_path, self.show_db) 
+        handler.add_route('POST', default_path, self.write_db) 
+        return 'consumptions', default_path 
 
     def do_POST(self):
+        self.try_do(self.write_db)
+
+    def write_db(self):
         from uuid import uuid4
         from urllib.parse import parse_qs
-        import datetime
         length = int(self.headers['content-length'])
         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
-        db = Database(False)
+        db = CaloriesDB(False)
         def decode(key, i, is_num=True):
             if is_num:
                 return float(postvars[key][i])
@@ -193,101 +251,93 @@ class MyServer(BaseHTTPRequestHandler):
                 to_delete += [target]
         i = 0
         if 'eatable_uuid' in postvars.keys():
-            for uuid_encoded in postvars['eatable_uuid']:
-                uuid = uuid_encoded
+            for uuid in postvars['eatable_uuid']:
                 if uuid not in to_delete:
                     e = Eatable(decode("title", i, False), decode("cals", i), decode("sugar_g", i), decode("standard_g", i), decode("comments", i, False))
                     db.add_eatable(uuid, e)
-                i += 1    
+                i += 1
         if 'title' in postvars.keys() and len(postvars['title'][i]) > 0:
              e = Eatable(decode("title", i, False), decode("cals", i), decode("sugar_g", i), decode("standard_g", i), decode("comments", i, False))
              db.add_eatable(str(uuid4()), e)
         i = 0
         if 'eatable_key' in postvars.keys():
             for eatable_key in postvars['eatable_key']:
-                c = Consumption(decode("eatable_key", i, False), decode("unit_count", i))
-                i += 1 
-                if c.unit_count == 0:
+                c = Consumption(decode("eatable_key", i, False), decode("unit_count", i), decode("keep_visible", i))
+                i += 1
+                if c.unit_count == 0 and c.keep_visible == 0:
                     continue
                 db.add_consumption(c)
         i = 0
         if 'day_date' in postvars.keys():
             for date in postvars['day_date']:
                 db.add_day((date), Day(decode("day_cals", i), decode("day_sugar", i)))
-                i += 1 
+                i += 1
         if 'new_date' in postvars.keys():
             db.set_today_date(postvars["new_date"][0])
         if 'archive_day' in postvars.keys():
             new_cals = postvars["new_day_cals"][0]
             new_sugar = postvars["new_day_sugar"][0]
-            db.add_day(db.today_date, Day(float(new_cals), float(new_sugar)))
-            db.set_today_date(str(datetime.datetime.now()))#[:10])
+            db.add_day(db.today_date, Day(float(new_cals), float(new_sugar)), archives_today=True)
+            db.set_today_date(str(datetime.datetime.now())[:10])
+            for c in db.consumptions:
+                if c.unit_count > 0:
+                    db.eatables[c.eatable_key].popularity += 1
             db.consumptions = []
-        try:
-            db.write()
-            self.send_response(302)
-            self.send_header('Location', '/')
-            self.end_headers()
-        except LockFileDetected:
-            self.send_response(400)
-            self.end_headers()
-            self.wfile.write(bytes("Sorry, lock file!", "utf-8"))
+            default_slots = 10
+            for k, v in sorted(db.eatables.items(), key=lambda item: -item[1].popularity):
+                db.add_consumption(Consumption(k, 0))
+                default_slots -= 1
+                if (default_slots <= 0):
+                    break
+        db.write()
+        homepage = self.apps['consumptions'] if hasattr(self, 'apps') else self.homepage
+        self.redirect(homepage)
 
     def do_GET(self):
-        self.send_response(200)
-        self.send_header("Content-type", "text/html")
-        self.end_headers()
-        db = Database()
+        self.try_do(self.show_db)
 
-        eatables = ""
+    def show_db(self):
+        db = CaloriesDB()
+        eatable_rows = []
         for k,v in db.eatables.items():
-            eatables += "<tr>"\
-                    "<input name=\"eatable_uuid\" type=\"hidden\" value=\"%s\" />"\
-                    "<td><input name=\"title\" value=\"%s\" /></td>"\
-                    "<td><input name=\"cals\" type=\"number\" step=\"0.1\" min=\"0\" value=\"%.1f\" /></td>"\
-                    "<td><input name=\"sugar_g\" type=\"number\" step=\"0.1\" min=\"0\" value=\"%.1f\" /></td>"\
-                    "<td><input name=\"standard_g\" type=\"number\" step=\"0.1\" min=\"1\" value=\"%1.f\" /></td>"\
-                    "<td><input name=\"comments\" value=\"%s\" /></td>"\
-                    "<td><input name=\"delete\" type=\"checkbox\" value=\"%s\" /></td>"\
-                    "</tr>" % (k, v.title, v.cals, v.sugar_g, v.standard_g, v.comments, k)
-        consumptions = ""
+            eatable_rows += [{
+               'uuid': k,
+               'title': v.title,
+               'cals': f'{v.cals:.1f}',
+               'sugar_g': f'{v.sugar_g:.1f}',
+               'standard_g': f'{v.standard_g:.1f}',
+               'comments': v.comments
+            }]
+        db.consumptions = sorted(db.consumptions, key=lambda x: db.eatables[x.eatable_key].title)
+        consumption_rows = []
         for c in db.consumptions:
             r = db.calc_consumption(c)
-            consumptions += "<tr />"\
-                    "<td><select name=\"eatable_key\">%s</select></td>"\
-                    "<td><input name=\"unit_count\" type=\"number\" min=\"0\" value=\"%d\" /></td>"\
-                    "<td></td>"\
-                    "<td>%.1f</td>"\
-                    "<td>%.1f</td>"\
-                    "</tr>" % (db.eatables_selection(c.eatable_key), c.unit_count, r["cals"], r["sugar"])
-        day_rows = ""
-        for date, day in db.days.items():
-            day_rows += "<tr>"\
-                    "<td><input name=\"day_date\" type=\"hidden\" value=\"%s\" />%s</td>"\
-                    "<td><input name=\"day_cals\" type=\"hidden\" step=\"0.1\" min=\"0\" value=\"%.1f\" />%.1f</td>"\
-                    "<td><input name=\"day_sugar\" type=\"hidden\" step=\"0.1\" min=\"0\" value=\"%.1f\" />%.1f</td>"\
-                    "</tr>" % (date, date[:10], day.calories, day.calories, day.sugar_g, day.sugar_g)
-        day_rows += "<tr>"\
-                "<th>today:</th><th></th><th></th><th>archive?</th>"\
-                "</tr>"\
-                "<tr>"\
-                "<td><input name=\"new_date\" size=8 value=\"%s\" /></td>"\
-                "<td><input name=\"new_day_cals\" type=\"hidden\" value=\"%.1f\" readonly />%.1f</td>"\
-                "<td><input name=\"new_day_sugar\" type=\"hidden\" value=\"%.1f\" readonly />%.1f</td>"\
-                "<td><input name=\"archive_day\" type=\"checkbox\" /></td>"\
-                "</tr>" % (db.today_date, db.today.calories, db.today.calories, db.today.sugar_g, db.today.sugar_g)
-        page = build_page(eatables, consumptions, db.eatables_selection(), day_rows)
-        self.wfile.write(bytes(page, "utf-8"))
-
-
-hostName = "localhost"
-serverPort = 8080
-if __name__ == "__main__":        
-    webServer = HTTPServer((hostName, serverPort), MyServer)
-    print("Server started http://%s:%s" % (hostName, serverPort))
-    try:
-        webServer.serve_forever()
-    except KeyboardInterrupt:
-        pass
-    webServer.server_close()
-    print("Server stopped.")
+            consumption_rows += [{
+                'key': c.eatable_key,
+                'count': c.unit_count,
+                'title': db.eatables[c.eatable_key].title,
+                'cals': r['cals'],
+                'sugar': r['sugar']
+            }]
+        day_rows = []
+        for date in reversed(sorted(db.days.keys())):
+            day = db.days[date]
+            day_rows += [{
+                'date': date,
+                'date_short': date[:10],
+                'cals': f'{day.calories:.1f}',
+                'sugar': f'{day.sugar_g:.1f}',
+            }]
+        homepage = self.apps['consumptions'] if hasattr(self, 'apps') else self.homepage
+        page = jinja2.Template(tmpl).render(
+                homepage = homepage,
+                db=db,
+                days=day_rows,
+                consumptions=consumption_rows,
+                eatables=eatable_rows,
+                eatables_selection=db.eatables_selection())
+        self.send_HTML(page)
+
+
+if __name__ == "__main__":  
+    run_server(server_port, ConsumptionsHandler)