home · contact · privacy
Slightly improve and re-organize Condition tests.
[plomtask] / plomtask / http.py
index 4bf58b41a2cb264a0a40bf018ee9c5536e358bda..b7040f76fa9c3c1d58b0ceedc58fabf02752f616 100644 (file)
@@ -1,15 +1,21 @@
 """Web server stuff."""
-from typing import Any
+from __future__ import annotations
+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
+from json import dumps as json_dumps
 from os.path import split as path_split
 from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader
-from plomtask.days import Day, todays_date
-from plomtask.exceptions import HandledException, BadFormatException, \
-        NotFoundException
+from plomtask.dating import date_in_n_days
+from plomtask.days import Day
+from plomtask.exceptions import (HandledException, BadFormatException,
+                                 NotFoundException)
 from plomtask.db import DatabaseConnection, DatabaseFile
-from plomtask.processes import Process
+from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
 
@@ -23,7 +29,47 @@ class TaskServer(HTTPServer):
                  *args: Any, **kwargs: Any) -> None:
         super().__init__(*args, **kwargs)
         self.db = db_file
-        self.jinja = JinjaEnv(loader=JinjaFSLoader(TEMPLATES_DIR))
+        self.headers: list[tuple[str, str]] = []
+        self._render_mode = 'html'
+        self._jinja = JinjaEnv(loader=JinjaFSLoader(TEMPLATES_DIR))
+
+    def set_json_mode(self) -> None:
+        """Make server send JSON instead of HTML responses."""
+        self._render_mode = 'json'
+        self.headers += [('Content-Type', 'application/json')]
+
+    @staticmethod
+    def ctx_to_json(ctx: dict[str, object]) -> str:
+        """Render ctx into JSON string."""
+        def walk_ctx(node: object) -> Any:
+            if hasattr(node, 'as_dict_into_reference'):
+                if hasattr(node, 'id_') and node.id_ is not None:
+                    return node.as_dict_into_reference(library)
+            if hasattr(node, 'as_dict'):
+                return node.as_dict
+            if isinstance(node, (list, tuple)):
+                return [walk_ctx(x) for x in node]
+            if isinstance(node, dict):
+                d = {}
+                for k, v in node.items():
+                    d[k] = walk_ctx(v)
+                return d
+            if isinstance(node, HandledException):
+                return str(node)
+            return node
+        library: dict[str, dict[str | int, object]] = {}
+        for k, v in ctx.items():
+            ctx[k] = walk_ctx(v)
+        ctx['_library'] = library
+        return json_dumps(ctx)
+
+    def render(self, ctx: dict[str, object], tmpl_name: str = '') -> str:
+        """Render ctx according to self._render_mode.."""
+        tmpl_name = f'{tmpl_name}.{self._render_mode}'
+        if 'html' == self._render_mode:
+            template = self._jinja.get_template(tmpl_name)
+            return template.render(ctx)
+        return self.__class__.ctx_to_json(ctx)
 
 
 class InputsParser:
@@ -43,6 +89,13 @@ class InputsParser:
             return default
         return self.inputs[key][0]
 
+    def get_first_strings_starting(self, prefix: str) -> dict[str, str]:
+        """Retrieve dict of (first) strings at key starting with prefix."""
+        ret = {}
+        for key in [k for k in self.inputs.keys() if k.startswith(prefix)]:
+            ret[key] = self.inputs[key][0]
+        return ret
+
     def get_int(self, key: str) -> int:
         """Retrieve single/first value of key as int, error if empty."""
         val = self.get_int_or_none(key)
