home · contact · privacy
Add foreign key restraints, expand and fix tests, add deletion and forking.
[misc] / calories.py
1 import os
2 import json
3 import datetime
4 import jinja2
5 from plomlib import PlomDB, PlomException, run_server, PlomHandler 
6
7 db_path = '/home/plom/org/calories_db.json'
8
9 server_port = 8081
10
11 tmpl = """
12 <style>
13 table { margin-bottom: 2em; }
14 th, td { text-align: left; }
15 td.number { text-align: right; }
16 input[type="number"] { text-align: right; }
17 </style>
18 <body>
19 <form action="{{homepage}}" method="POST">
20 <td><input name="update" type="submit" value="update" /></td>
21 <table>
22 <tr><th>eatable</th><th>unit count</th><th>unit weight (g)</th><th>calories</th><th>sugar (g)</th></tr>
23 {% for c in consumptions %}
24 <tr>
25 <input type="hidden" name="keep_visible" value="1"><input name="eatable_key" type="hidden" value="{{c.key|e}}">
26 <td class="number"><input class="unit_count number" name="unit_count" type="number" min="0" step="0.1" value="{{c.count}}" /></td>
27 <td>{{c.title}}</td>
28 <td></td>
29 <td class="number">{{c.cals}}</td>
30 <td class="number">{{c.sugar}}</td>
31 </tr>
32 {% endfor %}
33 <tr>
34 <th>add from DB:</th>
35 </tr>
36 <tr>
37 <input type="hidden" name="keep_visible" value="0">
38 <td class="number"><input class="unit_count" name="unit_count" type="number" step="0.1" min="0" value="0" /></td>
39 <td><select name="eatable_key">{% for sel in eatables_selection %}
40 <option value="{{sel.0|e}}">{{sel.1|e}}</option>
41 {% endfor %}</select></td>
42 <td></td>
43 </tr>
44 </table>
45 <table>
46 <tr><th>today:</th><th></th><th></th><th>archive?</th></tr>
47 <td><input name="new_date" size=8 value="{{db.today_date}}" /><td>
48 <td class="number"><input name="new_day_cals" type="hidden" value="{{db.today.calories}}" readonly />{{db.today.calories}}</td>
49 <td class="number"><input name="new_day_sugar" type="hidden" value="{{db.today.sugar_g}}" readonly />{{db.today.sugar_g}}</td>
50 <td><input name="archive_day" type="checkbox" /></td>
51 </tr>
52 <tr><th>day</th><th>calories</th><th>sugar (g)</th></tr>
53 {% for d in days %}
54 <tr>
55 <td><input name="day_date" type="hidden" value="{{d.date|e}}" />{{d.date_short|e}}</td>
56 <td class="number"><input name="day_cals" type="hidden" step="0.1" min="0" value="{{d.cals}}" />{{d.cals}}</td>
57 <td class="number"><input name="day_sugar" type="hidden" step="0.1" min="0" value="{{d.sugar}}" />{{d.sugar}}</td>
58 </tr>
59 {% endfor %}
60 </table>
61 <table>
62 <tr><th>title</th><th>calories</th><th>sugar (g)</th><th>standard weight (g)</th><th>comments</th><th>delete</th></tr>
63 {% for e in eatables %}
64 <tr>
65 <input name="eatable_uuid" type="hidden" value="{{e.uuid}}" />
66 <td><input name="title" value="{{e.title|e}}" /></td>
67 <td class="number"><input name="cals" type="number" step="0.1" min="0" value="{{e.cals}}" /></td>
68 <td class="number"><input name="sugar_g" type="number" step="0.1" min="0" value="{{e.sugar_g}}" /></td>
69 <td class="number"><input name="standard_g" type="number" step="0.1" min="0" value="{{e.sugar_g}}" /></td>
70 <td><input name="comments" value="{{e.comments|e}}" /</td>
71 <td><input name="delete" type="checkbox" value="{{e.uuid}}" />
72 </tr>
73 {% endfor %}
74 <tr>
75 <th>add:</th>
76 </tr>
77 <tr>
78 <td><input name="title" type="text" value="" /></td>
79 <td class="number"><input name="cals" type="number" min="0" step="0.1" value="0" /></td>
80 <td class="number"><input name="sugar_g" type="number" min="0" step="0.1" value="0" /></td>
81 <td class="number"><input name="standard_g" type="number" min="1" step="0.1" value="1" /></td>
82 <td><input name="comments" type="text" value="" /></td>
83 </tr>
84 </table>
85 </form>
86 </body>
87 <script>
88 var unit_count_inputs = document.getElementsByClassName("unit_count");
89 for (let i = 0; i < unit_count_inputs.length; i++) {
90     let input = unit_count_inputs[i];
91     let button = document.createElement('button');
92     button.innerHTML = '+1';
93     button.onclick = function(event) {
94         event.preventDefault();
95         input.value = parseFloat(input.value) + 1.0;
96     };
97     input.insertAdjacentElement('afterend', button);
98 }
99
100 </script>
101 """
102
103
104 class Eatable:
105
106     def __init__(self, title, cals, sugar_g, standard_g=100, comments="", popularity=0):
107         self.title = title
108         self.cals = cals  # per 100g
109         self.sugar_g = sugar_g  # per 100g
110         self.standard_g = standard_g  # common unit weight
111         self.comments = comments
112         self.popularity = popularity
113
114     def to_dict(self):
115         return {
116             "title": self.title,
117             "cals": self.cals,
118             "sugar_g": self.sugar_g,
119             "standard_g": self.standard_g,
120             "comments": self.comments,
121             "popularity": self.popularity
122         }
123
124
125 class Consumption:
126
127     def __init__(self, eatable_key, unit_count=None, keep_visible=0):
128         self.eatable_key = eatable_key
129         self.unit_count = unit_count
130         self.keep_visible = keep_visible
131
132     def to_dict(self):
133         return {
134             "eatable_key": self.eatable_key,
135             "unit_count": self.unit_count,
136             "keep_visible": self.keep_visible
137         }
138
139
140 class Day:
141
142     def __init__(self, calories, sugar_g):
143         self.calories = calories
144         self.sugar_g = sugar_g
145
146     def to_dict(self):
147         return {
148             "calories": self.calories,
149             "sugar_g": self.sugar_g,
150         }
151
152
153 class CaloriesDB(PlomDB):
154
155     def __init__(self, load_from_file=True):
156         self.load_from_file = load_from_file
157         self.eatables = {}
158         self.consumptions = []
159         self.days = {}
160         self.today = Day(0, 0)
161         self.today_date = ""
162         super().__init__(db_path)
163
164     def read_db_file(self, f):
165         if not self.load_from_file:
166             return
167         self.from_dict(json.load(f))
168
169     def from_dict(self, d):
170         self.set_today_date(d["today_date"])
171         for k,v in d["eatables"].items():
172             self.add_eatable(k, Eatable(v["title"], v["cals"], v["sugar_g"], v["standard_g"], v["comments"]))
173         for c in d["consumptions"]:
174             self.add_consumption(Consumption(c["eatable_key"], c["unit_count"]))
175         for k,v in d["days"].items():
176             self.add_day(k, Day(v["calories"], v["sugar_g"]))
177
178     def to_dict(self):
179         d = {"eatables": {}, "consumptions": [], "days":{}, "today_date":self.today_date}
180         for k,v in self.eatables.items():
181             d["eatables"][k] = v.to_dict()
182         for c in self.consumptions:
183             d["consumptions"] += [c.to_dict()]
184         for k,v in self.days.items():
185             d["days"][k] = v.to_dict()
186         return d
187
188     def calc_consumption(self, c):
189         eatable = self.eatables[c.eatable_key]
190         calories = eatable.cals * c.unit_count
191         sugar_g = eatable.sugar_g * c.unit_count
192         self.today.calories += calories
193         self.today.sugar_g += sugar_g
194         return {"cals": calories, "sugar": sugar_g }
195
196     def eatables_selection(self):
197         options = []
198         already_selected = [c.eatable_key for c in self.consumptions]
199         for k, v in sorted(self.eatables.items(), key=lambda item: item[1].title):
200             if k in already_selected:
201                 continue
202             v = self.eatables[k]
203             options += [(k, v.title)] 
204         return options 
205
206     def add_eatable(self, id_, eatable):
207         self.eatables[id_] = eatable
208
209     def add_consumption(self, consumption):
210         self.consumptions += [consumption]
211
212     def add_day(self, date, day, archives_today=False):
213         if archives_today:
214             date = date + str(datetime.datetime.now())[10:]
215         self.days[date] = day
216
217     def set_today_date(self, today_date):
218         self.today_date = today_date
219
220     def delete(self, id_):
221         del self.eatables[id_]
222
223     def write(self):
224         self.write_text_to_db(json.dumps(self.to_dict()))
225
226
227 class ConsumptionsHandler(PlomHandler):
228
229     def app_init(self, handler):
230         default_path = '/consumptions'
231         handler.add_route('GET', default_path, self.show_db) 
232         handler.add_route('POST', default_path, self.write_db) 
233         return 'consumptions', default_path 
234
235     def do_POST(self):
236         self.try_do(self.write_db)
237
238     def write_db(self):
239         from uuid import uuid4
240         from urllib.parse import parse_qs
241         length = int(self.headers['content-length'])
242         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
243         db = CaloriesDB(False)
244         def decode(key, i, is_num=True):
245             if is_num:
246                 return float(postvars[key][i])
247             return postvars[key][i]
248         to_delete = []
249         if 'delete' in postvars.keys():
250             for target in postvars['delete']:
251                 to_delete += [target]
252         i = 0
253         if 'eatable_uuid' in postvars.keys():
254             for uuid in postvars['eatable_uuid']:
255                 if uuid not in to_delete:
256                     e = Eatable(decode("title", i, False), decode("cals", i), decode("sugar_g", i), decode("standard_g", i), decode("comments", i, False))
257                     db.add_eatable(uuid, e)
258                 i += 1
259         if 'title' in postvars.keys() and len(postvars['title'][i]) > 0:
260              e = Eatable(decode("title", i, False), decode("cals", i), decode("sugar_g", i), decode("standard_g", i), decode("comments", i, False))
261              db.add_eatable(str(uuid4()), e)
262         i = 0
263         if 'eatable_key' in postvars.keys():
264             for eatable_key in postvars['eatable_key']:
265                 c = Consumption(decode("eatable_key", i, False), decode("unit_count", i), decode("keep_visible", i))
266                 i += 1
267                 if c.unit_count == 0 and c.keep_visible == 0:
268                     continue
269                 db.add_consumption(c)
270         i = 0
271         if 'day_date' in postvars.keys():
272             for date in postvars['day_date']:
273                 db.add_day((date), Day(decode("day_cals", i), decode("day_sugar", i)))
274                 i += 1
275         if 'new_date' in postvars.keys():
276             db.set_today_date(postvars["new_date"][0])
277         if 'archive_day' in postvars.keys():
278             new_cals = postvars["new_day_cals"][0]
279             new_sugar = postvars["new_day_sugar"][0]
280             db.add_day(db.today_date, Day(float(new_cals), float(new_sugar)), archives_today=True)
281             db.set_today_date(str(datetime.datetime.now())[:10])
282             for c in db.consumptions:
283                 if c.unit_count > 0:
284                     db.eatables[c.eatable_key].popularity += 1
285             db.consumptions = []
286             default_slots = 10
287             for k, v in sorted(db.eatables.items(), key=lambda item: -item[1].popularity):
288                 db.add_consumption(Consumption(k, 0))
289                 default_slots -= 1
290                 if (default_slots <= 0):
291                     break
292         db.write()
293         homepage = self.apps['consumptions'] if hasattr(self, 'apps') else self.homepage
294         self.redirect(homepage)
295
296     def do_GET(self):
297         self.try_do(self.show_db)
298
299     def show_db(self):
300         db = CaloriesDB()
301         eatable_rows = []
302         for k,v in db.eatables.items():
303             eatable_rows += [{
304                'uuid': k,
305                'title': v.title,
306                'cals': f'{v.cals:.1f}',
307                'sugar_g': f'{v.sugar_g:.1f}',
308                'standard_g': f'{v.standard_g:.1f}',
309                'comments': v.comments
310             }]
311         db.consumptions = sorted(db.consumptions, key=lambda x: db.eatables[x.eatable_key].title)
312         consumption_rows = []
313         for c in db.consumptions:
314             r = db.calc_consumption(c)
315             consumption_rows += [{
316                 'key': c.eatable_key,
317                 'count': c.unit_count,
318                 'title': db.eatables[c.eatable_key].title,
319                 'cals': r['cals'],
320                 'sugar': r['sugar']
321             }]
322         day_rows = []
323         for date in reversed(sorted(db.days.keys())):
324             day = db.days[date]
325             day_rows += [{
326                 'date': date,
327                 'date_short': date[:10],
328                 'cals': f'{day.calories:.1f}',
329                 'sugar': f'{day.sugar_g:.1f}',
330             }]
331         homepage = self.apps['consumptions'] if hasattr(self, 'apps') else self.homepage
332         page = jinja2.Template(tmpl).render(
333                 homepage = homepage,
334                 db=db,
335                 days=day_rows,
336                 consumptions=consumption_rows,
337                 eatables=eatable_rows,
338                 eatables_selection=db.eatables_selection())
339         self.send_HTML(page)
340
341
342 if __name__ == "__main__":  
343     run_server(server_port, ConsumptionsHandler)