home · contact · privacy
Improve clarity of request wrapping code.
[plomtask] / plomtask / http.py
index 1a06f3094c9c66a95f73b9a13e52d7618d3b2aaa..d61588a9f7627eb7e0ce1acff8fe211d7065ba1c 100644 (file)
@@ -1,5 +1,7 @@
 """Web server stuff."""
 """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 http.server import BaseHTTPRequestHandler
 from http.server import HTTPServer
 from base64 import b64encode, b64decode
 from http.server import BaseHTTPRequestHandler
 from http.server import HTTPServer
@@ -11,13 +13,23 @@ from plomtask.days import Day
 from plomtask.exceptions import HandledException, BadFormatException, \
         NotFoundException
 from plomtask.db import DatabaseConnection, DatabaseFile
 from plomtask.exceptions import HandledException, BadFormatException, \
         NotFoundException
 from plomtask.db import DatabaseConnection, DatabaseFile
-from plomtask.processes import Process, ProcessStep
+from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
 
 TEMPLATES_DIR = 'templates'
 
 
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
 
 TEMPLATES_DIR = 'templates'
 
 
+@dataclass
+class TodoStepsNode:
+    """Collect what's useful for Todo steps tree display."""
+    id_: int
+    todo: Todo | None
+    process: Process | None
+    children: list[TodoStepsNode]
+    fillable: bool = False
+
+
 class TaskServer(HTTPServer):
     """Variant of HTTPServer that knows .jinja as Jinja Environment."""
 
 class TaskServer(HTTPServer):
     """Variant of HTTPServer that knows .jinja as Jinja Environment."""
 
@@ -45,6 +57,13 @@ class InputsParser:
             return default
         return self.inputs[key][0]
 
             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)
     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)
@@ -90,32 +109,81 @@ class InputsParser:
 
 class TaskHandler(BaseHTTPRequestHandler):
     """Handles single HTTP request."""
 
 class TaskHandler(BaseHTTPRequestHandler):
     """Handles single HTTP request."""
+    # pylint: disable=too-many-public-methods
     server: TaskServer
     server: TaskServer
