X-Git-Url: https://plomlompom.com/repos/berlin_corona.txt?a=blobdiff_plain;ds=inline;f=plomtask%2Fhttp.py;h=a5e46132dec446952cc2c98cce153c890c6c310e;hb=HEAD;hp=d602f07b9baf3c18df19fa88435928d2a33845d6;hpb=db62e6559fdd577dae38d4b6f5cbd5ef6a14cc57;p=plomtask diff --git a/plomtask/http.py b/plomtask/http.py index d602f07..15c17db 100644 --- a/plomtask/http.py +++ b/plomtask/http.py @@ -1,8 +1,8 @@ """Web server stuff.""" 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 @@ -11,13 +11,12 @@ 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 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, BaseModel from plomtask.processes import Process, ProcessStep, ProcessStepsNode from plomtask.conditions import Condition -from plomtask.todos import Todo -from plomtask.db import BaseModel +from plomtask.todos import Todo, TodoOrProcStepNode, DictableNode TEMPLATES_DIR = 'templates' @@ -29,37 +28,8 @@ class TaskServer(HTTPServer): *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.db = db_file - 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) + self.render_mode = 'html' + self.jinja = JinjaEnv(loader=JinjaFSLoader(TEMPLATES_DIR)) class InputsParser: @@ -68,14 +38,14 @@ class InputsParser: def __init__(self, dict_: dict[str, list[str]], strictness: bool = True) -> None: self.inputs = dict_ - self.strict = strictness + self.strict = strictness # return None on absence of key, or fail? def get_str(self, key: str, default: str = '', ignore_strict: bool = False) -> str: """Retrieve single/first string value of key, or default.""" if key not in self.inputs.keys() or 0 == len(self.inputs[key]): if self.strict and not ignore_strict: - raise BadFormatException(f'no value found for key {key}') + raise NotFoundException(f'no value found for key {key}') return default return self.inputs[key][0] @@ -113,6 +83,21 @@ class InputsParser: msg = f'cannot float form field value for key {key}: {val}' raise BadFormatException(msg) from e + def get_float_or_none(self, key: str) -> float | None: + """Retrieve float value of key from self.postvars, None if empty.""" + val = self.get_str(key) + if '' == val: + return None + try: + return float(val) + except ValueError as e: + msg = f'cannot float form field value for key {key}: {val}' + raise BadFormatException(msg) from e + + def get_bool(self, key: str) -> bool: + """Return if key occurs and maps to a boolean Truth.""" + return bool(self.get_all_str(key)) + def get_all_str(self, key: str) -> list[str]: """Retrieve list of string values at key.""" if key not in self.inputs.keys(): @@ -128,6 +113,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.""" @@ -138,22 +137,111 @@ class TaskHandler(BaseHTTPRequestHandler): _form_data: InputsParser _params: InputsParser - 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) + def _send_page( + self, ctx: dict[str, Any], tmpl_name: str, code: int = 200 + ) -> None: + """HTTP-send ctx as HTML or JSON, as defined by .server.render_mode. + + The differentiation by .server.render_mode serves to allow easily + comparable JSON responses for automatic testing. + """ + body: str + headers: list[tuple[str, str]] = [] + if 'html' == self.server.render_mode: + tmpl = self.server.jinja.get_template(f'{tmpl_name}.html') + body = tmpl.render(ctx) + else: + body = self._ctx_to_json(ctx) + headers += [('Content-Type', 'application/json')] self.send_response(code) - for header_tuple in self.server.headers: + for header_tuple in headers: self.send_header(*header_tuple) self.end_headers() self.wfile.write(bytes(body, 'utf-8')) + def _ctx_to_json(self, ctx: dict[str, object]) -> str: + """Render ctx into JSON string. + + Flattens any objects that json.dumps might not want to serialize, and + turns occurrences of BaseModel objects into listings of their .id_, to + be resolved to a full dict inside a top-level '_library' dictionary, + to avoid endless and circular nesting. + """ + + def flatten(node: object) -> object: + + def update_library_with( + item: BaseModel[int] | BaseModel[str]) -> None: + cls_name = item.__class__.__name__ + if cls_name not in library: + library[cls_name] = {} + if item.id_ not in library[cls_name]: + d, refs = item.as_dict_and_refs + id_key = '?' if item.id_ is None else item.id_ + library[cls_name][id_key] = d + for ref in refs: + update_library_with(ref) + + if isinstance(node, BaseModel): + update_library_with(node) + return node.id_ + if isinstance(node, DictableNode): + d, refs = node.as_dict_and_refs + for ref in refs: + update_library_with(ref) + return d + if isinstance(node, (list, tuple)): + return [flatten(item) for item in node] + if isinstance(node, dict): + d = {} + for k, v in node.items(): + d[k] = flatten(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] = flatten(v) + ctx['_library'] = library + return json_dumps(ctx) + @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: @@ -170,6 +258,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) @@ -178,9 +268,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: @@ -212,6 +301,25 @@ class TaskHandler(BaseHTTPRequestHandler): # 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' @@ -223,12 +331,10 @@ class TaskHandler(BaseHTTPRequestHandler): 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 + start, end = self._params.get_str('start'), self._params.get_str('end') + end = end if end else date_in_n_days(366) + days, start, end = Day.by_date_range_with_limits(self.conn, + (start, end), 'id') days = Day.with_filled_gaps(days, start, end) today = date_in_n_days(0) return {'start': start, 'end': end, 'days': days, 'today': today} @@ -244,7 +350,7 @@ 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) + day = Day.by_id_or_create(self.conn, date) make_type = self._params.get_str('make_type') conditions_present = [] enablers_for = {} @@ -270,30 +376,25 @@ class TaskHandler(BaseHTTPRequestHandler): 'conditions_present': conditions_present, 'processes': Process.all(self.conn)} - def do_GET_todo(self) -> dict[str, object]: + @_get_item(Todo) + def do_GET_todo(self, todo: Todo) -> 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, + def walk_process_steps(node_id: int, process_step_nodes: list[ProcessStepsNode], - steps_nodes: list[TodoStepsNode]) -> None: + steps_nodes: list[TodoOrProcStepNode]) -> int: for process_step_node in process_step_nodes: - id_ += 1 - node = TodoStepsNode(id_, None, process_step_node.process, []) + node_id += 1 + node = TodoOrProcStepNode(node_id, None, + process_step_node.process, []) steps_nodes += [node] - walk_process_steps(id_, list(process_step_node.steps.values()), - node.children) + node_id = walk_process_steps( + node_id, list(process_step_node.steps.values()), + node.children) + return node_id - def walk_todo_steps(id_: int, todos: list[Todo], - steps_nodes: list[TodoStepsNode]) -> None: + def walk_todo_steps(node_id: int, todos: list[Todo], + steps_nodes: list[TodoOrProcStepNode]) -> int: for todo in todos: matched = False for match in [item for item in steps_nodes @@ -303,15 +404,18 @@ class TaskHandler(BaseHTTPRequestHandler): matched = True for child in match.children: child.fillable = True - walk_todo_steps(id_, todo.children, match.children) + node_id = walk_todo_steps( + node_id, todo.children, match.children) if not matched: - id_ += 1 - node = TodoStepsNode(id_, todo, None, []) + node_id += 1 + node = TodoOrProcStepNode(node_id, todo, None, []) steps_nodes += [node] - walk_todo_steps(id_, todo.children, node.children) + node_id = walk_todo_steps( + node_id, todo.children, node.children) + return node_id - def collect_adoptables_keys(steps_nodes: list[TodoStepsNode] - ) -> set[int]: + def collect_adoptables_keys( + steps_nodes: list[TodoOrProcStepNode]) -> set[int]: ids = set() for node in steps_nodes: if not node.todo: @@ -321,27 +425,26 @@ class TaskHandler(BaseHTTPRequestHandler): ids = ids | collect_adoptables_keys(node.children) return ids - id_ = self._params.get_int('id') - todo = Todo.by_id(self.conn, id_) 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) + steps_todo_to_process: list[TodoOrProcStepNode] = [] + last_node_id = 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) + walk_todo_steps(last_node_id, 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] + 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, + return {'todo': todo, + 'steps_todo_to_process': steps_todo_to_process, 'adoption_candidates_for': adoptables, - 'process_candidates': Process.all(self.conn), + 'process_candidates': sorted(Process.all(self.conn)), 'todo_candidates': any_adoptables, 'condition_candidates': Condition.all(self.conn)} @@ -358,22 +461,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 = 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} @@ -381,24 +469,16 @@ 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) + conditions = Condition.matching(self.conn, pattern) + sort_by = Condition.sort_by(conditions, sort_by) return {'conditions': conditions, 'sort_by': sort_by, 'pattern': pattern} - def do_GET_condition(self) -> dict[str, object]: + @_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') - c = Condition.by_id(self.conn, id_, create=True) 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], @@ -406,31 +486,35 @@ class TaskHandler(BaseHTTPRequestHandler): 'enabling_processes': [p for p in ps if c in p.enables], 'disabling_processes': [p for p in ps if c in p.disables]} - def do_GET_condition_titles(self) -> dict[str, object]: + @_get_item(Condition) + def do_GET_condition_titles(self, c: Condition) -> dict[str, object]: """Show title history of Condition of ?id=.""" - id_ = self._params.get_int_or_none('id') - condition = Condition.by_id(self.conn, id_) - return {'condition': condition} + return {'condition': c} - def do_GET_condition_descriptions(self) -> dict[str, object]: + @_get_item(Condition) + def do_GET_condition_descriptions(self, c: Condition) -> dict[str, object]: """Show description historys of Condition of ?id=.""" - id_ = self._params.get_int_or_none('id') - condition = Condition.by_id(self.conn, id_) - return {'condition': condition} + return {'condition': c} - def do_GET_process(self) -> dict[str, object]: + @_get_item(Process) + def do_GET_process(self, process: Process) -> dict[str, object]: """Show Process of ?id=.""" - id_ = self._params.get_int_or_none('id') - process = Process.by_id(self.conn, id_, create=True) + 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, @@ -439,49 +523,57 @@ class TaskHandler(BaseHTTPRequestHandler): 'process_candidates': Process.all(self.conn), 'condition_candidates': Condition.all(self.conn)} - def do_GET_process_titles(self) -> dict[str, object]: + @_get_item(Process) + def do_GET_process_titles(self, p: Process) -> dict[str, object]: """Show title history of Process of ?id=.""" - id_ = self._params.get_int_or_none('id') - process = Process.by_id(self.conn, id_) - return {'process': process} + return {'process': p} - def do_GET_process_descriptions(self) -> dict[str, object]: + @_get_item(Process) + def do_GET_process_descriptions(self, p: Process) -> dict[str, object]: """Show description historys of Process of ?id=.""" - id_ = self._params.get_int_or_none('id') - process = Process.by_id(self.conn, id_) - return {'process': process} + return {'process': p} - def do_GET_process_efforts(self) -> dict[str, object]: + @_get_item(Process) + def do_GET_process_efforts(self, p: Process) -> dict[str, object]: """Show default effort history of Process of ?id=.""" - id_ = self._params.get_int_or_none('id') - process = Process.by_id(self.conn, id_) - return {'process': process} + return {'process': p} 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) + 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') @@ -492,57 +584,84 @@ class TaskHandler(BaseHTTPRequestHandler): 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_}' + 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('day_comment') + # pylint: disable=too-many-locals + try: + date = self._params.get_str('date') + day_comment = self._form_data.get_str('day_comment') + make_type = self._form_data.get_str('make_type') + except NotFoundException as e: + raise BadFormatException from e + 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) - 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(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) - 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') - for i, todo_id in enumerate(self._form_data.get_all_int('todo_id')): + for i, todo_id in enumerate(old_todos): todo = Todo.by_id(self.conn, todo_id) - todo.is_done = todo_id in done_ids - if len(comments) > 0: - todo.comment = comments[i] - if len(efforts) > 0: - todo.effort = float(efforts[i]) if efforts[i] else None + 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) -> str: + @_delete_or_post(Todo, '/') + def do_POST_todo(self, todo: Todo) -> 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_) - todo.remove(self.conn) - 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)] + step_fillers = self._form_data.get_all_str('step_filler') + to_update = { + 'is_done': self._form_data.get_bool('done'), + 'calendarize': self._form_data.get_bool('calendarize'), + 'comment': self._form_data.get_str('comment', ignore_strict=True)} + try: + to_update['effort'] = self._form_data.get_float_or_none('effort') + except NotFoundException: + pass + cond_rel_id_lists = [self._form_data.get_all_int(name) for name in + ['conditions', 'blockers', 'enables', 'disables']] + for filler in [f for f in step_fillers if f != 'ignore']: + target_id: int + to_int = filler + for prefix in [p for p in ['make_empty_', 'make_full_'] + if filler.startswith(p)]: + to_int = filler[len(prefix):] + try: + target_id = int(to_int) + except ValueError as e: + msg = 'bad fill_for target: {filler}' + raise BadFormatException(msg) from e + if filler.startswith('make_empty_'): + processes_to_make_empty += [target_id] + elif filler.startswith('make_full_'): + processes_to_make_full += [target_id] + else: + adopted_child_ids += [target_id] to_remove = [] for child in todo.children: assert isinstance(child.id_, int) @@ -552,10 +671,8 @@ class TaskHandler(BaseHTTPRequestHandler): 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) + if child_id not in [c.id_ for c in todo.children]: + todo.add_child(Todo.by_id(self.conn, child_id)) for process_id in processes_to_make_empty: process = Process.by_id(self.conn, process_id) made = Todo(None, process, False, todo.date) @@ -564,18 +681,12 @@ class TaskHandler(BaseHTTPRequestHandler): 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 - 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.is_done = len(self._form_data.get_all_str('done')) > 0 - todo.calendarize = len(self._form_data.get_all_str('calendarize')) > 0 - todo.comment = self._form_data.get_str('comment', ignore_strict=True) + todo.set_condition_relations(self.conn, *cond_rel_id_lists) + todo.update_attrs(**to_update) + # todo.save() may destroy Todo if .effort < 0, so retrieve .id_ early + url = f'/todo?id={todo.id_}' todo.save(self.conn) - return f'/todo?id={todo.id_}' + return url def do_POST_process_descriptions(self) -> str: """Update history timestamps for Process.description.""" @@ -589,60 +700,71 @@ class TaskHandler(BaseHTTPRequestHandler): """Update history timestamps for Process.title.""" return self._change_versioned_timestamps(Process, 'title') - def do_POST_process(self) -> str: + @_delete_or_post(Process, '/processes') + def do_POST_process(self, process: Process) -> str: """Update or insert Process of ?id= and fields defined in postvars.""" - # pylint: disable=too-many-branches - id_ = self._params.get_int_or_none('id') - for _ in self._form_data.get_all_str('delete'): - process = Process.by_id(self.conn, id_) - process.remove(self.conn) - return '/processes' - 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_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.calendarize = self._form_data.get_all_str('calendarize') != [] + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + try: + title = self._form_data.get_str('title') + description = self._form_data.get_str('description') + effort = self._form_data.get_float('effort') + except NotFoundException as e: + raise BadFormatException from e + 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_bool('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_condition_relations( + self.conn, conditions, blockers, enables, disables) + process.calendarize = calendarize process.save(self.conn) assert isinstance(process.id_, int) + new_step_title = None steps: list[ProcessStep] = [] - for step_id in self._form_data.get_all_int('keep_step'): - if step_id not in self._form_data.get_all_int('steps'): + for step_id in keep_steps: + if step_id not in step_ids: raise BadFormatException('trying to keep unknown step') - for step_id in self._form_data.get_all_int('steps'): - 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 += [ProcessStep(step_id, process.id_, step_process_id, - parent_id)] - 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 += [ProcessStep(None, process.id_, step_process_id, - step_id)] - new_step_title = None - for step_identifier in self._form_data.get_all_str('new_top_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) - steps += [ProcessStep(None, process.id_, step_process_id, - None)] + 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, - self._form_data. - get_all_int('suppresses')) + process.set_step_suppressions(self.conn, suppresses) owners_to_set = [] new_owner_title = None - for owner_identifier in self._form_data.get_all_str('step_of'): + for owner_identifier in step_of: try: owners_to_set += [int(owner_identifier)] except ValueError: @@ -666,16 +788,17 @@ class TaskHandler(BaseHTTPRequestHandler): """Update history timestamps for Condition.title.""" return self._change_versioned_timestamps(Condition, 'title') - def do_POST_condition(self) -> str: + @_delete_or_post(Condition, '/conditions') + def do_POST_condition(self, condition: Condition) -> str: """Update/insert Condition of ?id= and fields defined in postvars.""" - id_ = self._params.get_int_or_none('id') - for _ in self._form_data.get_all_str('delete'): - condition = Condition.by_id(self.conn, id_) - condition.remove(self.conn) - return '/conditions' - condition = Condition.by_id(self.conn, id_, create=True) - condition.is_active = self._form_data.get_all_str('is_active') != [] - condition.title.set(self._form_data.get_str('title')) - condition.description.set(self._form_data.get_str('description')) + try: + is_active = self._form_data.get_str('is_active') == 'True' + title = self._form_data.get_str('title') + description = self._form_data.get_str('description') + except NotFoundException as e: + raise BadFormatException(e) from e + condition.is_active = is_active + condition.title.set(title) + condition.description.set(description) condition.save(self.conn) return f'/condition?id={condition.id_}'