home · contact · privacy
Slightly improve and re-organize Condition tests.
[plomtask] / plomtask / http.py
index 28812dffc4bd53e44dbb6aef640ebadd881df8d4..b7040f76fa9c3c1d58b0ceedc58fabf02752f616 100644 (file)
@@ -3,6 +3,7 @@ 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
@@ -137,6 +138,20 @@ 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."""
@@ -163,6 +178,38 @@ class TaskHandler(BaseHTTPRequestHandler):
     @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:
@@ -179,6 +226,8 @@ class TaskHandler(BaseHTTPRequestHandler):
                     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)
@@ -187,9 +236,8 @@ class TaskHandler(BaseHTTPRequestHandler):
                         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:
@@ -386,23 +434,7 @@ class TaskHandler(BaseHTTPRequestHandler):
         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}
@@ -410,17 +442,9 @@ class TaskHandler(BaseHTTPRequestHandler):
     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}
@@ -448,15 +472,22 @@ class TaskHandler(BaseHTTPRequestHandler):
     @_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,
@@ -483,25 +514,9 @@ class TaskHandler(BaseHTTPRequestHandler):
     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
@@ -552,13 +567,14 @@ class TaskHandler(BaseHTTPRequestHandler):
         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)