home · contact · privacy
Add foreign key restraints, expand and fix tests, add deletion and forking.
[misc] / new_todo / todo.py
1 #!/usr/bin/env python3
2 from sqlite3 import connect as sql_connect
3 from http.server import BaseHTTPRequestHandler
4 from urllib.parse import parse_qs
5 from datetime import datetime, timedelta
6 from unittest import TestCase
7 from os.path import isfile
8 from time import sleep
9
10
11
12 PATH_DB_SCHEMA='init.sql'
13 HTTP_PORT=8082
14 HTML_DIR='html'
15
16
17
18 DATE_FORMAT = '%Y-%m-%d'
19 DATETIME_KEY_RESOLUTION = 24
20
21
22
23 ######## basic system stuff ############
24
25 class HandledException(Exception):
26
27     def nice_exit(self):
28         from sys import exit as sys_exit
29         print(f'ABORTING: {self}')
30         sys_exit(1)
31
32
33
34 class TodoDBFile:
35
36     def __init__(self, path, force_creation=False):
37         self.path = path
38         if not isfile(self.path):
39             self._make_new_if_wanted_else_abort(force_creation)
40         self._validate_schema()
41
42     def _make_new_if_wanted_else_abort(self, force_creation):
43         if not force_creation:
44             create_question = f'Database file not found: {self.path}. Create? Y/n\n'
45             msg_on_no = 'Interpreting reply as "no", but cannot run without database file.'
46             legal_yesses = {'y', 'yes'}
47             create_reply = input(create_question)
48             if not create_reply.lower() in legal_yesses: 
49                 raise HandledException(msg_on_no)
50         with sql_connect(self.path) as conn:
51             with open(PATH_DB_SCHEMA, 'r') as f:
52                 conn.executescript(f.read())
53
54     def _validate_schema(self):
55         sql_for_schema = 'SELECT sql FROM sqlite_master ORDER BY sql'
56         msg_wrong_schema = 'Database has wrong tables schema. Diff:\n'
57         with sql_connect(self.path) as conn:
58             schema = ';\n'.join([r[0] for r in conn.execute(sql_for_schema) if r[0]]) + ';'
59             with open(PATH_DB_SCHEMA, 'r') as f:
60                 stored_schema = f.read().rstrip()
61                 if schema != stored_schema:
62                     from difflib import Differ
63                     d = Differ()
64                     diff_msg = d.compare(schema.splitlines(), stored_schema.splitlines())
65                     raise HandledException(msg_wrong_schema + '\n'.join(diff_msg))
66
67     def backup(self):
68         from shutil import copy2
69         from random import randint
70         copy2(self.path, f'{self.path}.bak.0')
71         for i in range(0, 6):
72             if 1 == randint(0, 2**i):
73                 copy2(self.path, f'{self.path}.bak.{i+1}')
74                 break
75
76
77
78 class TodoDBConnection:
79
80     def __init__(self, db_file):
81         self.file = db_file
82         self.conn = sql_connect(self.file.path)
83         self.conn.execute('PRAGMA foreign_keys = ON')
84     
85     def commit(self):
86         self.file.backup()
87         self.conn.commit()
88
89     def exec(self, code, inputs=None):
90         if not inputs:
91             inputs = []
92         return self.conn.execute(code, inputs)
93
94     def close(self):
95         self.conn.close()
96
97
98
99 ######## models ############
100
101 class VersionedAttribute:
102
103     def __init__(self, db_conn, parent, name, default):
104         self.parent = parent
105         self.name = name
106         self.default = default
107         self.history = {}
108         for row in db_conn.exec(f'SELECT * FROM {self.table_name} WHERE template = ?',
109                                 (self.parent.id_,)):
110             self.history[row[1]] = row[2]
111
112     @property
113     def table_name(self):
114         return f'versioned_{self.name}s' 
115
116     def save(self, db_conn):
117         for date, value in self.history.items():
118             db_conn.exec(f'REPLACE INTO {self.table_name} VALUES (?, ?, ?)',
119                          (self.parent.id_, date, value))
120
121     def delete(self, db_conn):
122         db_conn.exec(f'DELETE FROM {self.table_name} WHERE template = ?', (self.parent.id_,))
123
124     @property
125     def _newest_datetime(self):
126         return sorted(self.history.keys())[-1]
127
128     def _forked_value_at(self, date_with_time):
129         return getattr(self.parent.forked_from, self.name).at(date_with_time)
130
131     @property
132     def newest(self):
133         if 0 == len(self.history):
134             if self.parent.forked_from:
135                 return self._forked_value_at(self.parent.forked_at)
136             return self.default
137         return self.history[self._newest_datetime]
138
139     def at(self, date_with_time):
140         sorted_datetimes = sorted(self.history.keys())
141         if self.parent.forked_from and (0 == len(sorted_datetimes) or sorted_datetimes[0] > date_with_time):
142             return self._forked_value_at(date_with_time)
143         elif 0 == len(sorted_datetimes):
144             return self.default
145         ret = self.history[sorted_datetimes[0]]
146         for k, v in self.history.items():
147             if k > date_with_time:
148                 break
149             ret = v
150         return ret
151
152     def set(self, value):
153         if 0 == len(self.history) or value != self.history[self._newest_datetime]:
154             self.history[Day.todays_date(with_time=True)] = value
155
156
157
158 class Day:
159
160     def __init__(self, date, comment=''):
161         self.date = date
162         self.datetime = self.__class__.date_valid(self.date) 
163         if not self.datetime:
164             raise HandledException(f'Provided date is of wrong format: {date}')
165         self.comment = comment
166
167     @classmethod
168     def from_row(cls, row):
169         return cls(row[0], row[1])
170
171     @classmethod
172     def all(cls, db_conn, ensure_betweens=False, date_range=('', '')):
173         legal_day_names = {'yesterday', 'today', 'tomorrow'}
174         for val in [val for val in date_range
175                     if val != '' and val not in legal_day_names
176                     and not cls.date_valid(val)]:
177             raise HandledException(f'Provided date is of wrong format: {val}')
178         start_str = date_range[0] if date_range[0] else '2024-01-01'
179         end_str = date_range[1] if date_range[1] else '2030-12-31'
180         start_date = getattr(cls, f'{start_str}s_date')() if start_str in legal_day_names else start_str
181         end_date = getattr(cls, f'{end_str}s_date')() if end_str in legal_day_names else end_str
182         if start_date > end_date:
183             temp = end_date
184             end_date = start_date
185             start_date = temp
186         days = []
187         for row in db_conn.exec('SELECT * FROM days WHERE date >= ? AND date <= ?',
188                                 (start_date, end_date)):
189             days += [cls.from_row(row)]
190         if ensure_betweens:
191             if '' != date_range[0] and not start_date in [d.date for d in days]: 
192                 days += [cls(start_date)]
193             if '' != date_range[1] and not end_date in [d.date for d in days]: 
194                 days += [cls(end_date)]
195         days.sort()
196         if ensure_betweens and len(days) > 1:
197             gapless_days = []
198             for i, day in enumerate(days):
199                 gapless_days += [day]
200                 if i < len(days) - 1:
201                     while day.next_date != days[i+1].date:
202                         day = Day(day.next_date)
203                         gapless_days += [day]
204             days = gapless_days
205         return days
206
207     @classmethod
208     def by_date(cls, db_conn, date, make_if_none=False):
209         for row in db_conn.exec('SELECT * FROM days WHERE date = ?', (date,)):
210             return cls.from_row(row)
211         return cls(date) if make_if_none else None
212
213     def save(self, db_conn):
214         db_conn.exec('REPLACE INTO days VALUES (?, ?)', (self.date, self.comment))
215
216     @classmethod
217     def date_valid(cls, date):
218         try:
219             result = datetime.strptime(date, DATE_FORMAT)
220         except (ValueError, TypeError):
221             return None 
222         return result 
223
224     @classmethod
225     def todays_date(cls, with_time=False):
226         cut_length = DATETIME_KEY_RESOLUTION if with_time else 10
227         return str(datetime.now())[:cut_length]
228
229     @classmethod
230     def yesterdays_date(cls):
231         return str(datetime.now() - timedelta(days=1))[:10]
232
233     @classmethod
234     def tomorrows_date(cls):
235         return str(datetime.now() + timedelta(days=1))[:10]
236
237     @property
238     def next_date(self):
239         next_datetime = self.datetime + timedelta(days=1)
240         return next_datetime.strftime(DATE_FORMAT)
241
242     @property
243     def prev_date(self):
244         prev_datetime = self.datetime - timedelta(days=1)
245         return prev_datetime.strftime(DATE_FORMAT)
246
247     @property
248     def weekday(self):
249         return self.datetime.strftime('%A')
250
251     def __eq__(self, other):
252         return self.date == other.date
253
254     def __lt__(self, other):
255         return self.date < other.date
256
257
258
259 class TodoTemplate:
260
261     def __init__(self, db_conn, id_=None, forked_from=None, forked_at=None):
262         self.id_ = id_ 
263         self.forked_from = TodoTemplate.by_id(db_conn, forked_from) 
264         self.forked_at = forked_at 
265         self.title = VersionedAttribute(db_conn, self, 'title', 'UNNAMED') 
266         self.default_effort = VersionedAttribute(db_conn, self, 'default_effort', 1.0) 
267         self.description = VersionedAttribute(db_conn, self, 'description', '') 
268
269     @classmethod
270     def from_row(cls, db_conn, row):
271         return cls(db_conn, row[0], row[1], row[2])
272
273     @classmethod
274     def all(cls, db_conn):
275         tmpls = []
276         for row in db_conn.exec('SELECT * FROM templates'):
277             tmpls += [cls.from_row(db_conn, row)]
278         return tmpls 
279
280     @classmethod
281     def by_id(cls, db_conn, id_, make_if_none=False):
282         for row in db_conn.exec('SELECT * FROM templates WHERE id = ?', (id_,)):
283             return cls.from_row(db_conn, row)
284         if make_if_none:
285             return cls(db_conn, id_, None, None)
286         return None
287
288     def save(self, db_conn):
289         cursor = db_conn.exec('REPLACE INTO templates VALUES (?,?,?) RETURNING ID',
290                               (self.id_, self.forked_from.id_ if self.forked_from else None, self.forked_at))
291         if self.id_ is None:
292             self.id_ = cursor.fetchone()[0]
293         self.title.save(db_conn)
294         self.default_effort.save(db_conn)
295         self.description.save(db_conn)
296
297     def delete(self, db_conn):
298         for row in db_conn.exec('SELECT * FROM templates WHERE forked_from = ?', (self.id_,)):
299             raise HandledException('cannot delete forking reference') 
300         self.title.delete(db_conn)
301         self.default_effort.delete(db_conn)
302         self.description.delete(db_conn)
303         db_conn.exec('DELETE FROM templates WHERE id = ?', (self.id_,))
304
305     def fork(self, db_conn):
306         forked = self.__class__(db_conn, id_=None, forked_from=self.id_,
307                                 forked_at=Day.todays_date(with_time=True))
308         forked.save(db_conn)
309         return forked
310
311
312
313 ######## web stuff ############
314
315 class ParamsParser:
316
317     def __init__(self, url_query, site_cookie_dict):
318         self.params = parse_qs(url_query, keep_blank_values=True)
319         self.cookie = site_cookie_dict
320
321     def get(self, key, default):
322         return self.params.get(key, [default])[0]
323
324     def get_cookied(self, key, default):
325         val = self.params.get(key, [self.cookie.get(key, default)])[0]
326         self.cookie[key] = val 
327         return val 
328
329
330
331 class CookieDB:
332
333     def __init__(self, site, headers):
334         from http.cookies import SimpleCookie
335         from json import loads as json_loads
336         self.site = site
337         self.of_site = {}
338         self.full = SimpleCookie(headers['Cookie']) if 'Cookie' in headers.keys() else SimpleCookie()
339         if site in self.full.keys():
340             self.of_site = json_loads(self.full[site].value)
341
342     def send_headers(self):
343         from json import dumps as json_dumps
344         self.full[self.site] = json_dumps(self.of_site)
345         return [('Set-Cookie', morsel.OutputString()) for morsel in self.full.values()]
346
347     def reset(self):
348         for morsel in self.full.values():
349              morsel['expires'] = 'Thu, 01 Jan 1970 00:00:00 GMT'
350
351
352
353 class TodoHandler(BaseHTTPRequestHandler):
354
355     def init(self):
356         from os.path import split as path_split
357         from urllib.parse import urlparse
358         parsed_url = urlparse(self.path)
359         self.site = path_split(parsed_url.path)[1]
360         if 0 == len(self.site):
361             self.site = 'calendar'
362         self.cookie = CookieDB(self.site, self.headers)
363         self.params = ParamsParser(parsed_url.query, self.cookie.of_site)
364         self.db_conn = TodoDBConnection(self.server.db_file)
365
366     def send_code_and_headers(self, code, headers, set_cookies=True):
367         self.send_response(code)
368         if set_cookies:
369             [self.send_header(h[0], h[1]) for h in self.cookie.send_headers()]
370         for fieldname, content in headers:
371             self.send_header(fieldname, content)
372         self.end_headers()
373
374     def redirect(self, url):
375         self.send_code_and_headers(302, [('Location', url)])
376
377     def send_HTML(self, html, code=200):
378         self.send_code_and_headers(code, [('Content-type', 'text/html')])
379         self.wfile.write(bytes(html, 'utf-8'))
380
381     def send_fail(self, msg, code=400):
382         html = self.server.html.get_template('msg.html').render(msg=f'Exception: {msg}')
383         self.send_code_and_headers(code, [('Content-type', 'text/html')], set_cookies=False)
384         self.wfile.write(bytes(html, 'utf-8'))
385
386     # POST routes
387
388     def do_POST(self):
389         try:
390             self.init()
391             length = int(self.headers['content-length'])
392             self.redir_url = '/'
393             self.postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
394             if self.site in {'day', 'template'}:
395                 getattr(self, f'do_POST_{self.site}')()
396             self.db_conn.commit()
397             self.db_conn.close()
398             self.redirect(self.redir_url)
399         except HandledException as msg:
400             self.send_fail(msg)
401
402     def do_POST_day(self):
403         date = self.postvars['date'][0]
404         day = Day(date, self.postvars['comment'][0])
405         day.save(self.db_conn)
406
407     def do_POST_template(self):
408         id_ = self.params.get('id', None)
409         tmpl = TodoTemplate.by_id(self.db_conn, id_, make_if_none=True)
410         if 'update' in self.postvars.keys():
411             tmpl.title.set(self.postvars['title'][0])
412             tmpl.default_effort.set(float(self.postvars['default_effort'][0]))
413             tmpl.description.set(self.postvars['description'][0])
414             tmpl.save(self.db_conn)
415         elif 'fork' in self.postvars.keys():
416             tmpl.fork(self.db_conn)
417         elif 'delete' in self.postvars.keys():
418             tmpl.delete(self.db_conn)
419         self.redir_url = 'templates'
420
421     # GET routes
422
423     def do_GET(self):
424         try:
425             self.init()
426             if self.site in {'day', 'template', 'templates', 'reset_cookie', 'calendar'}:
427                 page = getattr(self, f'do_GET_{self.site}')()
428             else:
429                 page = self.do_GET_calendar() 
430             self.db_conn.close()
431             self.send_HTML(page)
432         except HandledException as msg:
433             self.send_fail(msg)
434
435     def do_GET_reset_cookie(self):
436         self.cookie.reset()
437         msg = 'Cookie has been re-set!'
438         return self.server.html.get_template('msg.html').render(msg=msg)
439
440     def do_GET_day(self):
441         date = self.params.get_cookied('id', Day.todays_date())
442         day = Day.by_date(self.db_conn, date, make_if_none=True)
443         return self.server.html.get_template('day.html').render(day=day)
444
445     def do_GET_calendar(self):
446         from_ = self.params.get_cookied('from', 'yesterday') 
447         to = self.params.get_cookied('to', '') 
448         days = Day.all(self.db_conn, ensure_betweens=True, date_range=(from_, to))
449         return self.server.html.get_template('calendar.html').render(
450                 days=days, from_=from_, to=to)
451
452     def do_GET_template(self):
453         id_ = self.params.get('id', None)
454         template = TodoTemplate.by_id(self.db_conn, id_, make_if_none=True)
455         return self.server.html.get_template('template.html').render(tmpl=template)
456
457     def do_GET_templates(self):
458         templates = TodoTemplate.all(self.db_conn)
459         return self.server.html.get_template('templates.html').render(templates=templates)
460
461
462
463 # main loop
464
465 def main():
466     from http.server import HTTPServer
467     from os import environ
468     from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader 
469     path_todo_db = environ.get('TODO_DB')
470     if not path_todo_db:
471         raise HandledException('TODO_DB environment variable not set.')
472     db_file = TodoDBFile(path_todo_db)
473     server = HTTPServer(('localhost', HTTP_PORT), TodoHandler)
474     server.db_file = db_file
475     server.html = JinjaEnv(loader=JinjaFSLoader(HTML_DIR))
476     print(f'running at http://localhost:{HTTP_PORT}')
477     try:
478         server.serve_forever()
479     except KeyboardInterrupt:
480         print('\ncaught KeyboardInterrupt, stopping server')
481     server.server_close()
482
483
484
485 if __name__ == '__main__':
486     try:
487         main()
488     except HandledException as e:
489         e.nice_exit()
490
491
492
493 # testing – run with: python3 -m unittest todo.py
494
495 class TestWithDB(TestCase):
496
497     def setUp(self):
498         self.db_file = TodoDBFile(f'test_db:{datetime.now().timestamp()}', force_creation=True)
499         self.db_conn = TodoDBConnection(self.db_file)
500         self.bak_0_path = f'{self.db_file.path}.bak.0'
501
502     def tearDown(self):
503         from os import remove
504         remove(self.db_file.path)
505         for i in range(0, 9):
506             bak_path = f'{self.db_file.path}.bak.{i}'
507             if isfile(f'{self.db_file.path}.bak.{i}'):
508                 remove(bak_path)
509
510     def test_backup_bak_0_file_content(self):
511         day = Day('2024-01-01')
512         day.save(self.db_conn)
513         self.db_conn.commit()  # backups before writing, so expect different file contents
514         with open(self.db_file.path, 'rb') as f1:
515             original_content = f1.read()
516             with open(self.bak_0_path, 'rb') as f2:
517                 backup_content = f2.read()
518         self.assertNotEqual(original_content, backup_content)
519         self.db_conn.commit()  # this time commit without changes, so expect equal file contents
520         with open(self.db_file.path, 'rb') as f1:
521             original_content = f1.read()
522             with open(self.bak_0_path, 'rb') as f2:
523                 backup_content = f2.read()
524         self.assertEqual(original_content, backup_content)
525
526     def test_backup_bak_0_file_attributes(self):
527         from os import stat
528         sleep(0.1)  # so mtime would change if not copied
529         self.db_conn.commit()
530         original_stat = stat(self.db_file.path)
531         backup_stat = stat(self.bak_0_path)
532         for name in {'st_mode', 'st_dev', 'st_nlink', 'st_uid', 'st_gid', 'st_size', 'st_atime', 'st_mtime'}:
533             self.assertEqual(getattr(original_stat, name), getattr(backup_stat, name))
534
535     def test_Day_by_date(self):
536         test_date_1 = '2024-02-28' 
537         test_date_2 = '2024-02-29' 
538         test_comment = 'foo'
539         day1 = Day(test_date_1, test_comment)
540         day1.save(self.db_conn)
541         retrieved_day = Day.by_date(self.db_conn, test_date_1)
542         self.assertEqual(day1, retrieved_day)
543         self.assertEqual(day1.comment, retrieved_day.comment)
544         self.assertEqual(None, Day.by_date(self.db_conn, test_date_2))
545         self.assertEqual(Day(test_date_2), Day.by_date(self.db_conn, test_date_2, make_if_none=True))
546
547     def test_Day_all(self):
548         with self.assertRaises(HandledException):
549             Day.all(self.db_conn, date_range=(None, None))
550             Day.all(self.db_conn, date_range=('foo', ''))
551             Day.all(self.db_conn, date_range=('', '2024-02-30'))
552         test_date_1 = str(datetime.now() - timedelta(days=2))[:10]
553         test_date_2 = Day.todays_date() 
554         test_date_3 = str(datetime.now() + timedelta(days=2))[:10]
555         day1 = Day(test_date_1)
556         day2 = Day(test_date_2)
557         day3 = Day(test_date_3)
558         day1.save(self.db_conn)
559         day2.save(self.db_conn)
560         day3.save(self.db_conn)
561         self.assertEqual([day1, day2, day3], Day.all(self.db_conn))
562         self.assertEqual([day2, day3], Day.all(self.db_conn, date_range=('yesterday', '')))
563         self.assertEqual([day2, day3], Day.all(self.db_conn, date_range=(Day.yesterdays_date(), '')))
564         self.assertEqual([day3], Day.all(self.db_conn, date_range=('tomorrow', '')))
565         self.assertEqual([day3], Day.all(self.db_conn, date_range=(Day.tomorrows_date(), '')))
566         self.assertEqual([day2], Day.all(self.db_conn, date_range=('today', Day.todays_date())))
567         self.assertEqual([day1], Day.all(self.db_conn, date_range=('', 'yesterday')))
568         self.assertEqual([Day(Day.yesterdays_date()), day2],
569                          Day.all(self.db_conn, ensure_betweens=True, date_range=('yesterday', 'today')))
570         self.assertEqual([day2, Day(Day.tomorrows_date())],
571                          Day.all(self.db_conn, ensure_betweens=True, date_range=('today', 'tomorrow')))
572
573     def test_TodoTemplate_by_id(self):
574         tmpl = TodoTemplate(self.db_conn)
575         tmpl.save(self.db_conn)
576         retrieved = TodoTemplate.by_id(self.db_conn, tmpl.id_)
577         self.assertEqual(tmpl.id_, retrieved.id_)
578         self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_ + 1))
579         self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_ + 1, make_if_none=True), TodoTemplate)
580
581     def test_TodoTemplate_all(self):
582         tmpl_1 = TodoTemplate(self.db_conn)
583         tmpl_1.save(self.db_conn)
584         tmpl_2 = TodoTemplate(self.db_conn)
585         tmpl_2.save(self.db_conn)
586         self.assertEqual({tmpl_1.id_, tmpl_2.id_}, set(t.id_ for t in TodoTemplate.all(self.db_conn)))
587
588     def test_TodoTemplate_delete(self):
589         tmpl = TodoTemplate(self.db_conn)
590         tmpl.title.set('foo')
591         tmpl.save(self.db_conn)
592         self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_), TodoTemplate)
593         tmpl.delete(self.db_conn)
594         self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_))
595         tmpl = TodoTemplate(self.db_conn)
596         tmpl.save(self.db_conn)
597         fork = tmpl.fork(self.db_conn)
598         with self.assertRaises(HandledException):
599             tmpl.delete(self.db_conn)
600         self.assertIsInstance(TodoTemplate.by_id(self.db_conn, tmpl.id_), TodoTemplate)
601         fork.delete(self.db_conn)
602         tmpl.delete(self.db_conn)
603         self.assertEqual(None, TodoTemplate.by_id(self.db_conn, tmpl.id_))
604
605     def test_versioned_attributes(self):
606         def test(name, default, values):
607             def wait_till_next_timestamp(timestamp):
608                 # if we .set() to early, the timestamp key will not have changed,
609                 # i.e. we'd simply over-write the previous value 
610                 while Day.todays_date(with_time=True) == timestamp:
611                     sleep(0.0001)
612                 return Day.todays_date(with_time=True) 
613             tmpl = TodoTemplate(self.db_conn)
614             tmpl.save(self.db_conn)
615             tmpl_attr = getattr(tmpl, name)
616             # check we get default value on empty history
617             self.assertEqual(tmpl_attr.newest, default)
618             self.assertEqual({}, tmpl_attr.history)
619             # check we get new value when set 
620             tmpl_attr.set(values[0])
621             timestamp_1 = Day.todays_date(with_time=True)
622             self.assertEqual(tmpl_attr.newest, values[0])
623             # check history remains unchanged if setting same value as .newest 
624             timestamp_2 = wait_till_next_timestamp(timestamp_1)
625             tmpl_attr.set(values[0])
626             self.assertEqual(len(tmpl_attr.history), 1)
627             # check we can access different values with .newest and .at 
628             tmpl_attr.set(values[1])
629             self.assertEqual(tmpl_attr.newest, values[1])
630             self.assertEqual(tmpl_attr.at(timestamp_1), values[0])
631             # check attribute history stored in DB with parent
632             tmpl.save(self.db_conn)
633             retrieved = TodoTemplate.by_id(self.db_conn, tmpl.id_)
634             self.assertEqual(getattr(retrieved, name).at(timestamp_1), values[0])  # i.e. attribute.save() works
635             # check forks can use original's history, but only up to their fork moment
636             fork = tmpl.fork(self.db_conn)
637             wait_till_next_timestamp(timestamp_2)
638             tmpl_attr.set(values[2])
639             forked_attr = getattr(fork, name)
640             self.assertEqual(forked_attr.newest, values[1])
641             self.assertEqual(forked_attr.at(timestamp_1), values[0])
642             # check original and fork bifurcate their history 
643             forked_attr.set(values[0])
644             self.assertEqual(forked_attr.newest, values[0])
645             self.assertEqual(tmpl_attr.newest, values[2])
646         test('title', 'UNNAMED', ['foo', 'bar', 'baz'])
647         test('default_effort', 1.0, [0.5, 3, 9])
648         test('description', '', ['foo', 'bar', 'baz'])
649
650
651
652 class TestSansDB(TestCase):
653
654     def test_Day_date_validate(self):
655         self.assertEqual(None, Day.date_valid('foo'))
656         self.assertEqual(None, Day.date_valid('2024-02-30'))
657         self.assertEqual(None, Day.date_valid('2024-01-01 23:59:59'))
658         self.assertEqual(datetime(2024,1,1), Day.date_valid('2024-01-01'))
659
660     def test_Day_date_classmethods(self):
661         self.assertEqual(str(datetime.now())[:10], Day.todays_date())
662         self.assertEqual(str(datetime.now())[:DATETIME_KEY_RESOLUTION], Day.todays_date(with_time=True))
663         self.assertEqual(str(datetime.now() - timedelta(days=1))[:10], Day.yesterdays_date())
664         self.assertEqual(str(datetime.now() + timedelta(days=1))[:10], Day.tomorrows_date())
665
666     def test_Day_init(self):
667         test_date = '2024-02-29'
668         test_comment = 'foo'
669         day = Day(test_date)
670         self.assertEqual(day.date, test_date)
671         self.assertEqual(day.comment, '')
672         day = Day(test_date, test_comment)
673         self.assertEqual(day.comment, test_comment)
674         with self.assertRaises(HandledException):
675             Day('foo')
676
677     def test_Day_date_neighbors(self):
678         day = Day('2024-02-29')
679         self.assertEqual(day.prev_date, '2024-02-28')
680         self.assertEqual(day.next_date, '2024-03-01')
681
682     def test_Day_date_weekday(self):
683         day = Day('2024-02-29')
684         self.assertEqual(day.weekday, 'Thursday')
685
686     def test_Day_cmp(self):
687         day1 = Day('2024-01-01')
688         day2 = Day('2024-01-02')
689         day3 = Day('2024-01-03')
690         days = [day3, day1, day2]
691         self.assertEqual(sorted(days), [day1, day2, day3])
692
693     def test_ParamsParser(self):
694         from urllib.parse import urlencode
695         q = urlencode({'is_foo': 'foo', 'is_whitespace': '', 'is_None': None})
696         c = {'is_bar': 'bar'}
697         p = ParamsParser(q, c)
698         # check basic retrieval and default behavior
699         self.assertEqual(p.get('is_foo', None), 'foo')
700         self.assertEqual(p.get('missing', None), None)
701         self.assertEqual(p.get('missing', 'default'), 'default')
702         # check handling of empty and None values
703         self.assertEqual(p.get('missing', ''), '')
704         self.assertEqual(p.get('is_whitespace', None), '')
705         self.assertEqual(p.get('is_None', None), 'None')  # TODO: unwanted behavior, or urlencode fault?
706         # check retrieval and setting of cookied values
707         self.assertEqual(p.get_cookied('missing', None), None)
708         self.assertEqual(c['missing'], None)
709         self.assertEqual(p.get_cookied('missing', 'default'), None)
710         self.assertEqual(p.get_cookied('is_bar', None), 'bar')