+    conn: DatabaseConnection
+    site: str
+    form_data: InputsParser
+    params: InputsParser
 
 
-    def do_GET(self) -> None:
-        """Handle any GET request."""
-        try:
-            self._init_handling()
-            if hasattr(self, f'do_GET_{self.site}'):
-                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()
+    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 do_GET_calendar(self) -> dict[str, object]:
+    @staticmethod
+    def _request_wrapper(http_method: str, not_found_msg: str
+                         ) -> Callable[..., Callable[[TaskHandler], None]]:
+        def decorator(f: Callable[..., str | None]
+                      ) -> Callable[[TaskHandler], None]:
+            def wrapper(self: TaskHandler) -> None:
+                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 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:
+                    html = self.server.jinja.\
+                            get_template('msg.html').render(msg=error)
+                    self.send_html(html, 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."""
+        template = f'{self.site}.html'
+        ctx_or_redir = handler()
+        if str == type(ctx_or_redir):
+            return ctx_or_redir
+        assert isinstance(ctx_or_redir, dict)
+        html = self.server.jinja.get_template(template).render(**ctx_or_redir)
+        self.send_html(html)
+        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
+
+    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')
         if not end:
         """Show Days from ?start= to ?end=."""
         start = self.params.get_str('start')
         end = self.params.get_str('end')
         if not end:
-            end = date_in_n_days(60)
+            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)
         ret = Day.by_date_range_with_limits(self.conn, (start, end), 'id')
         days, start, end = ret
         days = Day.with_filled_gaps(days, start, end)
@@ -124,10 +192,26 @@ class TaskHandler(BaseHTTPRequestHandler):
         today = date_in_n_days(0)
         return {'start': start, 'end': end, 'days': days, 'today': today}
 
         today = date_in_n_days(0)
         return {'start': start, 'end': end, 'days': days, 'today': today}
 
+    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= – 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', date_in_n_days(0))
     def do_GET_day(self) -> dict[str, object]:
         """Show single Day of ?date=."""
         date = self.params.get_str('date', date_in_n_days(0))
+        make_type = self.params.get_str('make_type')
         todays_todos = Todo.by_date(self.conn, date)
         todays_todos = Todo.by_date(self.conn, date)
+        total_effort = 0.0
+        for todo in todays_todos:
+            total_effort += todo.performed_effort
         conditions_present = []
         enablers_for = {}
         disablers_for = {}
         conditions_present = []
         enablers_for = {}
         disablers_for = {}
@@ -145,7 +229,9 @@ class TaskHandler(BaseHTTPRequestHandler):
         top_nodes = [t.get_step_tree(seen_todos)
                      for t in todays_todos if not t.parents]
         return {'day': Day.by_id(self.conn, date, create=True),
         top_nodes = [t.get_step_tree(seen_todos)
                      for t in todays_todos if not t.parents]
         return {'day': Day.by_id(self.conn, date, create=True),
+                'total_effort': total_effort,
                 'top_nodes': top_nodes,
                 'top_nodes': top_nodes,
+                'make_type': make_type,
                 'enablers_for': enablers_for,
                 'disablers_for': disablers_for,
                 'conditions_present': conditions_present,
                 'enablers_for': enablers_for,
                 'disablers_for': disablers_for,
                 'conditions_present': conditions_present,
@@ -153,11 +239,68 @@ class TaskHandler(BaseHTTPRequestHandler):
 
     def do_GET_todo(self) -> dict[str, object]:
         """Show single Todo of ?id=."""
 
     def do_GET_todo(self) -> dict[str, object]:
         """Show single Todo of ?id=."""
+
+        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
+
         id_ = self.params.get_int('id')
         todo = Todo.by_id(self.conn, id_)
         id_ = self.params.get_int('id')
         todo = Todo.by_id(self.conn, id_)
-        return {'todo': todo,
+        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 != 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),
                 'process_candidates': Process.all(self.conn),
-                'todo_candidates': Todo.by_date(self.conn, todo.date),
+                'todo_candidates': any_adoptables,
                 'condition_candidates': Condition.all(self.conn)}
 
     def do_GET_todos(self) -> dict[str, object]:
                 'condition_candidates': Condition.all(self.conn)}
 
     def do_GET_todos(self) -> dict[str, object]:
@@ -295,35 +438,20 @@ class TaskHandler(BaseHTTPRequestHandler):
             processes.sort(key=lambda p: p.title.newest)
         return {'processes': processes, 'sort_by': sort_by, 'pattern': pattern}
 
             processes.sort(key=lambda p: p.title.newest)
         return {'processes': processes, 'sort_by': sort_by, 'pattern': pattern}
 
-    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 hasattr(self, f'do_POST_{self.site}'):
-                redir_target = 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(redir_target)
-        except HandledException as error:
-            self._send_msg(error, code=error.http_code)
-        finally:
-            self.conn.close()
-
     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('day_comment')
         day.save(self.conn)
     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('day_comment')
         day.save(self.conn)
+        make_type = self.form_data.get_str('make_type')
         for process_id in sorted(self.form_data.get_all_int('new_todo')):
         for process_id in sorted(self.form_data.get_all_int('new_todo')):
-            Todo.create_with_children(self.conn, process_id, date)
+            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)
         done_ids = self.form_data.get_all_int('done')
         comments = self.form_data.get_all_str('comment')
         efforts = self.form_data.get_all_str('effort')
         done_ids = self.form_data.get_all_int('done')
         comments = self.form_data.get_all_str('comment')
         efforts = self.form_data.get_all_str('effort')
@@ -339,10 +467,12 @@ class TaskHandler(BaseHTTPRequestHandler):
                 condition.save(self.conn)
             for condition in todo.disables:
                 condition.save(self.conn)
                 condition.save(self.conn)
             for condition in todo.disables:
                 condition.save(self.conn)
-        return f'/day?date={date}'
+        return f'/day?date={date}&make_type={make_type}'
 
     def do_POST_todo(self) -> str:
         """Update Todo and its children."""
 
     def do_POST_todo(self) -> str:
         """Update Todo and its children."""
+        # pylint: disable=too-many-locals
+        # pylint: disable=too-many-branches
         id_ = self.params.get_int('id')
         for _ in self.form_data.get_all_str('delete'):
             todo = Todo .by_id(self.conn, id_)
         id_ = self.params.get_int('id')
         for _ in self.form_data.get_all_str('delete'):
             todo = Todo .by_id(self.conn, id_)
@@ -350,22 +480,41 @@ class TaskHandler(BaseHTTPRequestHandler):
             return '/'
         todo = Todo.by_id(self.conn, id_)
         adopted_child_ids = self.form_data.get_all_int('adopt')
             return '/'
         todo = Todo.by_id(self.conn, id_)
         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_')
+        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:
         for child in todo.children:
+            assert isinstance(child.id_, int)
             if child.id_ not in adopted_child_ids:
             if child.id_ not in adopted_child_ids:
-                assert isinstance(child.id_, int)
-                child = Todo.by_id(self.conn, child.id_)
-                todo.remove_child(child)
+                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)
         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)
-        for process_id in self.form_data.get_all_int('make'):
+        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)
         effort = self.form_data.get_str('effort', ignore_strict=True)
         todo.effort = float(effort) if effort else None
             made = Todo.create_with_children(self.conn, process_id, todo.date)
             todo.add_child(made)
         effort = self.form_data.get_str('effort', ignore_strict=True)
         todo.effort = float(effort) if effort else None
-        todo.set_conditions(self.conn, self.form_data.get_all_int('condition'))
+        todo.set_conditions(self.conn,
+                            self.form_data.get_all_int('condition'))
         todo.set_blockers(self.conn, self.form_data.get_all_int('blocker'))
         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.set_blockers(self.conn, self.form_data.get_all_int('blocker'))
         todo.set_enables(self.conn, self.form_data.get_all_int('enables'))
         todo.set_disables(self.conn, self.form_data.get_all_int('disables'))
@@ -379,6 +528,31 @@ class TaskHandler(BaseHTTPRequestHandler):
             condition.save(self.conn)
         return f'/todo?id={todo.id_}'
 
             condition.save(self.conn)
         return f'/todo?id={todo.id_}'
 
+    def _do_POST_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)
+        cls_name = cls.__name__.lower()
+        return f'/{cls_name}_{attr_name}s?id={item.id_}'
+
+    def do_POST_process_descriptions(self) -> str:
+        """Update history timestamps for Process.description."""
+        return self._do_POST_versioned_timestamps(Process, 'description')
+
+    def do_POST_process_efforts(self) -> str:
+        """Update history timestamps for Process.effort."""
+        return self._do_POST_versioned_timestamps(Process, 'effort')
+
+    def do_POST_process_titles(self) -> str:
+        """Update history timestamps for Process.title."""
+        return self._do_POST_versioned_timestamps(Process, 'title')
+
     def do_POST_process(self) -> str:
         """Update or insert Process of ?id= and fields defined in postvars."""
         # pylint: disable=too-many-branches
     def do_POST_process(self) -> str:
         """Update or insert Process of ?id= and fields defined in postvars."""
         # pylint: disable=too-many-branches
@@ -395,7 +569,8 @@ class TaskHandler(BaseHTTPRequestHandler):
                                self.form_data.get_all_int('condition'))
         process.set_blockers(self.conn, self.form_data.get_all_int('blocker'))
         process.set_enables(self.conn, self.form_data.get_all_int('enables'))
                                self.form_data.get_all_int('condition'))
         process.set_blockers(self.conn, self.form_data.get_all_int('blocker'))
         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.set_disables(self.conn,
+                             self.form_data.get_all_int('disables'))
         process.calendarize = self.form_data.get_all_str('calendarize') != []
         process.save(self.conn)
         assert isinstance(process.id_, int)
         process.calendarize = self.form_data.get_all_str('calendarize') != []
         process.save(self.conn)
         assert isinstance(process.id_, int)
@@ -428,7 +603,8 @@ class TaskHandler(BaseHTTPRequestHandler):
         process.uncache()
         process.set_steps(self.conn, steps)
         process.set_step_suppressions(self.conn,
         process.uncache()
         process.set_steps(self.conn, steps)
         process.set_step_suppressions(self.conn,
-                                      self.form_data.get_all_int('suppresses'))
+                                      self.form_data.
+                                      get_all_int('suppresses'))
         process.save(self.conn)
         owners_to_set = []
         new_owner_title = None
         process.save(self.conn)
         owners_to_set = []
         new_owner_title = None
@@ -447,6 +623,14 @@ class TaskHandler(BaseHTTPRequestHandler):
             params = f'has_step={process.id_}&title_b64={title_b64_encoded}'
         return f'/process?{params}'
 
             params = f'has_step={process.id_}&title_b64={title_b64_encoded}'
         return f'/process?{params}'
 
+    def do_POST_condition_descriptions(self) -> str:
+        """Update history timestamps for Condition.description."""
+        return self._do_POST_versioned_timestamps(Condition, 'description')
+
+    def do_POST_condition_titles(self) -> str:
+        """Update history timestamps for Condition.title."""
+        return self._do_POST_versioned_timestamps(Condition, 'title')
+
     def do_POST_condition(self) -> str:
         """Update/insert Condition of ?id= and fields defined in postvars."""
         id_ = self.params.get_int_or_none('id')
     def do_POST_condition(self) -> str:
         """Update/insert Condition of ?id= and fields defined in postvars."""
         id_ = self.params.get_int_or_none('id')
@@ -460,27 +644,3 @@ class TaskHandler(BaseHTTPRequestHandler):
         condition.description.set(self.form_data.get_str('description'))
         condition.save(self.conn)
         return f'/condition?id={condition.id_}'
         condition.description.set(self.form_data.get_str('description'))
         condition.save(self.conn)
         return f'/condition?id={condition.id_}'
-
-    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 _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)