@@ -85,194 +138,626 @@ class InputsParser:
             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."""
+    # pylint: disable=too-many-public-methods
     server: TaskServer
-
-    def do_GET(self) -> None:
-        """Handle any GET request."""
-        try:
-            self._init_handling()
-            if self.site in {'calendar', 'day', 'process', 'processes', 'todo',
-                             'condition', 'conditions'}:
-                template = f'{self.site}.html'
-                ctx = getattr(self, f'do_GET_{self.site}')()
-                html = self.server.jinja.get_template(template).render(**ctx)
-                self._send_html(html)
-            elif '' == self.site:
-                self._redirect('/day')
-            else:
-                raise NotFoundException(f'Unknown page: /{self.site}')
-        except HandledException as error:
-            self._send_msg(error, code=error.http_code)
-        finally:
-            self.conn.close()
+    conn: DatabaseConnection
+    _site: str
+    _form_data: InputsParser
+    _params: InputsParser
+
+    def _send_page(self,
+                   ctx: dict[str, Any],
+                   tmpl_name: str,
+                   code: int = 200
+                   ) -> None:
+        """Send ctx as proper HTTP response."""
+        body = self.server.render(ctx, tmpl_name)
+        self.send_response(code)
+        for header_tuple in self.server.headers:
+            self.send_header(*header_tuple)
+        self.end_headers()
+        self.wfile.write(bytes(body, 'utf-8'))
+
+    @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:
+                # pylint: disable=protected-access
+                # (because pylint here fails to detect the use of wrapper as a
+                # method to self with respective access privileges)
+                try:
+                    self.conn = DatabaseConnection(self.server.db)
+                    parsed_url = urlparse(self.path)
+                    self._site = path_split(parsed_url.path)[1]
+                    params = parse_qs(parsed_url.query, strict_parsing=True)
+                    self._params = InputsParser(params, False)
+                    handler_name = f'do_{http_method}_{self._site}'
+                    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)
+                            self.end_headers()
+                    else:
+                        msg = f'{not_found_msg}: {self._site}'
+                        raise NotFoundException(msg)
+                except HandledException as error:
+                    if 'POST' == http_method:
+                        clear_caches()
+                    ctx = {'msg': error}
+                    self._send_page(ctx, 'msg', error.http_code)
+                finally:
+                    self.conn.close()
+            return wrapper
+        return decorator
+
+    @_request_wrapper('GET', 'Unknown page')
+    def do_GET(self, handler: Callable[[], str | dict[str, object]]
+               ) -> str | None:
+        """Render page with result of handler, or redirect if result is str."""
+        tmpl_name = f'{self._site}'
+        ctx_or_redir_target = handler()
+        if isinstance(ctx_or_redir_target, str):
+            return ctx_or_redir_target
+        self._send_page(ctx_or_redir_target, tmpl_name)
+        return None
+
+    @_request_wrapper('POST', 'Unknown POST target')
+    def do_POST(self, handler: Callable[[], str]) -> str:
+        """Handle POST with handler, prepare redirection to result."""
+        length = int(self.headers['content-length'])
+        postvars = parse_qs(self.rfile.read(length).decode(),
+                            keep_blank_values=True, strict_parsing=True)
+        self._form_data = InputsParser(postvars)
+        redir_target = handler()
+        self.conn.commit()
+        return redir_target
+
+    # GET handlers
+
+    @staticmethod
+    def _get_item(target_class: Any
+                  ) -> Callable[..., Callable[[TaskHandler],
+                                              dict[str, object]]]:
+        def decorator(f: Callable[..., dict[str, object]]
+                      ) -> Callable[[TaskHandler], dict[str, object]]:
+            def wrapper(self: TaskHandler) -> dict[str, object]:
+                # pylint: disable=protected-access
+                # (because pylint here fails to detect the use of wrapper as a
+                # method to self with respective access privileges)
+                id_ = self._params.get_int_or_none('id')
+                if target_class.can_create_by_id:
+                    item = target_class.by_id_or_create(self.conn, id_)
+                else:
+                    item = target_class.by_id(self.conn, id_)
+                return f(self, item)
+            return wrapper
+        return decorator
+
+    def do_GET_(self) -> str:
+        """Return redirect target on GET /."""
+        return '/day'
+
+    def _do_GET_calendar(self) -> dict[str, object]:
+        """Show Days from ?start= to ?end=.
+
+        Both .do_GET_calendar and .do_GET_calendar_txt refer to this to do the
+        same, the only difference being the HTML template they are rendered to,
+        which .do_GET selects from their method name.
+        """
+        start = self._params.get_str('start')
+        end = self._params.get_str('end')
+        if not end:
+            end = date_in_n_days(366)
+        ret = Day.by_date_range_with_limits(self.conn, (start, end), 'id')
+        days, start, end = ret
+        days = Day.with_filled_gaps(days, start, end)
+        today = date_in_n_days(0)
+        return {'start': start, 'end': end, 'days': days, 'today': today}
 
     def do_GET_calendar(self) -> dict[str, object]:
-        """Show Days from ?start= to ?end=."""
-        start = self.params.get_str('start')
-        end = self.params.get_str('end')
-        days = Day.all(self.conn, date_range=(start, end), fill_gaps=True)
-        return {'start': start, 'end': end, 'days': days}
+        """Show Days from ?start= to ?end= – normal view."""
+        return self._do_GET_calendar()
+
+    def do_GET_calendar_txt(self) -> dict[str, object]:
+        """Show Days from ?start= to ?end= – minimalist view."""
+        return self._do_GET_calendar()
 
     def do_GET_day(self) -> dict[str, object]:
         """Show single Day of ?date=."""
-        date = self.params.get_str('date', todays_date())
-        conditions_listing = []
-        for condition in Condition.all(self.conn):
-            enablers = Todo.enablers_for_at(self.conn, condition, date)
-            disablers = Todo.disablers_for_at(self.conn, condition, date)
-            conditions_listing += [{
-                    'condition': condition,
-                    'enablers': enablers,
-                    'disablers': disablers}]
-        return {'day': Day.by_id(self.conn, date, create=True),
-                'todos': Todo.by_date(self.conn, date),
-                'processes': Process.all(self.conn),
-                'conditions_listing': conditions_listing}
-
-    def do_GET_todo(self) -> dict[str, object]:
+        date = self._params.get_str('date', date_in_n_days(0))
+        day = Day.by_id_or_create(self.conn, date)
+        make_type = self._params.get_str('make_type')
+        conditions_present = []
+        enablers_for = {}
+        disablers_for = {}
+        for todo in day.todos:
+            for condition in todo.conditions + todo.blockers:
+                if condition not in conditions_present:
+                    conditions_present += [condition]
+                    enablers_for[condition.id_] = [p for p in
+                                                   Process.all(self.conn)
+                                                   if condition in p.enables]
+                    disablers_for[condition.id_] = [p for p in
+                                                    Process.all(self.conn)
+                                                    if condition in p.disables]
+        seen_todos: set[int] = set()
+        top_nodes = [t.get_step_tree(seen_todos)
+                     for t in day.todos if not t.parents]
+        return {'day': day,
+                'top_nodes': top_nodes,
+                'make_type': make_type,
+                'enablers_for': enablers_for,
+                'disablers_for': disablers_for,
+                'conditions_present': conditions_present,
+                'processes': Process.all(self.conn)}
+
+    @_get_item(Todo)
+    def do_GET_todo(self, todo: Todo) -> dict[str, object]:
         """Show single Todo of ?id=."""
-        id_ = self.params.get_int('id')
-        todo = Todo.by_id(self.conn, id_)
-        return {'todo': todo,
-                'todo_candidates': Todo.by_date(self.conn, todo.date),
+
+        @dataclass
+        class TodoStepsNode:
+            """Collect what's useful for Todo steps tree display."""
+            id_: int
+            todo: Todo | None
+            process: Process | None
+            children: list[TodoStepsNode]  # pylint: disable=undefined-variable
+            fillable: bool = False
+
+        def walk_process_steps(id_: int,
+                               process_step_nodes: list[ProcessStepsNode],
+                               steps_nodes: list[TodoStepsNode]) -> None:
+            for process_step_node in process_step_nodes:
+                id_ += 1
+                node = TodoStepsNode(id_, None, process_step_node.process, [])
+                steps_nodes += [node]
+                walk_process_steps(id_, list(process_step_node.steps.values()),
+                                   node.children)
+
+        def walk_todo_steps(id_: int, todos: list[Todo],
+                            steps_nodes: list[TodoStepsNode]) -> None:
+            for todo in todos:
+                matched = False
+                for match in [item for item in steps_nodes
+                              if item.process
+                              and item.process == todo.process]:
+                    match.todo = todo
+                    matched = True
+                    for child in match.children:
+                        child.fillable = True
+                    walk_todo_steps(id_, todo.children, match.children)
+                if not matched:
+                    id_ += 1
+                    node = TodoStepsNode(id_, todo, None, [])
+                    steps_nodes += [node]
+                    walk_todo_steps(id_, todo.children, node.children)
+
+        def collect_adoptables_keys(steps_nodes: list[TodoStepsNode]
+                                    ) -> set[int]:
+            ids = set()
+            for node in steps_nodes:
+                if not node.todo:
+                    assert isinstance(node.process, Process)
+                    assert isinstance(node.process.id_, int)
+                    ids.add(node.process.id_)
+                ids = ids | collect_adoptables_keys(node.children)
+            return ids
+
+        todo_steps = [step.todo for step in todo.get_step_tree(set()).children]
+        process_tree = todo.process.get_steps(self.conn, None)
+        steps_todo_to_process: list[TodoStepsNode] = []
+        walk_process_steps(0, list(process_tree.values()),
+                           steps_todo_to_process)
+        for steps_node in steps_todo_to_process:
+            steps_node.fillable = True
+        walk_todo_steps(len(steps_todo_to_process), todo_steps,
+                        steps_todo_to_process)
+        adoptables: dict[int, list[Todo]] = {}
+        any_adoptables = [Todo.by_id(self.conn, t.id_)
+                          for t in Todo.by_date(self.conn, todo.date)
+                          if t.id_ is not None
+                          and t != todo]
+        for id_ in collect_adoptables_keys(steps_todo_to_process):
+            adoptables[id_] = [t for t in any_adoptables
+                               if t.process.id_ == id_]
+        return {'todo': todo, 'steps_todo_to_process': steps_todo_to_process,
+                'adoption_candidates_for': adoptables,
+                'process_candidates': Process.all(self.conn),
+                'todo_candidates': any_adoptables,
                 'condition_candidates': Condition.all(self.conn)}
 
