"""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
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."""
_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'))
+
+ @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:
+ 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
+
+ # 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:
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:
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."""
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."""
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."""
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)