home · contact · privacy
Enable server to alternatively output response ctx as JSON, for debugging and testing...
[plomtask] / plomtask / http.py
index 583203e6eba7a69bbee893d502b13bd132eb69d9..d602f07b9baf3c18df19fa88435928d2a33845d6 100644 (file)
@@ -1,11 +1,12 @@
 """Web server stuff."""
 from __future__ import annotations
 from dataclasses import dataclass
-from typing import Any
+from typing import Any, Callable
 from base64 import b64encode, b64decode
 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.dating import date_in_n_days
@@ -16,20 +17,11 @@ from plomtask.db import DatabaseConnection, DatabaseFile
 from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
+from plomtask.db import BaseModel
 
 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."""
 
@@ -37,7 +29,37 @@ 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 isinstance(node, BaseModel):
+                return node.as_dict
+            if isinstance(node, (list, tuple)):
+                return [walk_ctx(x) for x in node]
+            if isinstance(node, HandledException):
+                return str(node)
+            return node
+        for k, v in ctx.items():
+            ctx[k] = walk_ctx(v)
+        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:
@@ -116,26 +138,91 @@ class TaskHandler(BaseHTTPRequestHandler):
     _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_page(self,
+                   ctx: dict[str, Any],
+                   tmpl_name: str,
+                   code: int = 200
+                   ) -> None:
+        """Send HTML 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]]:
+        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 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:
+                    for cls in (Day, Todo, Condition, Process, ProcessStep):
+                        assert hasattr(cls, 'empty_cache')
+                        cls.empty_cache()
+                    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
+
+    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=."""
+        """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:
@@ -143,8 +230,6 @@ class TaskHandler(BaseHTTPRequestHandler):
         ret = Day.by_date_range_with_limits(self.conn, (start, end), 'id')
         days, start, end = ret
         days = Day.with_filled_gaps(days, start, end)
-        for day in days:
-            day.collect_calendarized_todos(self.conn)
         today = date_in_n_days(0)
         return {'start': start, 'end': end, 'days': days, 'today': today}
 
@@ -159,15 +244,12 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_GET_day(self) -> dict[str, object]:
         """Show single Day of ?date=."""
         date = self._params.get_str('date', date_in_n_days(0))
+        day = Day.by_id(self.conn, date, create=True)
         make_type = self._params.get_str('make_type')
-        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 = {}
-        for todo in todays_todos:
+        for todo in day.todos:
             for condition in todo.conditions + todo.blockers:
                 if condition not in conditions_present:
                     conditions_present += [condition]
@@ -179,9 +261,8 @@ class TaskHandler(BaseHTTPRequestHandler):
                                                     if condition in p.disables]
         seen_todos: set[int] = set()
         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,
+                     for t in day.todos if not t.parents]
+        return {'day': day,
                 'top_nodes': top_nodes,
                 'make_type': make_type,
                 'enablers_for': enablers_for,
@@ -192,6 +273,15 @@ class TaskHandler(BaseHTTPRequestHandler):
     def do_GET_todo(self) -> dict[str, object]:
         """Show single Todo of ?id=."""
 
+        @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:
@@ -390,25 +480,20 @@ class TaskHandler(BaseHTTPRequestHandler):
             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."""
-        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()
+    # POST handlers
+
+    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)
+        cls_name = cls.__name__.lower()
+        return f'/{cls_name}_{attr_name}s?id={item.id_}'
 
     def do_POST_day(self) -> str:
         """Update or insert Day of date and Todos mapped to it."""
@@ -435,10 +520,6 @@ class TaskHandler(BaseHTTPRequestHandler):
             if len(efforts) > 0:
                 todo.effort = float(efforts[i]) if efforts[i] else None
             todo.save(self.conn)
-            for condition in todo.enables:
-                condition.save(self.conn)
-            for condition in todo.disables:
-                condition.save(self.conn)
         return f'/day?date={date}&make_type={make_type}'
 
     def do_POST_todo(self) -> str:
@@ -494,36 +575,19 @@ class TaskHandler(BaseHTTPRequestHandler):
         todo.calendarize = len(self._form_data.get_all_str('calendarize')) > 0
         todo.comment = self._form_data.get_str('comment', ignore_strict=True)
         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_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')
+        return self._change_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')
+        return self._change_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')
+        return self._change_versioned_timestamps(Process, 'title')
 
     def do_POST_process(self) -> str:
         """Update or insert Process of ?id= and fields defined in postvars."""
@@ -572,12 +636,10 @@ class TaskHandler(BaseHTTPRequestHandler):
                                       None)]
             except ValueError:
                 new_step_title = step_identifier
-        process.uncache()
         process.set_steps(self.conn, steps)
         process.set_step_suppressions(self.conn,
                                       self._form_data.
                                       get_all_int('suppresses'))
-        process.save(self.conn)
         owners_to_set = []
         new_owner_title = None
         for owner_identifier in self._form_data.get_all_str('step_of'):
@@ -593,15 +655,16 @@ class TaskHandler(BaseHTTPRequestHandler):
         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_descriptions(self) -> str:
         """Update history timestamps for Condition.description."""
-        return self._do_POST_versioned_timestamps(Condition, 'description')
+        return self._change_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')
+        return self._change_versioned_timestamps(Condition, 'title')
 
     def do_POST_condition(self) -> str:
         """Update/insert Condition of ?id= and fields defined in postvars."""
@@ -616,28 +679,3 @@ class TaskHandler(BaseHTTPRequestHandler):
         condition.description.set(self._form_data.get_str('description'))
         condition.save(self.conn)
         return f'/condition?id={condition.id_}'
-
-    def _init_handling(self) -> None:
-        """Our own __init__, as we're not supposed to use the original."""
-        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:
-        """Redirect to target."""
-        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)