+    def do_GET_todos(self) -> dict[str, object]:
+        """Show Todos from ?start= to ?end=, of ?process=, ?comment= pattern"""
+        sort_by = self._params.get_str('sort_by')
+        start = self._params.get_str('start')
+        end = self._params.get_str('end')
+        process_id = self._params.get_int_or_none('process_id')
+        comment_pattern = self._params.get_str('comment_pattern')
+        todos = []
+        ret = Todo.by_date_range_with_limits(self.conn, (start, end))
+        todos_by_date_range, start, end = ret
+        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)]
+        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."""
-        return {'conditions': Condition.all(self.conn)}
-
-    def do_GET_condition(self) -> dict[str, object]:
+        pattern = self._params.get_str('pattern')
+        sort_by = self._params.get_str('sort_by')
+        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(Condition)
+    def do_GET_condition(self, c: Condition) -> dict[str, object]:
         """Show Condition of ?id=."""
-        id_ = self.params.get_int_or_none('id')
-        return {'condition': Condition.by_id(self.conn, id_, create=True)}
-
-    def do_GET_process(self) -> dict[str, object]:
-        """Show process of ?id=."""
-        id_ = self.params.get_int_or_none('id')
-        process = Process.by_id(self.conn, id_, create=True)
-        return {'process': process,
-                'steps': process.get_steps(self.conn),
-                'owners': process.used_as_step_by(self.conn),
+        ps = Process.all(self.conn)
+        return {'condition': c, 'is_new': c.id_ is None,
+                'enabled_processes': [p for p in ps if c in p.conditions],
+                'disabled_processes': [p for p in ps if c in p.blockers],
+                'enabling_processes': [p for p in ps if c in p.enables],
+                'disabling_processes': [p for p in ps if c in p.disables]}
+
+    @_get_item(Condition)
+    def do_GET_condition_titles(self, c: Condition) -> dict[str, object]:
+        """Show title history of Condition of ?id=."""
+        return {'condition': c}
+
+    @_get_item(Condition)
+    def do_GET_condition_descriptions(self, c: Condition) -> dict[str, object]:
+        """Show description historys of Condition of ?id=."""
+        return {'condition': c}
+
+    @_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:
+            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 owner_ids:
+            owners += [Process.by_id(self.conn, step_id)]
+        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,
+                'steps': process.get_steps(self.conn), 'owners': owners,
+                'n_todos': len(Todo.by_process_id(self.conn, process.id_)),
                 'process_candidates': Process.all(self.conn),
                 'condition_candidates': Condition.all(self.conn)}
 
+    @_get_item(Process)
+    def do_GET_process_titles(self, p: Process) -> dict[str, object]:
+        """Show title history of Process of ?id=."""
+        return {'process': p}
+
+    @_get_item(Process)
+    def do_GET_process_descriptions(self, p: Process) -> dict[str, object]:
+        """Show description historys of Process of ?id=."""
+        return {'process': p}
+
+    @_get_item(Process)
+    def do_GET_process_efforts(self, p: Process) -> dict[str, object]:
+        """Show default effort history of Process of ?id=."""
+        return {'process': p}
+
     def do_GET_processes(self) -> dict[str, object]:
         """Show all Processes."""
-        return {'processes': Process.all(self.conn)}
-
-    def do_POST(self) -> None:
-        """Handle any POST request."""
-        # pylint: disable=attribute-defined-outside-init
-        try:
-            self._init_handling()
-            length = int(self.headers['content-length'])
-            postvars = parse_qs(self.rfile.read(length).decode(),
-                                keep_blank_values=True, strict_parsing=True)
-            self.form_data = InputsParser(postvars)
-            if self.site in ('day', 'process', 'todo', 'condition'):
-                getattr(self, f'do_POST_{self.site}')()
-                self.conn.commit()
-            else:
-                msg = f'Page not known as POST target: /{self.site}'
-                raise NotFoundException(msg)
-            self._redirect('/')
-        except HandledException as error:
-            self._send_msg(error, code=error.http_code)
-        finally:
-            self.conn.close()
-
-    def do_POST_day(self) -> None:
+        pattern = self._params.get_str('pattern')
+        sort_by = self._params.get_str('sort_by')
+        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
+
+    @staticmethod
+    def _delete_or_post(target_class: Any, redir_target: str = '/'
+                        ) -> Callable[..., Callable[[TaskHandler], str]]:
+        def decorator(f: Callable[..., str]
+                      ) -> Callable[[TaskHandler], str]:
+            def wrapper(self: TaskHandler) -> str:
+                # pylint: disable=protected-access
+                # (because pylint here fails to detect the use of wrapper as a
+                # method to self with respective access privileges)
+                id_ = self._params.get_int_or_none('id')
+                for _ in self._form_data.get_all_str('delete'):
+                    if id_ is None:
+                        msg = 'trying to delete non-saved ' +\
+                                f'{target_class.__name__}'
+                        raise NotFoundException(msg)
+                    item = target_class.by_id(self.conn, id_)
+                    item.remove(self.conn)
+                    return redir_target
+                if target_class.can_create_by_id:
+                    item = target_class.by_id_or_create(self.conn, id_)
+                else:
+                    item = target_class.by_id(self.conn, id_)
+                return f(self, item)
+            return wrapper
+        return decorator
+
+    def _change_versioned_timestamps(self, cls: Any, attr_name: str) -> str:
+        """Update history timestamps for VersionedAttribute."""
+        id_ = self._params.get_int_or_none('id')
+        item = cls.by_id(self.conn, id_)
+        attr = getattr(item, attr_name)
+        for k, v in self._form_data.get_first_strings_starting('at:').items():
+            old = k[3:]
+            if old[19:] != v:
+                attr.reset_timestamp(old, f'{v}.0')
+        attr.save(self.conn)
+        return f'/{cls.name_lowercase()}_{attr_name}s?id={item.id_}'
+
+    def do_POST_day(self) -> str:
         """Update or insert Day of date and Todos mapped to it."""
-        date = self.params.get_str('date')
-        day = Day.by_id(self.conn, date, create=True)
-        day.comment = self.form_data.get_str('comment')
+        # pylint: disable=too-many-locals
+        date = self._params.get_str('date')
+        day_comment = self._form_data.get_str('day_comment')
+        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')
+        comments = self._form_data.get_all_str('comment')
+        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)
+        day = Day.by_id_or_create(self.conn, date)
+        day.comment = day_comment
         day.save(self.conn)
-        process_id = self.form_data.get_int_or_none('new_todo')
-        if process_id is not None:
-            process = Process.by_id(self.conn, process_id)
-            todo = Todo(None, process, False, day.date)
+        for process_id in sorted(new_todos):
+            if 'empty' == make_type:
+                process = Process.by_id(self.conn, process_id)
+                todo = Todo(None, process, False, date)
+                todo.save(self.conn)
+            else:
+                Todo.create_with_children(self.conn, process_id, date)
+        for i, todo_id in enumerate(old_todos):
+            todo = Todo.by_id(self.conn, todo_id)
+            todo.is_done = is_done[i]
+            todo.comment = comments[i]
+            todo.effort = efforts[i]
             todo.save(self.conn)
+        return f'/day?date={date}&make_type={make_type}'
 
-    def do_POST_todo(self) -> None:
+    @_delete_or_post(Todo, '/')
+    def do_POST_todo(self, todo: Todo) -> str:
         """Update Todo and its children."""
-        id_ = self.params.get_int('id')
-        todo = Todo.by_id(self.conn, id_)
-        child_id = self.form_data.get_int_or_none('adopt')
-        if child_id is not None:
+        # pylint: disable=too-many-locals
+        adopted_child_ids = self._form_data.get_all_int('adopt')
+        processes_to_make_full = self._form_data.get_all_int('make_full')
+        processes_to_make_empty = self._form_data.get_all_int('make_empty')
+        fill_fors = self._form_data.get_first_strings_starting('fill_for_')
+        effort = self._form_data.get_str('effort', ignore_strict=True)
+        conditions = self._form_data.get_all_int('conditions')
+        disables = self._form_data.get_all_int('disables')
+        blockers = self._form_data.get_all_int('blockers')
+        enables = self._form_data.get_all_int('enables')
+        is_done = len(self._form_data.get_all_str('done')) > 0
+        calendarize = len(self._form_data.get_all_str('calendarize')) > 0
+        comment = self._form_data.get_str('comment', ignore_strict=True)
+        for v in fill_fors.values():
+            if v.startswith('make_empty_'):
+                processes_to_make_empty += [int(v[11:])]
+            elif v.startswith('make_full_'):
+                processes_to_make_full += [int(v[10:])]
+            elif v != 'ignore':
+                adopted_child_ids += [int(v)]
+        to_remove = []
+        for child in todo.children:
+            assert isinstance(child.id_, int)
+            if child.id_ not in adopted_child_ids:
+                to_remove += [child.id_]
+        for id_ in to_remove:
+            child = Todo.by_id(self.conn, id_)
+            todo.remove_child(child)
+        for child_id in adopted_child_ids:
+            if child_id in [c.id_ for c in todo.children]:
+                continue
             child = Todo.by_id(self.conn, child_id)
             todo.add_child(child)
-        todo.set_conditions(self.conn, self.form_data.get_all_int('condition'))
-        todo.set_enables(self.conn, self.form_data.get_all_int('enables'))
-        todo.set_disables(self.conn, self.form_data.get_all_int('disables'))
-        todo.is_done = len(self.form_data.get_all_str('done')) > 0
+        for process_id in processes_to_make_empty:
+            process = Process.by_id(self.conn, process_id)
+            made = Todo(None, process, False, todo.date)
+            made.save(self.conn)
+            todo.add_child(made)
+        for process_id in processes_to_make_full:
+            made = Todo.create_with_children(self.conn, process_id, todo.date)
+            todo.add_child(made)
+        todo.effort = float(effort) if effort else None
+        todo.set_conditions(self.conn, conditions)
+        todo.set_blockers(self.conn, blockers)
+        todo.set_enables(self.conn, enables)
+        todo.set_disables(self.conn, disables)
+        todo.is_done = is_done
+        todo.calendarize = calendarize
+        todo.comment = comment
         todo.save(self.conn)
-        for condition in todo.enables:
-            condition.save(self.conn)
-        for condition in todo.disables:
-            condition.save(self.conn)
+        return f'/todo?id={todo.id_}'
 
-    def do_POST_process(self) -> None:
+    def do_POST_process_descriptions(self) -> str:
+        """Update history timestamps for Process.description."""
+        return self._change_versioned_timestamps(Process, 'description')
+
+    def do_POST_process_efforts(self) -> str:
+        """Update history timestamps for Process.effort."""
+        return self._change_versioned_timestamps(Process, 'effort')
+
+    def do_POST_process_titles(self) -> str:
+        """Update history timestamps for Process.title."""
+        return self._change_versioned_timestamps(Process, 'title')
+
+    @_delete_or_post(Process, '/processes')
+    def do_POST_process(self, process: Process) -> str:
         """Update or insert Process of ?id= and fields defined in postvars."""
-        id_ = self.params.get_int_or_none('id')
-        process = Process.by_id(self.conn, id_, create=True)
-        process.title.set(self.form_data.get_str('title'))
-        process.description.set(self.form_data.get_str('description'))
-        process.effort.set(self.form_data.get_float('effort'))
-        process.set_conditions(self.conn,
-                               self.form_data.get_all_int('condition'))
-        process.set_enables(self.conn, self.form_data.get_all_int('enables'))
-        process.set_disables(self.conn, self.form_data.get_all_int('disables'))
-        process.save_core(self.conn)
-        assert process.id_ is not None  # for mypy
-        process.explicit_steps = []
-        steps: list[tuple[int | None, int, int | None]] = []
-        for step_id in self.form_data.get_all_int('steps'):
-            for step_process_id in self.form_data.get_all_int(
-                    f'new_step_to_{step_id}'):
-                steps += [(None, step_process_id, step_id)]
-            if step_id not in self.form_data.get_all_int('keep_step'):
-                continue
-            step_process_id = self.form_data.get_int(
-                    f'step_{step_id}_process_id')
-            parent_id = self.form_data.get_int_or_none(
-                    f'step_{step_id}_parent_id')
-            steps += [(step_id, step_process_id, parent_id)]
-        for step_process_id in self.form_data.get_all_int('new_top_step'):
-            steps += [(None, step_process_id, None)]
+        # pylint: disable=too-many-locals
+        # pylint: disable=too-many-statements
+        title = self._form_data.get_str('title')
+        description = self._form_data.get_str('description')
+        effort = self._form_data.get_float('effort')
+        conditions = self._form_data.get_all_int('conditions')
+        blockers = self._form_data.get_all_int('blockers')
+        enables = self._form_data.get_all_int('enables')
+        disables = self._form_data.get_all_int('disables')
+        calendarize = self._form_data.get_all_str('calendarize') != []
+        suppresses = self._form_data.get_all_int('suppresses')
+        step_of = self._form_data.get_all_str('step_of')
+        keep_steps = self._form_data.get_all_int('keep_step')
+        step_ids = self._form_data.get_all_int('steps')
+        new_top_steps = self._form_data.get_all_str('new_top_step')
+        step_process_id_to = {}
+        step_parent_id_to = {}
+        new_steps_to = {}
+        for step_id in step_ids:
+            name = f'new_step_to_{step_id}'
+            new_steps_to[step_id] = self._form_data.get_all_int(name)
+        for step_id in keep_steps:
+            name = f'step_{step_id}_process_id'
+            step_process_id_to[step_id] = self._form_data.get_int(name)
+            name = f'step_{step_id}_parent_id'
+            step_parent_id_to[step_id] = self._form_data.get_int_or_none(name)
+        process.title.set(title)
+        process.description.set(description)
+        process.effort.set(effort)
+        process.set_conditions(self.conn, conditions)
+        process.set_blockers(self.conn, blockers)
+        process.set_enables(self.conn, enables)
+        process.set_disables(self.conn, disables)
+        process.calendarize = calendarize
+        process.save(self.conn)
+        assert isinstance(process.id_, int)
+        new_step_title = None
+        steps: list[ProcessStep] = []
+        for step_id in keep_steps:
+            if step_id not in step_ids:
+                raise BadFormatException('trying to keep unknown step')
+            step = ProcessStep(step_id, process.id_,
+                               step_process_id_to[step_id],
+                               step_parent_id_to[step_id])
+            steps += [step]
+        for step_id in step_ids:
+            new = [ProcessStep(None, process.id_, step_process_id, step_id)
+                   for step_process_id in new_steps_to[step_id]]
+            steps += new
+        for step_identifier in new_top_steps:
+            try:
+                step_process_id = int(step_identifier)
+                step = ProcessStep(None, process.id_, step_process_id, None)
+                steps += [step]
+            except ValueError:
+                new_step_title = step_identifier
         process.set_steps(self.conn, steps)
+        process.set_step_suppressions(self.conn, suppresses)
+        owners_to_set = []
+        new_owner_title = None
+        for owner_identifier in step_of:
+            try:
+                owners_to_set += [int(owner_identifier)]
+            except ValueError:
+                new_owner_title = owner_identifier
+        process.set_owners(self.conn, owners_to_set)
+        params = f'id={process.id_}'
+        if new_step_title:
+            title_b64_encoded = b64encode(new_step_title.encode()).decode()
+            params = f'step_to={process.id_}&title_b64={title_b64_encoded}'
+        elif new_owner_title:
+            title_b64_encoded = b64encode(new_owner_title.encode()).decode()
+            params = f'has_step={process.id_}&title_b64={title_b64_encoded}'
         process.save(self.conn)
+        return f'/process?{params}'
 
-    def do_POST_condition(self) -> None:
-        """Update/insert Condition of ?id= and fields defined in postvars."""
-        id_ = self.params.get_int_or_none('id')
-        condition = Condition.by_id(self.conn, id_, create=True)
-        condition.title.set(self.form_data.get_str('title'))
-        condition.description.set(self.form_data.get_str('description'))
-        condition.save(self.conn)
+    def do_POST_condition_descriptions(self) -> str:
+        """Update history timestamps for Condition.description."""
+        return self._change_versioned_timestamps(Condition, 'description')
 
-    def _init_handling(self) -> None:
-        # pylint: disable=attribute-defined-outside-init
-        self.conn = DatabaseConnection(self.server.db)
-        parsed_url = urlparse(self.path)
-        self.site = path_split(parsed_url.path)[1]
-        params = parse_qs(parsed_url.query, strict_parsing=True)
-        self.params = InputsParser(params, False)
-
-    def _redirect(self, target: str) -> None:
-        self.send_response(302)
-        self.send_header('Location', target)
-        self.end_headers()
+    def do_POST_condition_titles(self) -> str:
+        """Update history timestamps for Condition.title."""
+        return self._change_versioned_timestamps(Condition, 'title')
 
-    def _send_html(self, html: str, code: int = 200) -> None:
-        """Send HTML as proper HTTP response."""
-        self.send_response(code)
-        self.end_headers()
-        self.wfile.write(bytes(html, 'utf-8'))
-
-    def _send_msg(self, msg: Exception, code: int = 400) -> None:
-        """Send message in HTML formatting as HTTP response."""
-        html = self.server.jinja.get_template('msg.html').render(msg=msg)
-        self._send_html(html, code)
+    @_delete_or_post(Condition, '/conditions')
+    def do_POST_condition(self, condition: Condition) -> str:
+        """Update/insert Condition of ?id= and fields defined in postvars."""
+        is_active = self._form_data.get_str('is_active') == 'True'
+        title = self._form_data.get_str('title')
+        description = self._form_data.get_str('description')
+        condition.is_active = is_active
+        condition.title.set(title)
+        condition.description.set(description)
+        condition.save(self.conn)
+        return f'/condition?id={condition.id_}'