from dataclasses import dataclass
from typing import Any, Callable
from base64 import b64encode, b64decode
+from binascii import Error as binascii_Exception
from http.server import BaseHTTPRequestHandler
from http.server import HTTPServer
from urllib.parse import urlparse, parse_qs
msg = f'cannot int a form field value for key {key} in: {all_str}'
raise BadFormatException(msg) from e
+ def get_all_floats_or_nones(self, key: str) -> list[float | None]:
+ """Retrieve list of float value at key, None if empty strings."""
+ ret: list[float | None] = []
+ for val in self.get_all_str(key):
+ if '' == val:
+ ret += [None]
+ else:
+ try:
+ ret += [float(val)]
+ except ValueError as e:
+ msg = f'cannot float form field value for key {key}: {val}'
+ raise BadFormatException(msg) from e
+ return ret
+
class TaskHandler(BaseHTTPRequestHandler):
"""Handles single HTTP request."""
@staticmethod
def _request_wrapper(http_method: str, not_found_msg: str
) -> Callable[..., Callable[[TaskHandler], None]]:
+ """Wrapper for do_GET… and do_POST… handlers, to init and clean up.
+
+ Among other things, conditionally cleans all caches, but only on POST
+ requests, as only those are expected to change the states of objects
+ that may be cached, and certainly only those are expected to write any
+ changes to the database. We want to call them as early though as
+ possible here, either exactly after the specific request handler
+ returns successfully, or right after any exception is triggered –
+ otherwise, race conditions become plausible.
+
+ Note that otherwise any POST attempt, even a failed one, may end in
+ problematic inconsistencies:
+
+ - if the POST handler experiences an Exception, changes to objects
+ won't get written to the DB, but the changed objects may remain in
+ the cache and affect other objects despite their possibly illegal
+ state
+
+ - even if an object was just saved to the DB, we cannot be sure its
+ current state is completely identical to what we'd get if loading it
+ fresh from the DB (e.g. currently Process.n_owners is only updated
+ when loaded anew via .from_table_row, nor is its state written to
+ the DB by .save; a questionable design choice, but proof that we
+ have no guarantee that objects' .save stores all their states we'd
+ prefer at their most up-to-date.
+ """
+
+ def clear_caches() -> None:
+ for cls in (Day, Todo, Condition, Process, ProcessStep):
+ assert hasattr(cls, 'empty_cache')
+ cls.empty_cache()
+
def decorator(f: Callable[..., str | None]
) -> Callable[[TaskHandler], None]:
def wrapper(self: TaskHandler) -> None:
if hasattr(self, handler_name):
handler = getattr(self, handler_name)
redir_target = f(self, handler)
+ if 'POST' == http_method:
+ clear_caches()
if redir_target:
self.send_response(302)
self.send_header('Location', redir_target)
msg = f'{not_found_msg}: {self._site}'
raise NotFoundException(msg)
except HandledException as error:
- for cls in (Day, Todo, Condition, Process, ProcessStep):
- assert hasattr(cls, 'empty_cache')
- cls.empty_cache()
+ if 'POST' == http_method:
+ clear_caches()
ctx = {'msg': error}
self._send_page(ctx, 'msg', error.http_code)
finally:
todos = [t for t in todos_by_date_range
if comment_pattern in t.comment
and ((not process_id) or t.process.id_ == process_id)]
- if sort_by == 'doneness':
- todos.sort(key=lambda t: t.is_done)
- elif sort_by == '-doneness':
- todos.sort(key=lambda t: t.is_done, reverse=True)
- elif sort_by == 'title':
- todos.sort(key=lambda t: t.title_then)
- elif sort_by == '-title':
- todos.sort(key=lambda t: t.title_then, reverse=True)
- elif sort_by == 'comment':
- todos.sort(key=lambda t: t.comment)
- elif sort_by == '-comment':
- todos.sort(key=lambda t: t.comment, reverse=True)
- elif sort_by == '-date':
- todos.sort(key=lambda t: t.date, reverse=True)
- else:
- todos.sort(key=lambda t: t.date)
- sort_by = 'title'
+ sort_by = Todo.sort_by(todos, sort_by)
return {'start': start, 'end': end, 'process_id': process_id,
'comment_pattern': comment_pattern, 'todos': todos,
'all_processes': Process.all(self.conn), 'sort_by': sort_by}
def do_GET_conditions(self) -> dict[str, object]:
"""Show all Conditions."""
pattern = self._params.get_str('pattern')
- conditions = Condition.matching(self.conn, pattern)
sort_by = self._params.get_str('sort_by')
- if sort_by == 'is_active':
- conditions.sort(key=lambda c: c.is_active)
- elif sort_by == '-is_active':
- conditions.sort(key=lambda c: c.is_active, reverse=True)
- elif sort_by == '-title':
- conditions.sort(key=lambda c: c.title.newest, reverse=True)
- else:
- conditions.sort(key=lambda c: c.title.newest)
- sort_by = 'title'
+ conditions = Condition.matching(self.conn, pattern)
+ sort_by = Condition.sort_by(conditions, sort_by)
return {'conditions': conditions,
'sort_by': sort_by,
'pattern': pattern}
@_get_item(Process)
def do_GET_process(self, process: Process) -> dict[str, object]:
"""Show Process of ?id=."""
+ owner_ids = self._params.get_all_int('step_to')
+ owned_ids = self._params.get_all_int('has_step')
title_64 = self._params.get_str('title_b64')
if title_64:
- title = b64decode(title_64.encode()).decode()
+ try:
+ title = b64decode(title_64.encode()).decode()
+ except binascii_Exception as exc:
+ msg = 'invalid base64 for ?title_b64='
+ raise BadFormatException(msg) from exc
process.title.set(title)
+ preset_top_step = None
owners = process.used_as_step_by(self.conn)
- for step_id in self._params.get_all_int('step_to'):
+ for step_id in owner_ids:
owners += [Process.by_id(self.conn, step_id)]
- preset_top_step = None
- for process_id in self._params.get_all_int('has_step'):
+ for process_id in owned_ids:
+ Process.by_id(self.conn, process_id) # to ensure ID exists
preset_top_step = process_id
return {'process': process, 'is_new': process.id_ is None,
'preset_top_step': preset_top_step,
def do_GET_processes(self) -> dict[str, object]:
"""Show all Processes."""
pattern = self._params.get_str('pattern')
- processes = Process.matching(self.conn, pattern)
sort_by = self._params.get_str('sort_by')
- if sort_by == 'steps':
- processes.sort(key=lambda p: len(p.explicit_steps))
- elif sort_by == '-steps':
- processes.sort(key=lambda p: len(p.explicit_steps), reverse=True)
- elif sort_by == 'owners':
- processes.sort(key=lambda p: p.n_owners or 0)
- elif sort_by == '-owners':
- processes.sort(key=lambda p: p.n_owners or 0, reverse=True)
- elif sort_by == 'effort':
- processes.sort(key=lambda p: p.effort.newest)
- elif sort_by == '-effort':
- processes.sort(key=lambda p: p.effort.newest, reverse=True)
- elif sort_by == '-title':
- processes.sort(key=lambda p: p.title.newest, reverse=True)
- else:
- processes.sort(key=lambda p: p.title.newest)
- sort_by = 'title'
+ processes = Process.matching(self.conn, pattern)
+ sort_by = Process.sort_by(processes, sort_by)
return {'processes': processes, 'sort_by': sort_by, 'pattern': pattern}
# POST handlers
make_type = self._form_data.get_str('make_type')
old_todos = self._form_data.get_all_int('todo_id')
new_todos = self._form_data.get_all_int('new_todo')
- is_done = [t_id in self._form_data.get_all_int('done')
- for t_id in old_todos]
comments = self._form_data.get_all_str('comment')
- efforts = [float(effort) if effort else None
- for effort in self._form_data.get_all_str('effort')]
- if old_todos and 3*[len(old_todos)] != [len(is_done), len(comments),
- len(efforts)]:
+ efforts = self._form_data.get_all_floats_or_nones('effort')
+ done_todos = self._form_data.get_all_int('done')
+ for _ in [id_ for id_ in done_todos if id_ not in old_todos]:
+ raise BadFormatException('"done" field refers to unknown Todo')
+ is_done = [t_id in done_todos for t_id in old_todos]
+ if not (len(old_todos) == len(is_done) == len(comments)
+ == len(efforts)):
msg = 'not equal number each of number of todo_id, comments, ' +\
'and efforts inputs'
raise BadFormatException(msg)