class ConditionsRelations:
"""Methods for handling relations to Conditions, for Todo and Process."""
+ # pylint: disable=too-few-public-methods
def __init__(self) -> None:
self.conditions: list[Condition] = []
self.enables: list[Condition] = []
self.disables: list[Condition] = []
- def set_conditions(self, db_conn: DatabaseConnection, ids: list[int],
- target: str = 'conditions') -> None:
- """Set self.[target] to Conditions identified by ids."""
- target_list = getattr(self, target)
- while len(target_list) > 0:
- target_list.pop()
- for id_ in ids:
- target_list += [Condition.by_id(db_conn, id_)]
-
- def set_blockers(self, db_conn: DatabaseConnection,
- ids: list[int]) -> None:
- """Set self.enables to Conditions identified by ids."""
- self.set_conditions(db_conn, ids, 'blockers')
-
- def set_enables(self, db_conn: DatabaseConnection,
- ids: list[int]) -> None:
- """Set self.enables to Conditions identified by ids."""
- self.set_conditions(db_conn, ids, 'enables')
-
- def set_disables(self, db_conn: DatabaseConnection,
- ids: list[int]) -> None:
- """Set self.disables to Conditions identified by ids."""
- self.set_conditions(db_conn, ids, 'disables')
+ def set_condition_relations(self,
+ db_conn: DatabaseConnection,
+ ids_conditions: list[int],
+ ids_blockers: list[int],
+ ids_enables: list[int],
+ ids_disables: list[int]
+ ) -> None:
+ """Set owned Condition lists to those identified by respective IDs."""
+ # pylint: disable=too-many-arguments
+ for ids, target in [(ids_conditions, 'conditions'),
+ (ids_blockers, 'blockers'),
+ (ids_enables, 'enables'),
+ (ids_disables, 'disables')]:
+ target_list = getattr(self, target)
+ while len(target_list) > 0:
+ target_list.pop()
+ for id_ in ids:
+ target_list += [Condition.by_id(db_conn, id_)]
from difflib import Differ
from sqlite3 import connect as sql_connect, Cursor, Row
from typing import Any, Self, TypeVar, Generic, Callable
-from plomtask.exceptions import HandledException, NotFoundException
+from plomtask.exceptions import (HandledException, NotFoundException,
+ BadFormatException)
from plomtask.dating import valid_date
EXPECTED_DB_VERSION = 5
def __init__(self, id_: BaseModelId | None) -> None:
if isinstance(id_, int) and id_ < 1:
msg = f'illegal {self.__class__.__name__} ID, must be >=1: {id_}'
- raise HandledException(msg)
+ raise BadFormatException(msg)
if isinstance(id_, str) and "" == id_:
msg = f'illegal {self.__class__.__name__} ID, must be non-empty'
- raise HandledException(msg)
+ raise BadFormatException(msg)
self.id_ = id_
def __hash__(self) -> int:
return list(cls.versioned_defaults.keys())
@property
- def as_dict(self) -> dict[str, object]:
- """Return self as (json.dumps-compatible) dict."""
- library: dict[str, dict[str | int, object]] = {}
- d: dict[str, object] = {'id': self.id_, '_library': library}
+ def as_dict_and_refs(self) -> tuple[dict[str, object],
+ list[BaseModel[int] | BaseModel[str]]]:
+ """Return self as json.dumps-ready dict, list of referenced objects."""
+ d: dict[str, object] = {'id': self.id_}
+ refs: list[BaseModel[int] | BaseModel[str]] = []
for to_save in self.to_save_simples:
- attr = getattr(self, to_save)
- if hasattr(attr, 'as_dict_into_reference'):
- d[to_save] = attr.as_dict_into_reference(library)
- else:
- d[to_save] = attr
+ d[to_save] = getattr(self, to_save)
if len(self.to_save_versioned()) > 0:
d['_versioned'] = {}
for k in self.to_save_versioned():
attr = getattr(self, k)
assert isinstance(d['_versioned'], dict)
d['_versioned'][k] = attr.history
- for r in self.to_save_relations:
- attr_name = r[2]
- l: list[int | str] = []
- for rel in getattr(self, attr_name):
- l += [rel.as_dict_into_reference(library)]
- d[attr_name] = l
- for k in self.add_to_dict:
- d[k] = [x.as_dict_into_reference(library)
- for x in getattr(self, k)]
- return d
-
- def as_dict_into_reference(self,
- library: dict[str, dict[str | int, object]]
- ) -> int | str:
- """Return self.id_ while writing .as_dict into library."""
- def into_library(library: dict[str, dict[str | int, object]],
- cls_name: str,
- id_: str | int,
- d: dict[str, object]
- ) -> None:
- if cls_name not in library:
- library[cls_name] = {}
- if id_ in library[cls_name]:
- if library[cls_name][id_] != d:
- msg = 'Unexpected inequality of entries for ' +\
- f'_library at: {cls_name}/{id_}'
- raise HandledException(msg)
- else:
- library[cls_name][id_] = d
- as_dict = self.as_dict
- assert isinstance(as_dict['_library'], dict)
- for cls_name, dict_of_objs in as_dict['_library'].items():
- for id_, obj in dict_of_objs.items():
- into_library(library, cls_name, id_, obj)
- del as_dict['_library']
- assert self.id_ is not None
- into_library(library, self.__class__.__name__, self.id_, as_dict)
- assert isinstance(as_dict['id'], (int, str))
- return as_dict['id']
+ rels_to_collect = [rel[2] for rel in self.to_save_relations]
+ rels_to_collect += self.add_to_dict
+ for attr_name in rels_to_collect:
+ rel_list = []
+ for item in getattr(self, attr_name):
+ rel_list += [item.id_]
+ if item not in refs:
+ refs += [item]
+ d[attr_name] = rel_list
+ return d, refs
@classmethod
def name_lowercase(cls) -> str:
def __getattribute__(self, name: str) -> Any:
"""Ensure fail if ._disappear() was called, except to check ._exists"""
if name != '_exists' and not super().__getattribute__('_exists'):
- raise HandledException('Object does not exist.')
+ msg = f'Object for attribute does not exist: {name}'
+ raise HandledException(msg)
return super().__getattribute__(name)
def _disappear(self) -> None:
cls.cache_ = {}
@classmethod
- def get_cache(cls: type[BaseModelInstance]) -> dict[Any, BaseModel[Any]]:
+ def get_cache(cls: type[BaseModelInstance]
+ ) -> dict[Any, BaseModelInstance]:
"""Get cache dictionary, create it if not yet existing."""
if not hasattr(cls, 'cache_'):
- d: dict[Any, BaseModel[Any]] = {}
+ d: dict[Any, BaseModelInstance] = {}
cls.cache_ = d
return cls.cache_
@classmethod
def _get_cached(cls: type[BaseModelInstance],
- id_: BaseModelId) -> BaseModelInstance | None:
+ id_: BaseModelId
+ ) -> BaseModelInstance | None:
"""Get object of id_ from class's cache, or None if not found."""
cache = cls.get_cache()
if id_ in cache:
"""
obj = None
if id_ is not None:
+ if isinstance(id_, int) and id_ == 0:
+ raise BadFormatException('illegal ID of value 0')
obj = cls._get_cached(id_)
if not obj:
for row in db_conn.row_where(cls.table_name, 'id', id_):
def by_id_or_create(cls, db_conn: DatabaseConnection,
id_: BaseModelId | None
) -> Self:
- """Wrapper around .by_id, creating (not caching/saving) if not find."""
+ """Wrapper around .by_id, creating (not caching/saving) if no find."""
if not cls.can_create_by_id:
raise HandledException('Class cannot .by_id_or_create.')
if id_ is None:
item = cls.by_id(db_conn, id_)
assert item.id_ is not None
items[item.id_] = item
- return list(items.values())
+ return sorted(list(items.values()))
@classmethod
def by_date_range_with_limits(cls: type[BaseModelInstance],
"""Web server stuff."""
from __future__ import annotations
-from dataclasses import dataclass
+from inspect import signature
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 http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from json import dumps as json_dumps
from os.path import split as path_split
from plomtask.days import Day
from plomtask.exceptions import (HandledException, BadFormatException,
NotFoundException)
-from plomtask.db import DatabaseConnection, DatabaseFile
+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.todos import Todo, TodoOrProcStepNode
+from plomtask.misc import DictableNode
TEMPLATES_DIR = 'templates'
*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 hasattr(node, 'as_dict_into_reference'):
- if hasattr(node, 'id_') and node.id_ is not None:
- return node.as_dict_into_reference(library)
- if hasattr(node, 'as_dict'):
- return node.as_dict
- if isinstance(node, (list, tuple)):
- return [walk_ctx(x) for x in node]
- if isinstance(node, dict):
- d = {}
- for k, v in node.items():
- d[k] = walk_ctx(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] = walk_ctx(v)
- ctx['_library'] = library
- 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:
"""Wrapper for validating and retrieving dict-like HTTP inputs."""
- def __init__(self, dict_: dict[str, list[str]],
- strictness: bool = True) -> None:
+ def __init__(self, dict_: dict[str, list[str]]) -> None:
self.inputs = dict_
- self.strict = strictness
- 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}')
- 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_all_str(self, key: str) -> list[str]:
+ """Retrieve list of string values at key (empty if no key)."""
+ if key not in self.inputs.keys():
+ return []
+ return self.inputs[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)
- if val is None:
- raise BadFormatException(f'unexpected empty value for: {key}')
- return val
+ def get_all_int(self, key: str, fail_on_empty: bool = False) -> list[int]:
+ """Retrieve list of int values at key."""
+ all_str = self.get_all_str(key)
+ try:
+ return [int(s) for s in all_str if fail_on_empty or s != '']
+ except ValueError as e:
+ msg = f'cannot int a form field value for key {key} in: {all_str}'
+ raise BadFormatException(msg) from e
+
+ def get_str(self, key: str, default: str | None = None) -> str | None:
+ """Retrieve single/first string value of key, or default."""
+ vals = self.get_all_str(key)
+ if vals:
+ return vals[0]
+ return default
+
+ def get_str_or_fail(self, key: str, default: str | None = None) -> str:
+ """Retrieve first string value of key, if none: fail or default."""
+ vals = self.get_all_str(key)
+ if not vals:
+ if default is not None:
+ return default
+ raise BadFormatException(f'no value found for key: {key}')
+ return vals[0]
def get_int_or_none(self, key: str) -> int | None:
"""Retrieve single/first value of key as int, return None if empty."""
- val = self.get_str(key, ignore_strict=True)
+ val = self.get_str_or_fail(key, '')
if val == '':
return None
try:
return int(val)
- except ValueError as e:
+ except (ValueError, TypeError) as e:
msg = f'cannot int form field value for key {key}: {val}'
raise BadFormatException(msg) from e
- def get_float(self, key: str) -> float:
- """Retrieve float value of key from self.postvars."""
- val = self.get_str(key)
- 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 value to key truish; return False if None/no value."""
+ return self.get_str(key) in {'True', 'true', '1', 'on'}
- def get_all_str(self, key: str) -> list[str]:
- """Retrieve list of string values at key."""
- if key not in self.inputs.keys():
- return []
- return self.inputs[key]
+ def get_all_of_key_prefixed(self, key_prefix: str) -> dict[str, list[str]]:
+ """Retrieve dict of strings at keys starting with key_prefix."""
+ ret = {}
+ for key in [k for k in self.inputs.keys() if k.startswith(key_prefix)]:
+ ret[key[len(key_prefix):]] = self.inputs[key]
+ return ret
- def get_all_int(self, key: str) -> list[int]:
- """Retrieve list of int values at key."""
- all_str = self.get_all_str(key)
+ def get_float_or_fail(self, key: str) -> float:
+ """Retrieve float value of key from self.postvars, fail if none."""
+ val = self.get_str_or_fail(key)
try:
- return [int(s) for s in all_str if len(s) > 0]
+ return float(val)
except ValueError as e:
- msg = f'cannot int a form field value for key {key} in: {all_str}'
+ msg = f'cannot float form field value for key {key}: {val}'
raise BadFormatException(msg) from e
def get_all_floats_or_nones(self, key: str) -> list[float | None]:
"""Handles single HTTP request."""
# pylint: disable=too-many-public-methods
server: TaskServer
- conn: DatabaseConnection
+ _conn: DatabaseConnection
_site: str
- _form_data: InputsParser
+ _form: InputsParser
_params: InputsParser
- def _send_page(self,
- ctx: dict[str, Any],
- tmpl_name: str,
- code: int = 200
- ) -> None:
- """Send ctx 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]]:
# (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)
+ 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)
+ params = parse_qs(parsed_url.query,
+ keep_blank_values=True,
+ strict_parsing=True)
+ self._params = InputsParser(params)
handler_name = f'do_{http_method}_{self._site}'
if hasattr(self, handler_name):
handler = getattr(self, handler_name)
ctx = {'msg': error}
self._send_page(ctx, 'msg', error.http_code)
finally:
- self.conn.close()
+ self._conn.close()
return wrapper
return decorator
"""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)
+ keep_blank_values=True)
+ self._form = InputsParser(postvars)
redir_target = handler()
- self.conn.commit()
+ self._conn.commit()
return redir_target
# GET handlers
# 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')
+ id_ = None
+ for val in self._params.get_all_int('id', fail_on_empty=True):
+ id_ = val
if target_class.can_create_by_id:
- item = target_class.by_id_or_create(self.conn, id_)
+ item = target_class.by_id_or_create(self._conn, id_)
else:
- item = target_class.by_id(self.conn, id_)
+ item = target_class.by_id(self._conn, id_)
+ if 'exists' in signature(f).parameters:
+ exists = id_ is not None and target_class._get_cached(id_)
+ return f(self, item, exists)
return f(self, item)
return wrapper
return decorator
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 = self._params.get_str_or_fail('start', '')
+ end = self._params.get_str_or_fail('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}
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_or_create(self.conn, date)
- make_type = self._params.get_str('make_type')
+ make_type = self._params.get_str_or_fail('make_type', 'full')
+ #
+ day = Day.by_id_or_create(self._conn, date)
conditions_present = []
enablers_for = {}
disablers_for = {}
if condition not in conditions_present:
conditions_present += [condition]
enablers_for[condition.id_] = [p for p in
- Process.all(self.conn)
+ Process.all(self._conn)
if condition in p.enables]
disablers_for[condition.id_] = [p for p in
- Process.all(self.conn)
+ Process.all(self._conn)
if condition in p.disables]
seen_todos: set[int] = set()
top_nodes = [t.get_step_tree(seen_todos)
'enablers_for': enablers_for,
'disablers_for': disablers_for,
'conditions_present': conditions_present,
- 'processes': Process.all(self.conn)}
+ 'processes': Process.all(self._conn)}
@_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
+ proc = Process.by_id(self._conn,
+ process_step_node.step.step_process_id)
+ node = TodoOrProcStepNode(node_id, None, proc, [])
steps_nodes += [node]
- walk_process_steps(id_, list(process_step_node.steps.values()),
- node.children)
+ node_id = walk_process_steps(
+ node_id, process_step_node.steps, 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
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:
return ids
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)
+ process_tree = todo.process.get_steps(self._conn, None)
+ steps_todo_to_process: list[TodoOrProcStepNode] = []
+ last_node_id = walk_process_steps(0, process_tree,
+ 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)
+ any_adoptables = [Todo.by_id(self._conn, t.id_)
+ for t in Todo.by_date(self._conn, todo.date)
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)}
+ 'condition_candidates': Condition.all(self._conn)}
def do_GET_todos(self) -> dict[str, object]:
"""Show Todos from ?start= to ?end=, of ?process=, ?comment= pattern"""
- sort_by = self._params.get_str('sort_by')
- start = self._params.get_str('start')
- end = self._params.get_str('end')
+ sort_by = self._params.get_str_or_fail('sort_by', 'title')
+ start = self._params.get_str_or_fail('start', '')
+ end = self._params.get_str_or_fail('end', '')
process_id = self._params.get_int_or_none('process_id')
- comment_pattern = self._params.get_str('comment_pattern')
- todos = []
- ret = Todo.by_date_range_with_limits(self.conn, (start, end))
+ comment_pattern = self._params.get_str_or_fail('comment_pattern', '')
+ #
+ ret = Todo.by_date_range_with_limits(self._conn, (start, end))
todos_by_date_range, start, end = ret
todos = [t for t in todos_by_date_range
if comment_pattern in t.comment
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}
+ 'all_processes': Process.all(self._conn), 'sort_by': sort_by}
def do_GET_conditions(self) -> dict[str, object]:
"""Show all Conditions."""
- pattern = self._params.get_str('pattern')
- sort_by = self._params.get_str('sort_by')
- conditions = Condition.matching(self.conn, pattern)
+ pattern = self._params.get_str_or_fail('pattern', '')
+ sort_by = self._params.get_str_or_fail('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}
@_get_item(Condition)
- def do_GET_condition(self, c: Condition) -> dict[str, object]:
+ def do_GET_condition(self,
+ c: Condition,
+ exists: bool
+ ) -> dict[str, object]:
"""Show Condition of ?id=."""
- ps = Process.all(self.conn)
- return {'condition': c, 'is_new': c.id_ is None,
+ ps = Process.all(self._conn)
+ return {'condition': c,
+ 'is_new': not exists,
'enabled_processes': [p for p in ps if c in p.conditions],
'disabled_processes': [p for p in ps if c in p.blockers],
'enabling_processes': [p for p in ps if c in p.enables],
return {'condition': c}
@_get_item(Process)
- def do_GET_process(self, process: Process) -> dict[str, object]:
+ def do_GET_process(self,
+ process: Process,
+ exists: bool
+ ) -> 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')
+ title_new = None
if title_64:
try:
- title = b64decode(title_64.encode()).decode()
+ title_new = 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)
+ #
+ if title_new:
+ process.title.set(title_new)
preset_top_step = None
- owners = process.used_as_step_by(self.conn)
+ owners = process.used_as_step_by(self._conn)
for step_id in owner_ids:
- owners += [Process.by_id(self.conn, step_id)]
+ owners += [Process.by_id(self._conn, step_id)]
for process_id in owned_ids:
- Process.by_id(self.conn, process_id) # to ensure ID exists
+ 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,
+ return {'process': process,
+ 'is_new': not exists,
'preset_top_step': preset_top_step,
- 'steps': process.get_steps(self.conn), 'owners': owners,
- 'n_todos': len(Todo.by_process_id(self.conn, process.id_)),
- 'process_candidates': Process.all(self.conn),
- 'condition_candidates': Condition.all(self.conn)}
+ 'steps': process.get_steps(self._conn),
+ 'owners': owners,
+ 'n_todos': len(Todo.by_process_id(self._conn, process.id_)),
+ 'process_candidates': Process.all(self._conn),
+ 'condition_candidates': Condition.all(self._conn)}
@_get_item(Process)
def do_GET_process_titles(self, p: Process) -> dict[str, object]:
def do_GET_processes(self) -> dict[str, object]:
"""Show all Processes."""
- pattern = self._params.get_str('pattern')
- sort_by = self._params.get_str('sort_by')
- processes = Process.matching(self.conn, pattern)
+ pattern = self._params.get_str_or_fail('pattern', '')
+ sort_by = self._params.get_str_or_fail('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}
# (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'):
+ for _ in self._form.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)
+ 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_)
+ item = target_class.by_id_or_create(self._conn, id_)
else:
- item = target_class.by_id(self.conn, id_)
+ 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')
- item = cls.by_id(self.conn, 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)
+ for k, vals in self._form.get_all_of_key_prefixed('at:').items():
+ if k[19:] != vals[0]:
+ attr.reset_timestamp(k, f'{vals[0]}.0')
+ attr.save(self._conn)
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."""
# pylint: disable=too-many-locals
- date = self._params.get_str('date')
- day_comment = self._form_data.get_str('day_comment')
- 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')
- 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')
+ date = self._params.get_str_or_fail('date')
+ day_comment = self._form.get_str_or_fail('day_comment')
+ make_type = self._form.get_str_or_fail('make_type')
+ old_todos = self._form.get_all_int('todo_id')
+ new_todos_by_process = self._form.get_all_int('new_todo')
+ comments = self._form.get_all_str('comment')
+ efforts = self._form.get_all_floats_or_nones('effort')
+ done_todos = self._form.get_all_int('done')
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)
+ for _ in [id_ for id_ in done_todos if id_ not in old_todos]:
+ raise BadFormatException('"done" field refers to unknown Todo')
+ #
+ day = Day.by_id_or_create(self._conn, date)
day.comment = day_comment
- day.save(self.conn)
- 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)
+ day.save(self._conn)
+ new_todos = []
+ for process_id in sorted(new_todos_by_process):
+ process = Process.by_id(self._conn, process_id)
+ todo = Todo(None, process, False, date)
+ todo.save(self._conn)
+ new_todos += [todo]
+ if 'full' == make_type:
+ for todo in new_todos:
+ todo.ensure_children(self._conn)
for i, todo_id in enumerate(old_todos):
- todo = Todo.by_id(self.conn, todo_id)
+ todo = Todo.by_id(self._conn, todo_id)
todo.is_done = is_done[i]
todo.comment = comments[i]
todo.effort = efforts[i]
- todo.save(self.conn)
+ todo.save(self._conn)
return f'/day?date={date}&make_type={make_type}'
@_delete_or_post(Todo, '/')
def do_POST_todo(self, todo: Todo) -> str:
"""Update Todo and its children."""
# pylint: disable=too-many-locals
- 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_')
- effort = self._form_data.get_str('effort', ignore_strict=True)
- conditions = self._form_data.get_all_int('conditions')
- disables = self._form_data.get_all_int('disables')
- blockers = self._form_data.get_all_int('blockers')
- enables = self._form_data.get_all_int('enables')
- is_done = len(self._form_data.get_all_str('done')) > 0
- calendarize = len(self._form_data.get_all_str('calendarize')) > 0
- comment = self._form_data.get_str('comment', ignore_strict=True)
- 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:
- assert isinstance(child.id_, int)
- if child.id_ not in adopted_child_ids:
- 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 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)
- todo.effort = float(effort) if effort else None
- todo.set_conditions(self.conn, conditions)
- todo.set_blockers(self.conn, blockers)
- todo.set_enables(self.conn, enables)
- todo.set_disables(self.conn, disables)
- todo.is_done = is_done
- todo.calendarize = calendarize
- todo.comment = comment
- todo.save(self.conn)
- return f'/todo?id={todo.id_}'
+ # pylint: disable=too-many-branches
+ # pylint: disable=too-many-statements
+ assert todo.id_ is not None
+ adoptees = [(id_, todo.id_) for id_ in self._form.get_all_int('adopt')]
+ to_make = {'full': [(id_, todo.id_)
+ for id_ in self._form.get_all_int('make_full')],
+ 'empty': [(id_, todo.id_)
+ for id_ in self._form.get_all_int('make_empty')]}
+ step_fillers_to = self._form.get_all_of_key_prefixed('step_filler_to_')
+ to_update: dict[str, Any] = {
+ 'comment': self._form.get_str_or_fail('comment', ''),
+ 'is_done': self._form.get_bool('is_done'),
+ 'calendarize': self._form.get_bool('calendarize')}
+ cond_rels = [self._form.get_all_int(name) for name in
+ ['conditions', 'blockers', 'enables', 'disables']]
+ effort_or_not = self._form.get_str('effort')
+ if effort_or_not is not None:
+ if effort_or_not == '':
+ to_update['effort'] = None
+ else:
+ try:
+ to_update['effort'] = float(effort_or_not)
+ except ValueError as e:
+ msg = 'cannot float form field value for key: effort'
+ raise BadFormatException(msg) from e
+ for k, fillers in step_fillers_to.items():
+ try:
+ parent_id = int(k)
+ except ValueError as e:
+ msg = f'bad step_filler_to_ key: {k}'
+ raise BadFormatException(msg) from e
+ for filler in [f for f in fillers if f != 'ignore']:
+ target_id: int
+ prefix = 'make_'
+ to_int = filler[5:] if filler.startswith(prefix) else filler
+ try:
+ target_id = int(to_int)
+ except ValueError as e:
+ msg = f'bad fill_for target: {filler}'
+ raise BadFormatException(msg) from e
+ if filler.startswith(prefix):
+ to_make['empty'] += [(target_id, parent_id)]
+ else:
+ adoptees += [(target_id, parent_id)]
+ #
+ todo.set_condition_relations(self._conn, *cond_rels)
+ for parent in [Todo.by_id(self._conn, a[1])
+ for a in adoptees] + [todo]:
+ for child in parent.children:
+ if child not in [t[0] for t in adoptees
+ if t[0] == child.id_ and t[1] == parent.id_]:
+ parent.remove_child(child)
+ parent.save(self._conn)
+ for child_id, parent_id in adoptees:
+ parent = Todo.by_id(self._conn, parent_id)
+ if child_id not in [c.id_ for c in parent.children]:
+ parent.add_child(Todo.by_id(self._conn, child_id))
+ parent.save(self._conn)
+ todo.update_attrs(**to_update)
+ for approach, make_data in to_make.items():
+ for process_id, parent_id in make_data:
+ parent = Todo.by_id(self._conn, parent_id)
+ process = Process.by_id(self._conn, process_id)
+ made = Todo(None, process, False, todo.date)
+ made.save(self._conn)
+ if 'full' == approach:
+ made.ensure_children(self._conn)
+ parent.add_child(made)
+ parent.save(self._conn)
+ # todo.save() may destroy Todo if .effort < 0, so retrieve .id_ early
+ url = f'/todo?id={todo.id_}'
+ todo.save(self._conn)
+ return url
def do_POST_process_descriptions(self) -> str:
"""Update history timestamps for Process.description."""
def do_POST_process(self, process: Process) -> str:
"""Update or insert Process of ?id= and fields defined in postvars."""
# pylint: disable=too-many-locals
- # pylint: disable=too-many-statements
- title = self._form_data.get_str('title')
- description = self._form_data.get_str('description')
- effort = self._form_data.get_float('effort')
- 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_all_str('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 = {}
+
+ def id_or_title(l_id_or_title: list[str]) -> tuple[str, list[int]]:
+ l_ids, title = [], ''
+ for id_or_title in l_id_or_title:
+ try:
+ l_ids += [int(id_or_title)]
+ except ValueError:
+ title = id_or_title
+ return title, l_ids
+
+ versioned = {'title': self._form.get_str_or_fail('title'),
+ 'description': self._form.get_str_or_fail('description'),
+ 'effort': self._form.get_float_or_fail('effort')}
+ cond_rels = [self._form.get_all_int(s) for s
+ in ['conditions', 'blockers', 'enables', 'disables']]
+ calendarize = self._form.get_bool('calendarize')
+ step_of = self._form.get_all_str('step_of')
+ suppressions = self._form.get_all_int('suppresses')
+ kept_steps = self._form.get_all_int('kept_steps')
+ new_top_step_procs = self._form.get_all_str('new_top_step')
new_steps_to = {}
- for step_id in step_ids:
+ for step_id in kept_steps:
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_conditions(self.conn, conditions)
- process.set_blockers(self.conn, blockers)
- process.set_enables(self.conn, enables)
- process.set_disables(self.conn, disables)
+ new_steps_to[step_id] = self._form.get_all_int(name)
+ new_owner_title, owners_to_set = id_or_title(step_of)
+ new_step_title, new_top_step_proc_ids = id_or_title(new_top_step_procs)
+ #
+ for k, v in versioned.items():
+ getattr(process, k).set(v)
process.calendarize = calendarize
- process.save(self.conn)
+ process.save(self._conn)
assert isinstance(process.id_, int)
- new_step_title = None
- steps: list[ProcessStep] = []
- for step_id in keep_steps:
- if step_id not in step_ids:
- raise BadFormatException('trying to keep unknown 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)
- 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, suppresses)
- owners_to_set = []
- new_owner_title = None
- for owner_identifier in step_of:
- try:
- owners_to_set += [int(owner_identifier)]
- except ValueError:
- new_owner_title = owner_identifier
- process.set_owners(self.conn, owners_to_set)
+ # set relations to Conditions and ProcessSteps / other Processes
+ process.set_condition_relations(self._conn, *cond_rels)
+ owned_steps = []
+ for step_id in kept_steps:
+ owned_steps += [ProcessStep.by_id(self._conn, step_id)]
+ owned_steps += [ # new sub-steps
+ ProcessStep(None, process.id_, step_process_id, step_id)
+ for step_process_id in new_steps_to[step_id]]
+ for step_process_id in new_top_step_proc_ids:
+ owned_steps += [ProcessStep(None, process.id_, step_process_id,
+ None)]
+ process.set_step_relations(self._conn, owners_to_set, suppressions,
+ owned_steps)
+ # encode titles for potential newly-to-create Processes up or down
params = f'id={process.id_}'
if new_step_title:
title_b64_encoded = b64encode(new_step_title.encode()).decode()
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)
+ process.save(self._conn)
return f'/process?{params}'
def do_POST_condition_descriptions(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."""
- is_active = self._form_data.get_str('is_active') == 'True'
- title = self._form_data.get_str('title')
- description = self._form_data.get_str('description')
+ title = self._form.get_str_or_fail('title')
+ description = self._form.get_str_or_fail('description')
+ is_active = self._form.get_bool('is_active')
condition.is_active = is_active
+ #
condition.title.set(title)
condition.description.set(description)
- condition.save(self.conn)
+ condition.save(self._conn)
return f'/condition?id={condition.id_}'
--- /dev/null
+"""What doesn't fit elsewhere so far."""
+from typing import Any
+
+
+class DictableNode:
+ """Template for display chain nodes providing .as_dict_and_refs."""
+ # pylint: disable=too-few-public-methods
+ _to_dict: list[str] = []
+
+ def __init__(self, *args: Any) -> None:
+ for i, arg in enumerate(args):
+ setattr(self, self._to_dict[i], arg)
+
+ @property
+ def as_dict_and_refs(self) -> tuple[dict[str, object], list[Any]]:
+ """Return self as json.dumps-ready dict, list of referenced objects."""
+ d = {}
+ refs = []
+ for name in self._to_dict:
+ attr = getattr(self, name)
+ if hasattr(attr, 'id_'):
+ d[name] = attr.id_
+ continue
+ if isinstance(attr, list):
+ d[name] = []
+ for item in attr:
+ item_d, item_refs = item.as_dict_and_refs
+ d[name] += [item_d]
+ for item_ref in [r for r in item_refs if r not in refs]:
+ refs += [item_ref]
+ continue
+ d[name] = attr
+ return d, refs
"""Collecting Processes and Process-related items."""
from __future__ import annotations
-from dataclasses import dataclass
from typing import Set, Any
from sqlite3 import Row
+from plomtask.misc import DictableNode
from plomtask.db import DatabaseConnection, BaseModel
from plomtask.versioned_attributes import VersionedAttribute
from plomtask.conditions import Condition, ConditionsRelations
HandledException)
-@dataclass
-class ProcessStepsNode:
+class ProcessStepsNode(DictableNode):
"""Collects what's useful to know for ProcessSteps tree display."""
+ # pylint: disable=too-few-public-methods
+ step: ProcessStep
process: Process
- parent_id: int | None
is_explicit: bool
- steps: dict[int, ProcessStepsNode]
+ steps: list[ProcessStepsNode]
seen: bool = False
is_suppressed: bool = False
+ _to_dict = ['step', 'process', 'is_explicit', 'steps', 'seen',
+ 'is_suppressed']
class Process(BaseModel[int], ConditionsRelations):
return [self.__class__.by_id(db_conn, id_) for id_ in owner_ids]
def get_steps(self, db_conn: DatabaseConnection, external_owner:
- Process | None = None) -> dict[int, ProcessStepsNode]:
+ Process | None = None) -> list[ProcessStepsNode]:
"""Return tree of depended-on explicit and implicit ProcessSteps."""
def make_node(step: ProcessStep, suppressed: bool) -> ProcessStepsNode:
if external_owner is not None:
is_explicit = step.owner_id == external_owner.id_
process = self.__class__.by_id(db_conn, step.step_process_id)
- step_steps = {}
+ step_steps = []
if not suppressed:
step_steps = process.get_steps(db_conn, external_owner)
- return ProcessStepsNode(process, step.parent_step_id,
- is_explicit, step_steps, False, suppressed)
+ return ProcessStepsNode(step, process, is_explicit, step_steps,
+ False, suppressed)
- def walk_steps(node_id: int, node: ProcessStepsNode) -> None:
- node.seen = node_id in seen_step_ids
- seen_step_ids.add(node_id)
+ def walk_steps(node: ProcessStepsNode) -> None:
+ node.seen = node.step.id_ in seen_step_ids
+ assert isinstance(node.step.id_, int)
+ seen_step_ids.add(node.step.id_)
if node.is_suppressed:
return
explicit_children = [s for s in self.explicit_steps
- if s.parent_step_id == node_id]
+ if s.parent_step_id == node.step.id_]
for child in explicit_children:
- assert isinstance(child.id_, int)
- node.steps[child.id_] = make_node(child, False)
- for id_, step in node.steps.items():
- walk_steps(id_, step)
+ node.steps += [make_node(child, False)]
+ for step in node.steps:
+ walk_steps(step)
- steps: dict[int, ProcessStepsNode] = {}
+ step_nodes: list[ProcessStepsNode] = []
seen_step_ids: Set[int] = set()
if external_owner is None:
external_owner = self
if s.parent_step_id is None]:
assert isinstance(step.id_, int)
new_node = make_node(step, step in external_owner.suppressed_steps)
- steps[step.id_] = new_node
- for step_id, step_node in steps.items():
- walk_steps(step_id, step_node)
- return steps
+ step_nodes += [new_node]
+ for step_node in step_nodes:
+ walk_steps(step_node)
+ return step_nodes
+
+ def set_step_relations(self,
+ db_conn: DatabaseConnection,
+ owners: list[int],
+ suppressions: list[int],
+ owned_steps: list[ProcessStep]
+ ) -> None:
+ """Set step owners, suppressions, and owned steps."""
+ self._set_owners(db_conn, owners)
+ self._set_step_suppressions(db_conn, suppressions)
+ self.set_steps(db_conn, owned_steps)
- def set_step_suppressions(self, db_conn: DatabaseConnection,
- step_ids: list[int]) -> None:
+ def _set_step_suppressions(self,
+ db_conn: DatabaseConnection,
+ step_ids: list[int]
+ ) -> None:
"""Set self.suppressed_steps from step_ids."""
assert isinstance(self.id_, int)
db_conn.delete_where('process_step_suppressions', 'process', self.id_)
self.suppressed_steps = [ProcessStep.by_id(db_conn, s)
for s in step_ids]
- def set_steps(self, db_conn: DatabaseConnection,
- steps: list[ProcessStep]) -> None:
+ def _set_owners(self,
+ db_conn: DatabaseConnection,
+ owner_ids: list[int]
+ ) -> None:
+ """Re-set owners to those identified in owner_ids."""
+ owners_old = self.used_as_step_by(db_conn)
+ losers = [o for o in owners_old if o.id_ not in owner_ids]
+ owners_old_ids = [o.id_ for o in owners_old]
+ winners = [Process.by_id(db_conn, id_) for id_ in owner_ids
+ if id_ not in owners_old_ids]
+ steps_to_remove = []
+ for loser in losers:
+ steps_to_remove += [s for s in loser.explicit_steps
+ if s.step_process_id == self.id_]
+ for step in steps_to_remove:
+ step.remove(db_conn)
+ for winner in winners:
+ assert isinstance(winner.id_, int)
+ assert isinstance(self.id_, int)
+ new_step = ProcessStep(None, winner.id_, self.id_, None)
+ new_explicit_steps = winner.explicit_steps + [new_step]
+ winner.set_steps(db_conn, new_explicit_steps)
+
+ def set_steps(self,
+ db_conn: DatabaseConnection,
+ steps: list[ProcessStep]
+ ) -> None:
"""Set self.explicit_steps in bulk.
Checks against recursion, and turns into top-level steps any of
walk_steps(step)
step.save(db_conn)
- def set_owners(self, db_conn: DatabaseConnection,
- owner_ids: list[int]) -> None:
- """Re-set owners to those identified in owner_ids."""
- owners_old = self.used_as_step_by(db_conn)
- losers = [o for o in owners_old if o.id_ not in owner_ids]
- owners_old_ids = [o.id_ for o in owners_old]
- winners = [Process.by_id(db_conn, id_) for id_ in owner_ids
- if id_ not in owners_old_ids]
- steps_to_remove = []
- for loser in losers:
- steps_to_remove += [s for s in loser.explicit_steps
- if s.step_process_id == self.id_]
- for step in steps_to_remove:
- step.remove(db_conn)
- for winner in winners:
- assert isinstance(winner.id_, int)
- assert isinstance(self.id_, int)
- new_step = ProcessStep(None, winner.id_, self.id_, None)
- new_explicit_steps = winner.explicit_steps + [new_step]
- winner.set_steps(db_conn, new_explicit_steps)
-
def save(self, db_conn: DatabaseConnection) -> None:
"""Add (or re-write) self and connected items to DB."""
super().save(db_conn)
from __future__ import annotations
from typing import Any, Set
from sqlite3 import Row
+from plomtask.misc import DictableNode
from plomtask.db import DatabaseConnection, BaseModel
from plomtask.processes import Process, ProcessStepsNode
from plomtask.versioned_attributes import VersionedAttribute
from plomtask.dating import valid_date
-class TodoNode:
+class TodoNode(DictableNode):
"""Collects what's useful to know for Todo/Condition tree display."""
# pylint: disable=too-few-public-methods
todo: Todo
seen: bool
children: list[TodoNode]
+ _to_dict = ['todo', 'seen', 'children']
- def __init__(self,
- todo: Todo,
- seen: bool,
- children: list[TodoNode]) -> None:
- self.todo = todo
- self.seen = seen
- self.children = children
- @property
- def as_dict(self) -> dict[str, object]:
- """Return self as (json.dumps-coompatible) dict."""
- return {'todo': self.todo.id_,
- 'seen': self.seen,
- 'children': [c.as_dict for c in self.children]}
+class TodoOrProcStepNode(DictableNode):
+ """Collect what's useful for Todo-or-ProcessStep tree display."""
+ # pylint: disable=too-few-public-methods
+ node_id: int
+ todo: Todo | None
+ process: Process | None
+ children: list[TodoOrProcStepNode] # pylint: disable=undefined-variable
+ fillable: bool = False
+ _to_dict = ['node_id', 'todo', 'process', 'children', 'fillable']
class Todo(BaseModel[int], ConditionsRelations):
todos, _, _ = cls.by_date_range_with_limits(db_conn, date_range)
return todos
- @classmethod
- def create_with_children(cls, db_conn: DatabaseConnection,
- process_id: int, date: str) -> Todo:
- """Create Todo of process for date, ensure children."""
-
- def key_order_func(n: ProcessStepsNode) -> int:
- assert isinstance(n.process.id_, int)
- return n.process.id_
+ def ensure_children(self, db_conn: DatabaseConnection) -> None:
+ """Ensure Todo children (create or adopt) demanded by Process chain."""
def walk_steps(parent: Todo, step_node: ProcessStepsNode) -> Todo:
- adoptables = [t for t in cls.by_date(db_conn, date)
+ adoptables = [t for t in Todo.by_date(db_conn, parent.date)
if (t not in parent.children)
and (t != parent)
- and step_node.process == t.process]
+ and step_node.process.id_ == t.process_id]
satisfier = None
for adoptable in adoptables:
satisfier = adoptable
break
if not satisfier:
- satisfier = cls(None, step_node.process, False, date)
+ satisfier = Todo(None, step_node.process, False, parent.date)
satisfier.save(db_conn)
- sub_step_nodes = list(step_node.steps.values())
- sub_step_nodes.sort(key=key_order_func)
+ sub_step_nodes = sorted(
+ step_node.steps,
+ key=lambda s: s.process.id_ if s.process.id_ else 0)
for sub_node in sub_step_nodes:
if sub_node.is_suppressed:
continue
n_slots = len([n for n in sub_step_nodes
if n.process == sub_node.process])
filled_slots = len([t for t in satisfier.children
- if t.process == sub_node.process])
+ if t.process.id_ == sub_node.process.id_])
# if we did not newly create satisfier, it may already fill
# some step dependencies, so only fill what remains open
if n_slots - filled_slots > 0:
satisfier.save(db_conn)
return satisfier
- process = Process.by_id(db_conn, process_id)
- todo = cls(None, process, False, date)
- todo.save(db_conn)
+ process = Process.by_id(db_conn, self.process_id)
steps_tree = process.get_steps(db_conn)
- for step_node in steps_tree.values():
+ for step_node in steps_tree:
if step_node.is_suppressed:
continue
- todo.add_child(walk_steps(todo, step_node))
- todo.save(db_conn)
- return todo
+ self.add_child(walk_steps(self, step_node))
+ self.save(db_conn)
@classmethod
def from_table_row(cls, db_conn: DatabaseConnection,
return 0
@property
- def process_id(self) -> int | str | None:
+ def process_id(self) -> int:
"""Needed for super().save to save Processes as attributes."""
+ assert isinstance(self.process.id_, int)
return self.process.id_
@property
self.children.remove(child)
child.parents.remove(self)
+ def update_attrs(self, **kwargs: Any) -> None:
+ """Update self's attributes listed in kwargs."""
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
def save(self, db_conn: DatabaseConnection) -> None:
"""On save calls, also check if auto-deletion by effort < 0."""
if self.effort and self.effort < 0 and self.is_deletable:
self.table_name = table_name
self._default = default
self.history: dict[str, str | float] = {}
+ # NB: For tighter mypy testing, we might prefer self.history to be
+ # dict[str, float] | dict[str, str] instead, but my current coding
+ # knowledge only manages to make that work by adding much further
+ # complexity, so let's leave it at that for now …
def __hash__(self) -> int:
history_tuples = tuple((k, v) for k, v in self.history.items())
-{% macro step_with_steps(step_id, step_node, indent) %}
+{% macro step_with_steps(step_node, indent) %}
<tr>
<td>
-<input type="hidden" name="steps" value="{{step_id}}" />
+<input type="hidden" name="steps" value="{{step_node.step.id_}}" />
{% if step_node.is_explicit %}
-<input type="checkbox" name="keep_step" value="{{step_id}}" checked />
-<input type="hidden" name="step_{{step_id}}_process_id" value="{{step_node.process.id_}}" />
-<input type="hidden" name="step_{{step_id}}_parent_id" value="{{step_node.parent_id or ''}}" />
+<input type="checkbox" name="kept_steps" value="{{step_node.step.id_}}" checked />
{% endif %}
</td>
{% endif %}
</tr>
{% if step_node.is_explicit or not step_node.seen %}
-{% for substep_id, substep in step_node.steps.items() %}
-{{ step_with_steps(substep_id, substep, indent+1) }}
+{% for substep in step_node.steps %}
+{{ step_with_steps(substep, indent+1) }}
{% endfor %}
{% endif %}
{% endmacro %}
<td>
{% if steps %}
<table>
-{% for step_id, step_node in steps.items() %}
-{{ step_with_steps(step_id, step_node, 0) }}
+{% for step_node in steps %}
+{{ step_with_steps(step_node, 0) }}
{% endfor %}
</table>
{% endif %}
<a href="todo?id={{item.todo.id_}}">{{item.todo.title_then|e}}</a>
{% else %}
{{item.process.title.newest|e}}
-{% if indent == 0 %}
-· fill: <select name="fill_for_{{item.id_}}">
+{% if parent_todo %}
+· fill: <select name="step_filler_to_{{parent_todo.id_}}">
<option value="ignore">--</option>
-<option value="make_empty_{{item.process.id_}}">make empty</option>
-<option value="make_full_{{item.process.id_}}">make full</option>
+<option value="make_{{item.process.id_}}">make empty</option>
{% for adoptable in adoption_candidates_for[item.process.id_] %}
<option value="{{adoptable.id_}}">adopt #{{adoptable.id_}}{% if adoptable.comment %} / {{adoptable.comment}}{% endif %}</option>
{% endfor %}
</select>
{% endif %}
+
{% endif %}
</td>
</tr>
{% for child in item.children %}
-{{ draw_tree_row(child, item, indent+1) }}
+{{ draw_tree_row(child, item.todo, indent+1) }}
{% endfor %}
{% endmacro %}
</tr>
<tr>
<th>done</th>
-<td><input type="checkbox" name="done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/>
-{% if not todo.is_doable and todo.is_done %}<input type="hidden" name="done" value="1" />{% endif %}
+<td><input type="checkbox" name="is_done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/>
+{% if not todo.is_doable and todo.is_done %}<input type="hidden" name="is_done" value="1" />{% endif %}
</td>
</tr>
<tr>
"""Test Conditions module."""
-from tests.utils import TestCaseWithDB, TestCaseWithServer, TestCaseSansDB
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+ Expected)
from plomtask.conditions import Condition
-from plomtask.processes import Process
-from plomtask.todos import Todo
-from plomtask.exceptions import HandledException
class TestsSansDB(TestCaseSansDB):
class TestsWithDB(TestCaseWithDB):
"""Tests requiring DB, but not server setup."""
checked_class = Condition
- default_init_kwargs = {'is_active': False}
-
- def test_remove(self) -> None:
- """Test .remove() effects on DB and cache."""
- super().test_remove()
- proc = Process(None)
- proc.save(self.db_conn)
- todo = Todo(None, proc, False, '2024-01-01')
- todo.save(self.db_conn)
- # check condition can only be deleted if not depended upon
- for depender in (proc, todo):
- assert hasattr(depender, 'save')
- assert hasattr(depender, 'set_conditions')
- c = Condition(None)
- c.save(self.db_conn)
- depender.set_conditions(self.db_conn, [c.id_])
- depender.save(self.db_conn)
- with self.assertRaises(HandledException):
- c.remove(self.db_conn)
- depender.set_conditions(self.db_conn, [])
- depender.save(self.db_conn)
- c.remove(self.db_conn)
+ default_init_kwargs = {'is_active': 0}
+
+
+class ExpectedGetConditions(Expected):
+ """Builder of expectations for GET /conditions."""
+ _default_dict = {'sort_by': 'title', 'pattern': ''}
+
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
+ super().recalc()
+ self._fields['conditions'] = self.as_ids(self.lib_all('Condition'))
+
+
+class ExpectedGetCondition(Expected):
+ """Builder of expectations for GET /condition."""
+ _default_dict = {'is_new': False}
+ _on_empty_make_temp = ('Condition', 'cond_as_dict')
+
+ def __init__(self, id_: int | None, *args: Any, **kwargs: Any) -> None:
+ self._fields = {'condition': id_}
+ super().__init__(*args, **kwargs)
+
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
+ super().recalc()
+ for p_field, c_field in [('conditions', 'enabled_processes'),
+ ('disables', 'disabling_processes'),
+ ('blockers', 'disabled_processes'),
+ ('enables', 'enabling_processes')]:
+ self._fields[c_field] = self.as_ids([
+ p for p in self.lib_all('Process')
+ if self._fields['condition'] in p[p_field]])
class TestsWithServer(TestCaseWithServer):
"""Module tests against our HTTP server/handler (and database)."""
-
- @classmethod
- def GET_condition_dict(cls, cond: dict[str, object]) -> dict[str, object]:
- """Return JSON of GET /condition to expect."""
- return {'is_new': False,
- 'enabled_processes': [],
- 'disabled_processes': [],
- 'enabling_processes': [],
- 'disabling_processes': [],
- 'condition': cond['id'],
- '_library': {'Condition': cls.as_refs([cond])}}
-
- @classmethod
- def GET_conditions_dict(cls, conds: list[dict[str, object]]
- ) -> dict[str, object]:
- """Return JSON of GET /conditions to expect."""
- library = {'Condition': cls.as_refs(conds)} if conds else {}
- d: dict[str, object] = {'conditions': cls.as_id_list(conds),
- 'sort_by': 'title',
- 'pattern': '',
- '_library': library}
- return d
+ checked_class = Condition
def test_fail_POST_condition(self) -> None:
"""Test malformed/illegal POST /condition requests."""
# check incomplete POST payloads
- url = '/condition'
- self.check_post({}, url, 400)
- self.check_post({'title': ''}, url, 400)
- self.check_post({'title': '', 'description': ''}, url, 400)
- self.check_post({'title': '', 'is_active': False}, url, 400)
- self.check_post({'description': '', 'is_active': False}, url, 400)
+ valid_payload = {'title': '', 'description': ''}
+ self.check_minimal_inputs('/condition', valid_payload)
# check valid POST payload on bad paths
- valid_payload = {'title': '', 'description': '', 'is_active': False}
self.check_post(valid_payload, '/condition?id=foo', 400)
+ # check cannot delete depended-upon Condition
+ self.post_exp_cond([], {})
+ for key in ('conditions', 'blockers', 'enables', 'disables'):
+ self.post_exp_process([], {key: [1]}, 1)
+ self.check_post({'delete': ''}, '/condition?id=1', 500)
+ self.post_exp_process([], {}, 1)
+ self.post_exp_day([], {'new_todo': '1'})
+ for key in ('conditions', 'blockers', 'enables', 'disables'):
+ self.post_exp_todo([], {key: [1]}, 1)
+ self.check_post({'delete': ''}, '/condition?id=1', 500)
def test_POST_condition(self) -> None:
"""Test (valid) POST /condition and its effect on GET /condition[s]."""
- # test valid POST's effect on …
- post = {'title': 'foo', 'description': 'oof', 'is_active': False}
- self.check_post(post, '/condition', redir='/condition?id=1')
- # … single /condition
- expected_cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
- assert isinstance(expected_cond['_versioned'], dict)
- expected_single = self.GET_condition_dict(expected_cond)
- self.check_json_get('/condition?id=1', expected_single)
- # … full /conditions
- expected_all = self.GET_conditions_dict([expected_cond])
- self.check_json_get('/conditions', expected_all)
+ url_single, url_all = '/condition?id=1', '/conditions'
+ exp_single, exp_all = ExpectedGetCondition(1), ExpectedGetConditions()
+ all_exps = [exp_single, exp_all]
+ # test valid POST's effect on single /condition and full /conditions
+ self.post_exp_cond(all_exps, {}, post_to_id=False)
+ self.check_json_get(url_single, exp_single)
+ self.check_json_get(url_all, exp_all)
# test (no) effect of invalid POST to existing Condition on /condition
- self.check_post({}, '/condition?id=1', 400)
- self.check_json_get('/condition?id=1', expected_single)
- # test effect of POST changing title and activeness
- post = {'title': 'bar', 'description': 'oof', 'is_active': True}
- self.check_post(post, '/condition?id=1')
- expected_cond['_versioned']['title'][1] = 'bar'
- expected_cond['is_active'] = True
- self.check_json_get('/condition?id=1', expected_single)
- # test deletion POST's effect, both to return id=1 into empty single, …
- self.check_post({'delete': ''}, '/condition?id=1', redir='/conditions')
- expected_cond = self.cond_as_dict()
- assert isinstance(expected_single['_library'], dict)
- expected_single['_library']['Condition'] = self.as_refs(
- [expected_cond])
- self.check_json_get('/condition?id=1', expected_single)
- # … and full /conditions into empty list
- expected_all['conditions'] = []
- expected_all['_library'] = {}
- self.check_json_get('/conditions', expected_all)
+ self.check_post({}, url_single, 400)
+ self.check_json_get(url_single, exp_single)
+ # test effect of POST changing title, description, and activeness
+ self.post_exp_cond(all_exps, {'title': 'bar', 'description': 'oof',
+ 'is_active': 1})
+ self.check_json_get(url_single, exp_single)
+ # test POST sans 'is_active' setting it negative
+ self.post_exp_cond(all_exps, {})
+ self.check_json_get(url_single, exp_single)
+ # test deletion POST's effect, both to return id=1 into empty single,
+ # full /conditions into empty list
+ self.check_json_get(url_single, exp_single)
+ self.post_exp_cond(all_exps, {'delete': ''}, redir_to_id=False)
+ exp_single.set('is_new', True)
+ self.check_json_get(url_single, exp_single)
+ self.check_json_get(url_all, exp_all)
def test_GET_condition(self) -> None:
"""More GET /condition testing, especially for Process relations."""
# check expected default status codes
self.check_get_defaults('/condition')
+ # check 'is_new' set if id= absent or pointing to not-yet-existing ID
+ exp = ExpectedGetCondition(None)
+ exp.set('is_new', True)
+ self.check_json_get('/condition', exp)
+ exp = ExpectedGetCondition(1)
+ exp.set('is_new', True)
+ self.check_json_get('/condition?id=1', exp)
# make Condition and two Processes that among them establish all
- # possible ConditionsRelations to it, …
- cond_post = {'title': 'foo', 'description': 'oof', 'is_active': False}
- self.check_post(cond_post, '/condition', redir='/condition?id=1')
- proc1_post = {'title': 'A', 'description': '', 'effort': 1.0,
- 'conditions': [1], 'disables': [1]}
- proc2_post = {'title': 'B', 'description': '', 'effort': 1.0,
- 'enables': [1], 'blockers': [1]}
- self.post_process(1, proc1_post)
- self.post_process(2, proc2_post)
- # … then check /condition displays all these properly.
- cond_expected = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
- assert isinstance(cond_expected['id'], int)
- proc1 = self.proc_as_dict(conditions=[cond_expected['id']],
- disables=[cond_expected['id']])
- proc2 = self.proc_as_dict(2, 'B',
- blockers=[cond_expected['id']],
- enables=[cond_expected['id']])
- display_expected = self.GET_condition_dict(cond_expected)
- assert isinstance(display_expected['_library'], dict)
- display_expected['enabled_processes'] = self.as_id_list([proc1])
- display_expected['disabled_processes'] = self.as_id_list([proc2])
- display_expected['enabling_processes'] = self.as_id_list([proc2])
- display_expected['disabling_processes'] = self.as_id_list([proc1])
- display_expected['_library']['Process'] = self.as_refs([proc1, proc2])
- self.check_json_get('/condition?id=1', display_expected)
+ # possible ConditionsRelations to it, check /condition displays all
+ exp = ExpectedGetCondition(1)
+ self.post_exp_cond([exp], {}, post_to_id=False)
+ for i, p in enumerate([('conditions', 'disables'),
+ ('enables', 'blockers')]):
+ self.post_exp_process([exp], {k: [1] for k in p}, i+1)
+ self.check_json_get('/condition?id=1', exp)
def test_GET_conditions(self) -> None:
"""Test GET /conditions."""
# test empty result on empty DB, default-settings on empty params
- expected = self.GET_conditions_dict([])
- self.check_json_get('/conditions', expected)
- # test ignorance of meaningless non-empty params (incl. unknown key),
- # that 'sort_by' default to 'title' (even if set to something else, as
+ exp = ExpectedGetConditions()
+ self.check_json_get('/conditions', exp)
+ # test 'sort_by' default to 'title' (even if set to something else, as
# long as without handler) and 'pattern' get preserved
- expected['pattern'] = 'bar' # preserved despite zero effect!
- expected['sort_by'] = 'title' # for clarity (actually already set)
- url = '/conditions?sort_by=foo&pattern=bar&foo=x'
- self.check_json_get(url, expected)
+ exp.set('pattern', 'bar')
+ self.check_json_get('/conditions?sort_by=foo&pattern=bar&foo=x', exp)
+ exp.set('pattern', '')
# test non-empty result, automatic (positive) sorting by title
- post_cond1 = {'is_active': False, 'title': 'foo', 'description': 'oof'}
- post_cond2 = {'is_active': False, 'title': 'bar', 'description': 'rab'}
- post_cond3 = {'is_active': True, 'title': 'baz', 'description': 'zab'}
- self.check_post(post_cond1, '/condition', redir='/condition?id=1')
- self.check_post(post_cond2, '/condition', redir='/condition?id=2')
- self.check_post(post_cond3, '/condition', redir='/condition?id=3')
- cond1 = self.cond_as_dict(1, False, ['foo'], ['oof'])
- cond2 = self.cond_as_dict(2, False, ['bar'], ['rab'])
- cond3 = self.cond_as_dict(3, True, ['baz'], ['zab'])
- expected = self.GET_conditions_dict([cond2, cond3, cond1])
- self.check_json_get('/conditions', expected)
+ post_cond1 = {'is_active': 0, 'title': 'foo', 'description': 'oof'}
+ post_cond2 = {'is_active': 0, 'title': 'bar', 'description': 'rab'}
+ post_cond3 = {'is_active': 1, 'title': 'baz', 'description': 'zab'}
+ for i, post in enumerate([post_cond1, post_cond2, post_cond3]):
+ self.post_exp_cond([exp], post, i+1, post_to_id=False)
+ self.check_filter(exp, 'conditions', 'sort_by', 'title', [2, 3, 1])
# test other sortings
- expected['sort_by'] = '-title'
- assert isinstance(expected['conditions'], list)
- expected['conditions'].reverse()
- self.check_json_get('/conditions?sort_by=-title', expected)
- expected['sort_by'] = 'is_active'
- expected['conditions'] = self.as_id_list([cond1, cond2, cond3])
- self.check_json_get('/conditions?sort_by=is_active', expected)
- expected['sort_by'] = '-is_active'
- expected['conditions'].reverse()
- self.check_json_get('/conditions?sort_by=-is_active', expected)
+ self.check_filter(exp, 'conditions', 'sort_by', '-title', [1, 3, 2])
+ self.check_filter(exp, 'conditions', 'sort_by', 'is_active', [1, 2, 3])
+ self.check_filter(exp, 'conditions', 'sort_by', '-is_active',
+ [3, 2, 1])
+ exp.set('sort_by', 'title')
# test pattern matching on title
- expected = self.GET_conditions_dict([cond2, cond3])
- expected['pattern'] = 'ba'
- self.check_json_get('/conditions?pattern=ba', expected)
+ exp.lib_del('Condition', 1)
+ self.check_filter(exp, 'conditions', 'pattern', 'ba', [2, 3])
# test pattern matching on description
- assert isinstance(expected['_library'], dict)
- expected['pattern'] = 'of'
- expected['conditions'] = self.as_id_list([cond1])
- expected['_library']['Condition'] = self.as_refs([cond1])
- self.check_json_get('/conditions?pattern=of', expected)
+ exp.lib_wipe('Condition')
+ exp.set_cond_from_post(1, post_cond1)
+ self.check_filter(exp, 'conditions', 'pattern', 'of', [1])
"""Test Days module."""
from datetime import datetime, timedelta
-from typing import Callable
-from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
-from plomtask.dating import date_in_n_days, DATE_FORMAT
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+ Expected)
+from plomtask.dating import date_in_n_days as tested_date_in_n_days
from plomtask.days import Day
+# so far the same as plomtask.dating.DATE_FORMAT, but for testing purposes we
+# want to explicitly state our expectations here indepedently from that
+TESTING_DATE_FORMAT = '%Y-%m-%d'
+
+
+def _testing_date_in_n_days(n: int) -> str:
+ """Return in TEST_DATE_FORMAT date from today + n days.
+
+ As with TESTING_DATE_FORMAT, we assume this equal the original's code
+ at plomtask.dating.date_in_n_days, but want to state our expectations
+ explicitly to rule out importing issues from the original.
+ """
+ date = datetime.now() + timedelta(days=n)
+ return date.strftime(TESTING_DATE_FORMAT)
+
class TestsSansDB(TestCaseSansDB):
"""Days module tests not requiring DB setup."""
illegal_ids = ['foo', '2023-02-29', '2024-02-30', '2024-02-01 23:00:00']
def test_date_in_n_days(self) -> None:
- """Test dating.date_in_n_days, as we rely on it in later tests."""
+ """Test dating.date_in_n_days"""
for n in [-100, -2, -1, 0, 1, 2, 1000]:
date = datetime.now() + timedelta(days=n)
- self.assertEqual(date_in_n_days(n), date.strftime(DATE_FORMAT))
+ self.assertEqual(tested_date_in_n_days(n),
+ date.strftime(TESTING_DATE_FORMAT))
def test_Day_datetime_weekday_neighbor_dates(self) -> None:
"""Test Day's date parsing and neighbourhood resolution."""
def test_Day_with_filled_gaps(self) -> None:
"""Test .with_filled_gaps."""
- def test(range_indexes: tuple[int, int], indexes_to_provide: list[int]
+ def expect_within_full_range_as_commented(
+ range_indexes: tuple[int, int],
+ indexes_to_provide: list[int]
) -> None:
start_i, end_i = range_indexes
days_provided = []
self.assertEqual(days_result, days_expected)
# for provided Days we use those from days_with_comment, to identify
- # them against mere filler days by their lack of comment (identity
- # with Day at the respective position in days_sans_comment)
+ # them against same-dated mere filler Days by their lack of comment
+ # (identity with Day at the respective position in days_sans_comment)
dates = [f'2024-02-0{n+1}' for n in range(9)]
days_with_comment = [Day(date, comment=date[-1:]) for date in dates]
days_sans_comment = [Day(date, comment='') for date in dates]
# check provided Days recognizable in (full-range) interval
- test((0, 8), [0, 4, 8])
+ expect_within_full_range_as_commented((0, 8), [0, 4, 8])
# check limited range, but limiting Days provided
- test((2, 6), [2, 5, 6])
+ expect_within_full_range_as_commented((2, 6), [2, 5, 6])
# check Days within range but beyond provided Days also filled in
- test((1, 7), [2, 5])
+ expect_within_full_range_as_commented((1, 7), [2, 5])
# check provided Days beyond range ignored
- test((3, 5), [1, 2, 4, 6, 7])
+ expect_within_full_range_as_commented((3, 5), [1, 2, 4, 6, 7])
# check inversion of start_date and end_date returns empty list
- test((5, 3), [2, 4, 6])
+ expect_within_full_range_as_commented((5, 3), [2, 4, 6])
# check empty provision still creates filler elements in interval
- test((3, 5), [])
+ expect_within_full_range_as_commented((3, 5), [])
# check single-element selection creating only filler beyond provided
- test((1, 1), [2, 4, 6])
+ expect_within_full_range_as_commented((1, 1), [2, 4, 6])
# check (un-saved) filler Days don't show up in cache or DB
- # dates = [f'2024-02-0{n}' for n in range(1, 6)]
day = Day(dates[3])
day.save(self.db_conn)
self.checked_class.with_filled_gaps([day], dates[0], dates[-1])
self.check_identity_with_cache_and_db([day])
- # check 'today', 'yesterday', 'tomorrow' are interpreted
- yesterday = Day('yesterday')
- tomorrow = Day('tomorrow')
- today = Day('today')
- result = self.checked_class.with_filled_gaps([today], 'yesterday',
- 'tomorrow')
- self.assertEqual(result, [yesterday, today, tomorrow])
+
+
+class ExpectedGetCalendar(Expected):
+ """Builder of expectations for GET /calendar."""
+
+ def __init__(self, start: int, end: int, *args: Any, **kwargs: Any
+ ) -> None:
+ self._fields = {'start': _testing_date_in_n_days(start),
+ 'end': _testing_date_in_n_days(end),
+ 'today': _testing_date_in_n_days(0)}
+ self._fields['days'] = [_testing_date_in_n_days(i)
+ for i in range(start, end+1)]
+ super().__init__(*args, **kwargs)
+ for date in self._fields['days']:
+ self.lib_set('Day', [self.day_as_dict(date)])
+
+
+class ExpectedGetDay(Expected):
+ """Builder of expectations for GET /day."""
+ _default_dict = {'make_type': 'full'}
+ _on_empty_make_temp = ('Day', 'day_as_dict')
+
+ def __init__(self, date: str, *args: Any, **kwargs: Any) -> None:
+ self._fields = {'day': date}
+ super().__init__(*args, **kwargs)
+
+ def recalc(self) -> None:
+ super().recalc()
+ todos = [t for t in self.lib_all('Todo')
+ if t['date'] == self._fields['day']]
+ self.lib_get('Day', self._fields['day'])['todos'] = self.as_ids(todos)
+ self._fields['top_nodes'] = [
+ {'children': [], 'seen': 0, 'todo': todo['id']}
+ for todo in todos]
+ for todo in todos:
+ proc = self.lib_get('Process', todo['process_id'])
+ for title in ['conditions', 'enables', 'blockers', 'disables']:
+ todo[title] = proc[title]
+ conds_present = set()
+ for todo in todos:
+ for title in ['conditions', 'enables', 'blockers', 'disables']:
+ for cond_id in todo[title]:
+ conds_present.add(cond_id)
+ self._fields['conditions_present'] = list(conds_present)
+ for prefix in ['en', 'dis']:
+ blers = {}
+ for cond_id in conds_present:
+ blers[str(cond_id)] = self.as_ids(
+ [t for t in todos if cond_id in t[f'{prefix}ables']])
+ self._fields[f'{prefix}ablers_for'] = blers
+ self._fields['processes'] = self.as_ids(self.lib_all('Process'))
class TestsWithServer(TestCaseWithServer):
"""Tests against our HTTP server/handler (and database)."""
-
- @classmethod
- def GET_day_dict(cls, date: str) -> dict[str, object]:
- """Return JSON of GET /day to expect."""
- # day: dict[str, object] = {'id': date, 'comment': '', 'todos': []}
- day = cls._day_as_dict(date)
- d: dict[str, object] = {'day': date,
- 'top_nodes': [],
- 'make_type': '',
- 'enablers_for': {},
- 'disablers_for': {},
- 'conditions_present': [],
- 'processes': [],
- '_library': {'Day': cls.as_refs([day])}}
- return d
-
- @classmethod
- def GET_calendar_dict(cls, start: int, end: int) -> dict[str, object]:
- """Return JSON of GET /calendar to expect.
-
- NB: the date string list to key 'days' implies/expects a continuous (=
- gaps filled) alphabetical order of dates by virtue of range(start,
- end+1) and date_in_n_days tested in TestsSansDB.test_date_in_n_days.
- """
- today_date = date_in_n_days(0)
- start_date = date_in_n_days(start)
- end_date = date_in_n_days(end)
- dates = [date_in_n_days(i) for i in range(start, end+1)]
- days = [cls._day_as_dict(d) for d in dates]
- library = {'Day': cls.as_refs(days)} if len(days) > 0 else {}
- return {'today': today_date, 'start': start_date, 'end': end_date,
- 'days': dates, '_library': library}
-
- @staticmethod
- def _todo_as_dict(id_: int = 1,
- process_id: int = 1,
- date: str = '2024-01-01',
- conditions: None | list[int] = None,
- disables: None | list[int] = None,
- blockers: None | list[int] = None,
- enables: None | list[int] = None
- ) -> dict[str, object]:
- """Return JSON of Todo to expect."""
- # pylint: disable=too-many-arguments
- d = {'id': id_,
- 'date': date,
- 'process_id': process_id,
- 'is_done': False,
- 'calendarize': False,
- 'comment': '',
- 'children': [],
- 'parents': [],
- 'effort': None,
- 'conditions': conditions if conditions else [],
- 'disables': disables if disables else [],
- 'blockers': blockers if blockers else [],
- 'enables': enables if enables else []}
- return d
-
- @staticmethod
- def _todo_node_as_dict(todo_id: int) -> dict[str, object]:
- """Return JSON of TodoNode to expect."""
- return {'children': [], 'seen': False, 'todo': todo_id}
-
- @staticmethod
- def _day_as_dict(date: str) -> dict[str, object]:
- return {'id': date, 'comment': '', 'todos': []}
-
- @staticmethod
- def _post_batch(list_of_args: list[list[object]],
- names_of_simples: list[str],
- names_of_versioneds: list[str],
- f_as_dict: Callable[..., dict[str, object]],
- f_to_post: Callable[..., None | dict[str, object]]
- ) -> list[dict[str, object]]:
- """Post expected=f_as_dict(*args) as input to f_to_post, for many."""
- expecteds = []
- for args in list_of_args:
- expecteds += [f_as_dict(*args)]
- for expected in expecteds:
- assert isinstance(expected['_versioned'], dict)
- post = {}
- for name in names_of_simples:
- post[name] = expected[name]
- for name in names_of_versioneds:
- post[name] = expected['_versioned'][name][0]
- f_to_post(expected['id'], post)
- return expecteds
-
- def _post_day(self, params: str = '',
- form_data: None | dict[str, object] = None,
- redir_to: str = '',
- status: int = 302,
- ) -> None:
- """POST /day?{params} with form_data."""
- if not form_data:
- form_data = {'day_comment': '', 'make_type': ''}
- target = f'/day?{params}'
- if not redir_to:
- redir_to = f'{target}&make_type={form_data["make_type"]}'
- self.check_post(form_data, target, status, redir_to)
+ checked_class = Day
def test_basic_GET_day(self) -> None:
"""Test basic (no Processes/Conditions/Todos) GET /day basics."""
# check illegal date parameters
- self.check_get('/day?date=foo', 400)
+ self.check_get_defaults('/day', '2024-01-01', 'date')
self.check_get('/day?date=2024-02-30', 400)
# check undefined day
- date = date_in_n_days(0)
- expected = self.GET_day_dict(date)
- self.check_json_get('/day', expected)
- # NB: GET ?date="today"/"yesterday"/"tomorrow" in test_basic_POST_day
- # check 'make_type' GET parameter affects immediate reply, but …
+ exp = ExpectedGetDay(_testing_date_in_n_days(0))
+ self.check_json_get('/day', exp)
+ # check defined day with make_type parameter
date = '2024-01-01'
- expected = self.GET_day_dict(date)
- expected['make_type'] = 'bar'
- self.check_json_get(f'/day?date={date}&make_type=bar', expected)
- # … not any following, …
- expected['make_type'] = ''
- self.check_json_get(f'/day?date={date}', expected)
- # … not even when part of a POST request
- post: dict[str, object] = {'day_comment': '', 'make_type': 'foo'}
- self._post_day(f'date={date}', post)
- self.check_json_get(f'/day?date={date}', expected)
+ exp = ExpectedGetDay(date)
+ exp.set('make_type', 'bar')
+ self.check_json_get(f'/day?date={date}&make_type=bar', exp)
+ # check parsing of 'yesterday', 'today', 'tomorrow'
+ for name, dist in [('yesterday', -1), ('today', 0), ('tomorrow', +1)]:
+ date = _testing_date_in_n_days(dist)
+ exp = ExpectedGetDay(date)
+ self.check_json_get(f'/day?date={name}', exp)
def test_fail_POST_day(self) -> None:
"""Test malformed/illegal POST /day requests."""
# check payloads lacking minimum expecteds
url = '/day?date=2024-01-01'
- self.check_post({}, url, 400)
- self.check_post({'day_comment': ''}, url, 400)
- self.check_post({'make_type': ''}, url, 400)
+ minimal_post = {'make_type': '', 'day_comment': ''}
+ self.check_minimal_inputs(url, minimal_post)
# to next check illegal new_todo values, we need an actual Process
- self.post_process(1)
+ self.post_exp_process([], {}, 1)
# check illegal new_todo values
- post: dict[str, object]
- post = {'make_type': '', 'day_comment': '', 'new_todo': ['foo']}
- self.check_post(post, url, 400)
- post['new_todo'] = [1, 2] # no Process of .id_=2 exists
+ self.check_post(minimal_post | {'new_todo': ['foo']}, url, 400)
+ self.check_post(minimal_post | {'new_todo': [1, 2]}, url, 404)
# to next check illegal old_todo inputs, we need to first post Todo
- post['new_todo'] = [1]
- self.check_post(post, url, 302, '/day?date=2024-01-01&make_type=')
+ self.check_post(minimal_post | {'new_todo': [1]}, url, 302,
+ '/day?date=2024-01-01&make_type=')
# check illegal old_todo inputs (equal list lengths though)
- post = {'make_type': '', 'day_comment': '', 'comment': ['foo'],
- 'effort': [3.3], 'done': [], 'todo_id': [1]}
+ post = minimal_post | {'comment': ['foo'], 'effort': [3.3],
+ 'done': [], 'todo_id': [1]}
self.check_post(post, url, 302, '/day?date=2024-01-01&make_type=')
post['todo_id'] = [2] # reference to non-existant Process
self.check_post(post, url, 404)
self.check_post(post, '/day?date=foo', 400)
def test_basic_POST_day(self) -> None:
- """Test basic (no Todos) POST /day.
+ """Test basic (no Processes/Conditions/Todos) POST /day.
- Check POST (& GET!) requests properly parse 'today', 'tomorrow',
- 'yesterday', and actual date strings;
- preserve 'make_type' setting in redirect even if nonsensical;
- and store 'day_comment'
+ Check POST requests properly parse 'today', 'tomorrow', 'yesterday',
+ and actual date strings; store 'day_comment'; preserve 'make_type'
+ setting in redirect even if nonsensical; and allow '' as 'new_todo'.
"""
for name, dist, test_str in [('2024-01-01', None, 'a'),
('today', 0, 'b'),
('yesterday', -1, 'c'),
('tomorrow', +1, 'd')]:
- date = name if dist is None else date_in_n_days(dist)
- post = {'day_comment': test_str, 'make_type': f'x:{test_str}'}
+ date = name if dist is None else _testing_date_in_n_days(dist)
+ post = {'day_comment': test_str, 'make_type': f'x:{test_str}',
+ 'new_todo': ['', '']}
post_url = f'/day?date={name}'
redir_url = f'{post_url}&make_type={post["make_type"]}'
self.check_post(post, post_url, 302, redir_url)
- expected = self.GET_day_dict(date)
- assert isinstance(expected['_library'], dict)
- expected['_library']['Day'][date]['comment'] = test_str
- self.check_json_get(post_url, expected)
+ exp = ExpectedGetDay(date)
+ exp.set_day_from_post(date, post)
+ self.check_json_get(post_url, exp)
def test_GET_day_with_processes_and_todos(self) -> None:
"""Test GET /day displaying Processes and Todos (no trees)."""
date = '2024-01-01'
- # check Processes get displayed in ['processes'] and ['_library']
- procs_data = [[1, 'foo', 'oof', 1.1], [2, 'bar', 'rab', 0.9]]
- procs_expected = self._post_batch(procs_data, [],
- ['title', 'description', 'effort'],
- self.proc_as_dict, self.post_process)
- expected = self.GET_day_dict(date)
- assert isinstance(expected['_library'], dict)
- expected['processes'] = self.as_id_list(procs_expected)
- expected['_library']['Process'] = self.as_refs(procs_expected)
- self._post_day(f'date={date}')
- self.check_json_get(f'/day?date={date}', expected)
- # post Todos of either process and check their display
- post_day: dict[str, object]
- post_day = {'day_comment': '', 'make_type': '', 'new_todo': [1, 2]}
- todos = [self._todo_as_dict(1, 1, date),
- self._todo_as_dict(2, 2, date)]
- expected['_library']['Todo'] = self.as_refs(todos)
- expected['_library']['Day'][date]['todos'] = self.as_id_list(todos)
- nodes = [self._todo_node_as_dict(1), self._todo_node_as_dict(2)]
- expected['top_nodes'] = nodes
- self._post_day(f'date={date}', post_day)
- self.check_json_get(f'/day?date={date}', expected)
+ exp = ExpectedGetDay(date)
+ # check Processes get displayed in ['processes'] and ['_library'],
+ # even without any Todos referencing them
+ proc_posts = [{'title': 'foo', 'description': 'oof', 'effort': 1.1},
+ {'title': 'bar', 'description': 'rab', 'effort': 0.9}]
+ for i, proc_post in enumerate(proc_posts):
+ self.post_exp_process([exp], proc_post, i+1)
+ self.check_json_get(f'/day?date={date}', exp)
+ # post Todos of either Process and check their display
+ self.post_exp_day([exp], {'new_todo': [1, 2]})
+ self.check_json_get(f'/day?date={date}', exp)
+ # test malformed Todo manipulation posts
+ post_day = {'day_comment': '', 'make_type': '', 'comment': [''],
+ 'new_todo': [], 'done': [1], 'effort': [2.3]}
+ self.check_post(post_day, f'/day?date={date}', 400) # no todo_id
+ post_day['todo_id'] = [2] # not identifying Todo refered by done
+ self.check_post(post_day, f'/day?date={date}', 400)
+ post_day['todo_id'] = [1, 2] # imply range beyond that of effort etc.
+ self.check_post(post_day, f'/day?date={date}', 400)
+ post_day['comment'] = ['FOO', '']
+ self.check_post(post_day, f'/day?date={date}', 400)
+ post_day['effort'] = [2.3, '']
+ post_day['comment'] = ['']
+ self.check_post(post_day, f'/day?date={date}', 400)
# add a comment to one Todo and set the other's doneness and effort
- post_day = {'day_comment': '', 'make_type': '', 'new_todo': [],
- 'todo_id': [1, 2], 'done': [2], 'comment': ['FOO', ''],
- 'effort': [2.3, '']}
- expected['_library']['Todo']['1']['comment'] = 'FOO'
- expected['_library']['Todo']['1']['effort'] = 2.3
- expected['_library']['Todo']['2']['is_done'] = True
- self._post_day(f'date={date}', post_day)
- self.check_json_get(f'/day?date={date}', expected)
+ post_day['comment'] = ['FOO', '']
+ self.post_exp_day([exp], post_day)
+ self.check_json_get(f'/day?date={date}', exp)
+ # invert effort and comment between both Todos
+ # (cannot invert doneness, /day only collects positive setting)
+ post_day['comment'] = ['', 'FOO']
+ post_day['effort'] = ['', 2.3]
+ self.post_exp_day([exp], post_day)
+ self.check_json_get(f'/day?date={date}', exp)
+
+ def test_POST_day_todo_make_types(self) -> None:
+ """Test behavior of POST /todo on 'make_type'='full' and 'empty'."""
+ date = '2024-01-01'
+ exp = ExpectedGetDay(date)
+ # create two Processes, with second one step of first one
+ self.post_exp_process([exp], {}, 2)
+ self.post_exp_process([exp], {'new_top_step': 2}, 1)
+ exp.lib_set('ProcessStep', [
+ exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
+ self.check_json_get(f'/day?date={date}', exp)
+ # post Todo of adopting Process, with make_type=full
+ self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
+ exp.lib_get('Todo', 1)['children'] = [2]
+ exp.lib_set('Todo', [exp.todo_as_dict(2, 2)])
+ top_nodes = [{'todo': 1,
+ 'seen': 0,
+ 'children': [{'todo': 2,
+ 'seen': 0,
+ 'children': []}]}]
+ exp.force('top_nodes', top_nodes)
+ self.check_json_get(f'/day?date={date}', exp)
+ # post another Todo of adopting Process, expect to adopt existing
+ self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1]})
+ exp.lib_set('Todo', [exp.todo_as_dict(3, 1, children=[2])])
+ top_nodes += [{'todo': 3,
+ 'seen': 0,
+ 'children': [{'todo': 2,
+ 'seen': 1,
+ 'children': []}]}]
+ exp.force('top_nodes', top_nodes)
+ self.check_json_get(f'/day?date={date}', exp)
+ # post another Todo of adopting Process, no adopt with make_type=empty
+ self.post_exp_day([exp], {'make_type': 'empty', 'new_todo': [1]})
+ exp.lib_set('Todo', [exp.todo_as_dict(4, 1)])
+ top_nodes += [{'todo': 4,
+ 'seen': 0,
+ 'children': []}]
+ exp.force('top_nodes', top_nodes)
+ self.check_json_get(f'/day?date={date}', exp)
+
+ def test_POST_day_new_todo_order_commutative(self) -> None:
+ """Check that order of 'new_todo' values in POST /day don't matter."""
+ date = '2024-01-01'
+ exp = ExpectedGetDay(date)
+ self.post_exp_process([exp], {}, 2)
+ self.post_exp_process([exp], {'new_top_step': 2}, 1)
+ exp.lib_set('ProcessStep', [
+ exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
+ # make-full-day-post batch of Todos of both Processes in one order …,
+ self.post_exp_day([exp], {'make_type': 'full', 'new_todo': [1, 2]})
+ top_nodes: list[dict[str, Any]] = [{'todo': 1,
+ 'seen': 0,
+ 'children': [{'todo': 2,
+ 'seen': 0,
+ 'children': []}]}]
+ exp.force('top_nodes', top_nodes)
+ exp.lib_get('Todo', 1)['children'] = [2]
+ self.check_json_get(f'/day?date={date}', exp)
+ # … and then in the other, expecting same node tree / relations
+ exp.lib_del('Day', date)
+ date = '2024-01-02'
+ exp.set('day', date)
+ day_post = {'make_type': 'full', 'new_todo': [2, 1]}
+ self.post_exp_day([exp], day_post, date)
+ exp.lib_del('Todo', 1)
+ exp.lib_del('Todo', 2)
+ top_nodes[0]['todo'] = 3 # was: 1
+ top_nodes[0]['children'][0]['todo'] = 4 # was: 2
+ exp.lib_get('Todo', 3)['children'] = [4]
+ self.check_json_get(f'/day?date={date}', exp)
+
+ def test_POST_day_todo_deletion_by_negative_effort(self) -> None:
+ """Test POST /day removal of Todos by setting negative effort."""
+ date = '2024-01-01'
+ exp = ExpectedGetDay(date)
+ self.post_exp_process([exp], {}, 1)
+ self.post_exp_day([exp], {'new_todo': [1]})
+ # check cannot remove Todo if commented
+ self.post_exp_day([exp],
+ {'todo_id': [1], 'comment': ['foo'], 'effort': [-1]})
+ self.check_json_get(f'/day?date={date}', exp)
+ # check *can* remove Todo while getting done
+ self.post_exp_day([exp],
+ {'todo_id': [1], 'comment': [''], 'effort': [-1],
+ 'done': [1]})
+ exp.lib_del('Todo', 1)
+ self.check_json_get(f'/day?date={date}', exp)
def test_GET_day_with_conditions(self) -> None:
"""Test GET /day displaying Conditions and their relations."""
date = '2024-01-01'
- # add Process with Conditions and their Todos, check display
- conds_data = [[1, False, ['A'], ['a']], [2, True, ['B'], ['b']]]
- conds_expected = self._post_batch(
- conds_data, ['is_active'], ['title', 'description'],
- self.cond_as_dict,
- lambda x, y: self.check_post(y, f'/condition?id={x}', 302))
- cond_names = ['conditions', 'disables', 'blockers', 'enables']
- procs_data = [[1, 'foo', 'oof', 1.1, [1], [1], [2], [2]],
- [2, 'bar', 'rab', 0.9, [2], [2], [1], [1]]]
- procs_expected = self._post_batch(procs_data, cond_names,
- ['title', 'description', 'effort'],
- self.proc_as_dict, self.post_process)
- expected = self.GET_day_dict(date)
- assert isinstance(expected['_library'], dict)
- expected['processes'] = self.as_id_list(procs_expected)
- expected['_library']['Process'] = self.as_refs(procs_expected)
- expected['_library']['Condition'] = self.as_refs(conds_expected)
- self._post_day(f'date={date}')
- self.check_json_get(f'/day?date={date}', expected)
- # add Todos in relation to Conditions, check consequences
- post_day: dict[str, object]
- post_day = {'day_comment': '', 'make_type': '', 'new_todo': [1, 2]}
- todos = [self._todo_as_dict(1, 1, date, [1], [1], [2], [2]),
- self._todo_as_dict(2, 2, date, [2], [2], [1], [1])]
- expected['_library']['Todo'] = self.as_refs(todos)
- expected['_library']['Day'][date]['todos'] = self.as_id_list(todos)
- nodes = [self._todo_node_as_dict(1), self._todo_node_as_dict(2)]
- expected['top_nodes'] = nodes
- expected['disablers_for'] = {'1': [1], '2': [2]}
- expected['enablers_for'] = {'1': [2], '2': [1]}
- expected['conditions_present'] = self.as_id_list(conds_expected)
- self._post_day(f'date={date}', post_day)
- self.check_json_get(f'/day?date={date}', expected)
+ exp = ExpectedGetDay(date)
+ # check non-referenced Conditions not shown
+ cond_posts = [{'is_active': 0, 'title': 'A', 'description': 'a'},
+ {'is_active': 1, 'title': 'B', 'description': 'b'}]
+ for i, cond_post in enumerate(cond_posts):
+ self.check_post(cond_post, f'/condition?id={i+1}')
+ self.check_json_get(f'/day?date={date}', exp)
+ # add Processes with Conditions, check Conditions now shown
+ for i, (c1, c2) in enumerate([(1, 2), (2, 1)]):
+ post = {'conditions': [c1], 'disables': [c1],
+ 'blockers': [c2], 'enables': [c2]}
+ self.post_exp_process([exp], post, i+1)
+ for i, cond_post in enumerate(cond_posts):
+ exp.set_cond_from_post(i+1, cond_post)
+ self.check_json_get(f'/day?date={date}', exp)
+ # add Todos in relation to Conditions, check consequence relations
+ self.post_exp_day([exp], {'new_todo': [1, 2]})
+ self.check_json_get(f'/day?date={date}', exp)
def test_GET_calendar(self) -> None:
"""Test GET /calendar responses based on various inputs, DB states."""
self.check_get('/calendar?start=foo', 400)
self.check_get('/calendar?end=foo', 400)
# check default range for expected selection/order without saved days
- expected = self.GET_calendar_dict(-1, 366)
- self.check_json_get('/calendar', expected)
- self.check_json_get('/calendar?start=&end=', expected)
+ exp = ExpectedGetCalendar(-1, 366)
+ self.check_json_get('/calendar', exp)
+ self.check_json_get('/calendar?start=&end=', exp)
# check with named days as delimiters
- expected = self.GET_calendar_dict(-1, +1)
- self.check_json_get('/calendar?start=yesterday&end=tomorrow', expected)
+ exp = ExpectedGetCalendar(-1, +1)
+ self.check_json_get('/calendar?start=yesterday&end=tomorrow', exp)
# check zero-element range
- expected = self.GET_calendar_dict(+1, 0)
- self.check_json_get('/calendar?start=tomorrow&end=today', expected)
+ exp = ExpectedGetCalendar(+1, 0)
+ self.check_json_get('/calendar?start=tomorrow&end=today', exp)
# check saved day shows up in results, proven by its comment
- post_day: dict[str, object] = {'day_comment': 'foo', 'make_type': ''}
- date1 = date_in_n_days(-2)
- self._post_day(f'date={date1}', post_day)
- start_date = date_in_n_days(-5)
- end_date = date_in_n_days(+5)
+ start_date = _testing_date_in_n_days(-5)
+ date = _testing_date_in_n_days(-2)
+ end_date = _testing_date_in_n_days(+5)
+ exp = ExpectedGetCalendar(-5, +5)
+ self.post_exp_day([exp], {'day_comment': 'foo'}, date)
url = f'/calendar?start={start_date}&end={end_date}'
- expected = self.GET_calendar_dict(-5, +5)
- assert isinstance(expected['_library'], dict)
- expected['_library']['Day'][date1]['comment'] = post_day['day_comment']
- self.check_json_get(url, expected)
+ self.check_json_get(url, exp)
class TestsSansServer(TestCase):
"""Tests that do not require DB setup or a server."""
- def test_InputsParser_get_str(self) -> None:
- """Test InputsParser.get_str on strict and non-strictk."""
- parser = InputsParser({}, False)
- self.assertEqual('', parser.get_str('foo'))
- self.assertEqual('bar', parser.get_str('foo', 'bar'))
- parser.strict = True
+ def test_InputsParser_get_str_or_fail(self) -> None:
+ """Test InputsParser.get_str."""
+ parser = InputsParser({})
with self.assertRaises(BadFormatException):
- parser.get_str('foo')
+ parser.get_str_or_fail('foo')
+ self.assertEqual('bar', parser.get_str_or_fail('foo', 'bar'))
+ parser = InputsParser({'foo': []})
with self.assertRaises(BadFormatException):
- parser.get_str('foo', 'bar')
- parser = InputsParser({'foo': []}, False)
- self.assertEqual('bar', parser.get_str('foo', 'bar'))
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}, True).get_str('foo', 'bar')
- for strictness in (False, True):
- parser = InputsParser({'foo': ['baz']}, strictness)
- self.assertEqual('baz', parser.get_str('foo', 'bar'))
- parser = InputsParser({'foo': ['baz', 'quux']}, strictness)
- self.assertEqual('baz', parser.get_str('foo', 'bar'))
+ parser.get_str_or_fail('foo')
+ self.assertEqual('bar', parser.get_str_or_fail('foo', 'bar'))
+ parser = InputsParser({'foo': ['baz']})
+ self.assertEqual('baz', parser.get_str_or_fail('foo', 'bar'))
+ parser = InputsParser({'foo': ['baz', 'quux']})
+ self.assertEqual('baz', parser.get_str_or_fail('foo', 'bar'))
- def test_InputsParser_get_first_strings_starting(self) -> None:
- """Test InputsParser.get_first_strings_starting [non-]strict."""
- for strictness in (False, True):
- parser = InputsParser({}, strictness)
- self.assertEqual({},
- parser.get_first_strings_starting(''))
- parser = InputsParser({}, strictness)
- self.assertEqual({},
- parser.get_first_strings_starting('foo'))
- parser = InputsParser({'foo': ['bar']}, strictness)
- self.assertEqual({'foo': 'bar'},
- parser.get_first_strings_starting(''))
- parser = InputsParser({'x': ['y']}, strictness)
- self.assertEqual({'x': 'y'},
- parser.get_first_strings_starting('x'))
- parser = InputsParser({'xx': ['y']}, strictness)
- self.assertEqual({'xx': 'y'},
- parser.get_first_strings_starting('x'))
- parser = InputsParser({'xx': ['y']}, strictness)
- self.assertEqual({},
- parser.get_first_strings_starting('xxx'))
- d = {'xxx': ['x'], 'xxy': ['y'], 'xyy': ['z']}
- parser = InputsParser(d, strictness)
- self.assertEqual({'xxx': 'x', 'xxy': 'y'},
- parser.get_first_strings_starting('xx'))
- d = {'xxx': ['x', 'y', 'z'], 'xxy': ['y', 'z']}
- parser = InputsParser(d, strictness)
- self.assertEqual({'xxx': 'x', 'xxy': 'y'},
- parser.get_first_strings_starting('xx'))
+ def test_InputsParser_get_str(self) -> None:
+ """Test InputsParser.get_str."""
+ parser = InputsParser({})
+ self.assertEqual(None, parser.get_str('foo'))
+ self.assertEqual('bar', parser.get_str('foo', 'bar'))
+ parser = InputsParser({'foo': []})
+ self.assertEqual(None, parser.get_str('foo'))
+ self.assertEqual('bar', parser.get_str('foo', 'bar'))
+ parser = InputsParser({'foo': ['baz']})
+ self.assertEqual('baz', parser.get_str('foo', 'bar'))
+ parser = InputsParser({'foo': ['baz', 'quux']})
+ self.assertEqual('baz', parser.get_str('foo', 'bar'))
- def test_InputsParser_get_int(self) -> None:
- """Test InputsParser.get_int on strict and non-strict."""
- for strictness in (False, True):
- with self.assertRaises(BadFormatException):
- InputsParser({}, strictness).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}, strictness).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['']}, strictness).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['bar']}, strictness).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['0.1']}).get_int('foo')
- parser = InputsParser({'foo': ['0']}, strictness)
- self.assertEqual(0, parser.get_int('foo'))
- parser = InputsParser({'foo': ['17', '23']}, strictness)
- self.assertEqual(17, parser.get_int('foo'))
+ def test_InputsParser_get_all_of_key_prefixed(self) -> None:
+ """Test InputsParser.get_all_of_key_prefixed."""
+ parser = InputsParser({})
+ self.assertEqual({},
+ parser.get_all_of_key_prefixed(''))
+ self.assertEqual({},
+ parser.get_all_of_key_prefixed('foo'))
+ parser = InputsParser({'foo': ['bar']})
+ self.assertEqual({'foo': ['bar']},
+ parser.get_all_of_key_prefixed(''))
+ parser = InputsParser({'x': ['y', 'z']})
+ self.assertEqual({'': ['y', 'z']},
+ parser.get_all_of_key_prefixed('x'))
+ parser = InputsParser({'xx': ['y', 'Z']})
+ self.assertEqual({'x': ['y', 'Z']},
+ parser.get_all_of_key_prefixed('x'))
+ parser = InputsParser({'xx': ['y']})
+ self.assertEqual({},
+ parser.get_all_of_key_prefixed('xxx'))
+ parser = InputsParser({'xxx': ['x'], 'xxy': ['y'], 'xyy': ['z']})
+ self.assertEqual({'x': ['x'], 'y': ['y']},
+ parser.get_all_of_key_prefixed('xx'))
+ parser = InputsParser({'xxx': ['x', 'y'], 'xxy': ['y', 'z']})
+ self.assertEqual({'x': ['x', 'y'], 'y': ['y', 'z']},
+ parser.get_all_of_key_prefixed('xx'))
def test_InputsParser_get_int_or_none(self) -> None:
- """Test InputsParser.get_int_or_none on strict and non-strict."""
- for strictness in (False, True):
- parser = InputsParser({}, strictness)
- self.assertEqual(None, parser.get_int_or_none('foo'))
- parser = InputsParser({'foo': []}, strictness)
- self.assertEqual(None, parser.get_int_or_none('foo'))
- parser = InputsParser({'foo': ['']}, strictness)
- self.assertEqual(None, parser.get_int_or_none('foo'))
- parser = InputsParser({'foo': ['0']}, strictness)
- self.assertEqual(0, parser.get_int_or_none('foo'))
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['None']},
- strictness).get_int_or_none('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['0.1']},
- strictness).get_int_or_none('foo')
- parser = InputsParser({'foo': ['23']}, strictness)
- self.assertEqual(23, parser.get_int_or_none('foo'))
+ """Test InputsParser.get_int_or_none."""
+ parser = InputsParser({})
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': []})
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': ['']})
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': ['0']})
+ self.assertEqual(0, parser.get_int_or_none('foo'))
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['None']}).get_int_or_none('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['0.1']}).get_int_or_none('foo')
+ parser = InputsParser({'foo': ['23']})
+ self.assertEqual(23, parser.get_int_or_none('foo'))
- def test_InputsParser_get_float(self) -> None:
- """Test InputsParser.get_float on strict and non-strict."""
- for strictness in (False, True):
- with self.assertRaises(BadFormatException):
- InputsParser({}, strictness).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}, strictness).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['']}, strictness).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['bar']}, strictness).get_float('foo')
- parser = InputsParser({'foo': ['0']}, strictness)
- self.assertEqual(0, parser.get_float('foo'))
- parser = InputsParser({'foo': ['0.1']}, strictness)
- self.assertEqual(0.1, parser.get_float('foo'))
- parser = InputsParser({'foo': ['1.23', '456']}, strictness)
- self.assertEqual(1.23, parser.get_float('foo'))
+ def test_InputsParser_get_float_or_fail(self) -> None:
+ """Test InputsParser.get_float_or_fail."""
+ with self.assertRaises(BadFormatException):
+ InputsParser({}).get_float_or_fail('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['']}).get_float_or_fail('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['bar']}).get_float_or_fail('foo')
+ parser = InputsParser({'foo': ['0']})
+ self.assertEqual(0, parser.get_float_or_fail('foo'))
+ parser = InputsParser({'foo': ['0.1']})
+ self.assertEqual(0.1, parser.get_float_or_fail('foo'))
+ parser = InputsParser({'foo': ['1.23', '456']})
+ self.assertEqual(1.23, parser.get_float_or_fail('foo'))
+ with self.assertRaises(BadFormatException):
+ InputsParser({}).get_float_or_fail('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': []}).get_float_or_fail('foo')
+
+ def test_InputsParser_get_bool(self) -> None:
+ """Test InputsParser.get_bool."""
+ self.assertEqual(0, InputsParser({}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'val': ['foo']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'val': ['True']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': []}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['None']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['0']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['bar']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['bar',
+ 'baz']}).get_bool('foo'))
+ self.assertEqual(0, InputsParser({'foo': ['False']}).get_bool('foo'))
+ self.assertEqual(1, InputsParser({'foo': ['true']}).get_bool('foo'))
+ self.assertEqual(1, InputsParser({'foo': ['True']}).get_bool('foo'))
+ self.assertEqual(1, InputsParser({'foo': ['1']}).get_bool('foo'))
+ self.assertEqual(1, InputsParser({'foo': ['on']}).get_bool('foo'))
def test_InputsParser_get_all_str(self) -> None:
- """Test InputsParser.get_all_str on strict and non-strict."""
- for strictness in (False, True):
- parser = InputsParser({}, strictness)
- self.assertEqual([], parser.get_all_str('foo'))
- parser = InputsParser({'foo': []}, strictness)
- self.assertEqual([], parser.get_all_str('foo'))
- parser = InputsParser({'foo': ['bar']}, strictness)
- self.assertEqual(['bar'], parser.get_all_str('foo'))
- parser = InputsParser({'foo': ['bar', 'baz']}, strictness)
- self.assertEqual(['bar', 'baz'], parser.get_all_str('foo'))
+ """Test InputsParser.get_all_str."""
+ parser = InputsParser({})
+ self.assertEqual([], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': []})
+ self.assertEqual([], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': ['bar']})
+ self.assertEqual(['bar'], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': ['bar', 'baz']})
+ self.assertEqual(['bar', 'baz'], parser.get_all_str('foo'))
- def test_InputsParser_strict_get_all_int(self) -> None:
- """Test InputsParser.get_all_int on strict and non-strict."""
- for strictness in (False, True):
- parser = InputsParser({}, strictness)
- self.assertEqual([], parser.get_all_int('foo'))
- parser = InputsParser({'foo': []}, strictness)
- self.assertEqual([], parser.get_all_int('foo'))
- parser = InputsParser({'foo': ['']}, strictness)
- self.assertEqual([], parser.get_all_int('foo'))
- parser = InputsParser({'foo': ['0']}, strictness)
- self.assertEqual([0], parser.get_all_int('foo'))
- parser = InputsParser({'foo': ['0', '17']}, strictness)
- self.assertEqual([0, 17], parser.get_all_int('foo'))
- parser = InputsParser({'foo': ['0.1', '17']}, strictness)
- with self.assertRaises(BadFormatException):
- parser.get_all_int('foo')
- parser = InputsParser({'foo': ['None', '17']}, strictness)
- with self.assertRaises(BadFormatException):
- parser.get_all_int('foo')
+ def test_InputsParser_get_all_int(self) -> None:
+ """Test InputsParser.get_all_int."""
+ parser = InputsParser({})
+ self.assertEqual([], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': []})
+ self.assertEqual([], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['']})
+ parser.get_all_int('foo')
+ with self.assertRaises(BadFormatException):
+ parser.get_all_int('foo', fail_on_empty=True)
+ parser = InputsParser({'foo': ['0']})
+ self.assertEqual([0], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['0', '17']})
+ self.assertEqual([0, 17], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['0.1', '17']})
+ with self.assertRaises(BadFormatException):
+ parser.get_all_int('foo')
+ parser = InputsParser({'foo': ['None', '17']})
+ with self.assertRaises(BadFormatException):
+ parser.get_all_int('foo')
class TestsWithServer(TestCaseWithServer):
"""Test Processes module."""
from typing import Any
-from tests.utils import TestCaseWithDB, TestCaseWithServer, TestCaseSansDB
-from plomtask.processes import Process, ProcessStep, ProcessStepsNode
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+ Expected)
+from plomtask.processes import Process, ProcessStep
from plomtask.conditions import Condition
-from plomtask.exceptions import HandledException, NotFoundException
-from plomtask.todos import Todo
+from plomtask.exceptions import NotFoundException
class TestsSansDB(TestCaseSansDB):
"""Module tests requiring DB setup."""
checked_class = Process
- def three_processes(self) -> tuple[Process, Process, Process]:
- """Return three saved processes."""
- p1, p2, p3 = Process(None), Process(None), Process(None)
- for p in [p1, p2, p3]:
- p.save(self.db_conn)
- return p1, p2, p3
-
- def p_of_conditions(self) -> tuple[Process, list[Condition],
- list[Condition], list[Condition]]:
- """Return Process and its three Condition sets."""
- p = Process(None)
- c1, c2, c3 = Condition(None), Condition(None), Condition(None)
- for c in [c1, c2, c3]:
- c.save(self.db_conn)
- assert isinstance(c1.id_, int)
- assert isinstance(c2.id_, int)
- assert isinstance(c3.id_, int)
- set_1 = [c1, c2]
- set_2 = [c2, c3]
- set_3 = [c1, c3]
- p.set_conditions(self.db_conn, [c.id_ for c in set_1
- if isinstance(c.id_, int)])
- p.set_enables(self.db_conn, [c.id_ for c in set_2
- if isinstance(c.id_, int)])
- p.set_disables(self.db_conn, [c.id_ for c in set_3
- if isinstance(c.id_, int)])
- p.save(self.db_conn)
- return p, set_1, set_2, set_3
-
- def test_Process_conditions_saving(self) -> None:
- """Test .save/.save_core."""
- p, set1, set2, set3 = self.p_of_conditions()
- assert p.id_ is not None
- r = Process.by_id(self.db_conn, p.id_)
- self.assertEqual(sorted(r.conditions), sorted(set1))
- self.assertEqual(sorted(r.enables), sorted(set2))
- self.assertEqual(sorted(r.disables), sorted(set3))
-
def test_from_table_row(self) -> None:
"""Test .from_table_row() properly reads in class from DB."""
super().test_from_table_row()
assert isinstance(p2.id_, int)
assert isinstance(p3.id_, int)
steps_p1: list[ProcessStep] = []
- # add step of process p2 as first (top-level) step to p1
- s_p2_to_p1 = ProcessStep(None, p1.id_, p2.id_, None)
- steps_p1 += [s_p2_to_p1]
- p1.set_steps(self.db_conn, steps_p1)
- p1_dict: dict[int, ProcessStepsNode] = {}
- p1_dict[1] = ProcessStepsNode(p2, None, True, {})
- self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- # add step of process p3 as second (top-level) step to p1
- s_p3_to_p1 = ProcessStep(None, p1.id_, p3.id_, None)
- steps_p1 += [s_p3_to_p1]
- p1.set_steps(self.db_conn, steps_p1)
- p1_dict[2] = ProcessStepsNode(p3, None, True, {})
- self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- # add step of process p3 as first (top-level) step to p2,
- steps_p2: list[ProcessStep] = []
- s_p3_to_p2 = ProcessStep(None, p2.id_, p3.id_, None)
- steps_p2 += [s_p3_to_p2]
- p2.set_steps(self.db_conn, steps_p2)
- # expect it as implicit sub-step of p1's second (p3) step
- p2_dict = {3: ProcessStepsNode(p3, None, False, {})}
- p1_dict[1].steps[3] = p2_dict[3]
- self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- # add step of process p2 as explicit sub-step to p1's second sub-step
- s_p2_to_p1_first = ProcessStep(None, p1.id_, p2.id_, s_p3_to_p1.id_)
- steps_p1 += [s_p2_to_p1_first]
- p1.set_steps(self.db_conn, steps_p1)
- seen_3 = ProcessStepsNode(p3, None, False, {}, False)
- p1_dict[1].steps[3].seen = True
- p1_dict[2].steps[4] = ProcessStepsNode(p2, s_p3_to_p1.id_, True,
- {3: seen_3})
- self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # # add step of process p2 as first (top-level) step to p1
+ # s_p2_to_p1 = ProcessStep(None, p1.id_, p2.id_, None)
+ # steps_p1 += [s_p2_to_p1]
+ # p1.set_steps(self.db_conn, steps_p1)
+ # p1_dict: dict[int, ProcessStepsNode] = {}
+ # p1_dict[1] = ProcessStepsNode(p2, None, True, {})
+ # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # # add step of process p3 as second (top-level) step to p1
+ # s_p3_to_p1 = ProcessStep(None, p1.id_, p3.id_, None)
+ # steps_p1 += [s_p3_to_p1]
+ # p1.set_steps(self.db_conn, steps_p1)
+ # p1_dict[2] = ProcessStepsNode(p3, None, True, {})
+ # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # # add step of process p3 as first (top-level) step to p2,
+ # steps_p2: list[ProcessStep] = []
+ # s_p3_to_p2 = ProcessStep(None, p2.id_, p3.id_, None)
+ # steps_p2 += [s_p3_to_p2]
+ # p2.set_steps(self.db_conn, steps_p2)
+ # # expect it as implicit sub-step of p1's second (p3) step
+ # p2_dict = {3: ProcessStepsNode(p3, None, False, {})}
+ # p1_dict[1].steps[3] = p2_dict[3]
+ # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # # add step of process p2 as explicit sub-step to p1's second sub-step
+ # s_p2_to_p1_first = ProcessStep(None, p1.id_, p2.id_, s_p3_to_p1.id_)
+ # steps_p1 += [s_p2_to_p1_first]
+ # p1.set_steps(self.db_conn, steps_p1)
+ # seen_3 = ProcessStepsNode(p3, None, False, {}, False)
+ # p1_dict[1].steps[3].seen = True
+ # p1_dict[2].steps[4] = ProcessStepsNode(p2, s_p3_to_p1.id_, True,
+ # {3: seen_3})
+ # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+
# add step of process p3 as explicit sub-step to non-existing p1
# sub-step (of id=999), expect it to become another p1 top-level step
s_p3_to_p1_999 = ProcessStep(None, p1.id_, p3.id_, 999)
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
# add step of process p3 as explicit sub-step to p1's implicit p3
# sub-step, expect it to become another p1 top-level step
- s_p3_to_p1_impl_p3 = ProcessStep(None, p1.id_, p3.id_, s_p3_to_p2.id_)
+ s_p3_to_p1_impl_p3 = ProcessStep(None, p1.id_, p3.id_,
+ s_p3_to_p2.id_)
steps_p1 += [s_p3_to_p1_impl_p3]
p1.set_steps(self.db_conn, steps_p1)
p1_dict[6] = ProcessStepsNode(p3, None, True, {})
self.assertEqual(p1.used_as_step_by(self.db_conn), [])
self.assertEqual(p2.used_as_step_by(self.db_conn), [p1])
self.assertEqual(p3.used_as_step_by(self.db_conn), [p1, p2])
- # # add step of process p3 as explicit sub-step to p1's first sub-step,
- # # expect it to eliminate implicit p3 sub-step
- # s_p3_to_p1_first_explicit = ProcessStep(None, p1.id_, p3.id_,
- # s_p2_to_p1.id_)
- # p1_dict[1].steps = {7: ProcessStepsNode(p3, 1, True, {})}
- # p1_dict[2].steps[4].steps[3].seen = False
- # steps_p1 += [s_p3_to_p1_first_explicit]
- # p1.set_steps(self.db_conn, steps_p1)
- # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # add step of process p3 as explicit sub-step to p1's first
+ # sub-step, expect it to eliminate implicit p3 sub-step
+ s_p3_to_p1_first_explicit = ProcessStep(None, p1.id_, p3.id_,
+ s_p2_to_p1.id_)
+ p1_dict[1].steps = {7: ProcessStepsNode(p3, 1, True, {})}
+ p1_dict[2].steps[4].steps[3].seen = False
+ steps_p1 += [s_p3_to_p1_first_explicit]
+ p1.set_steps(self.db_conn, steps_p1)
+ self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
# ensure implicit steps non-top explicit steps are shown
s_p3_to_p2_first = ProcessStep(None, p2.id_, p3.id_, s_p3_to_p2.id_)
steps_p2 += [s_p3_to_p2_first]
p2.set_steps(self.db_conn, steps_p2)
- p1_dict[1].steps[3].steps[7] = ProcessStepsNode(p3, 3, False, {}, True)
- p1_dict[2].steps[4].steps[3].steps[7] = ProcessStepsNode(p3, 3, False,
- {}, False)
+ p1_dict[1].steps[3].steps[7] = ProcessStepsNode(p3, 3, False, {},
+ True)
+ p1_dict[2].steps[4].steps[3].steps[7] = ProcessStepsNode(
+ p3, 3, False, {}, False)
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
# ensure suppressed step nodes are hidden
assert isinstance(s_p3_to_p2.id_, int)
p1_dict[2].steps[4].steps[3].is_suppressed = True
self.assertEqual(p1.get_steps(self.db_conn), p1_dict)
- def test_Process_conditions(self) -> None:
- """Test setting Process.conditions/enables/disables."""
- p = Process(None)
- p.save(self.db_conn)
- for target in ('conditions', 'enables', 'disables'):
- method = getattr(p, f'set_{target}')
- c1, c2 = Condition(None), Condition(None)
- c1.save(self.db_conn)
- c2.save(self.db_conn)
- assert isinstance(c1.id_, int)
- assert isinstance(c2.id_, int)
- method(self.db_conn, [])
- self.assertEqual(getattr(p, target), [])
- method(self.db_conn, [c1.id_])
- self.assertEqual(getattr(p, target), [c1])
- method(self.db_conn, [c2.id_])
- self.assertEqual(getattr(p, target), [c2])
- method(self.db_conn, [c1.id_, c2.id_])
- self.assertEqual(getattr(p, target), [c1, c2])
-
def test_remove(self) -> None:
"""Test removal of Processes and ProcessSteps."""
super().test_remove()
- p1, p2, p3 = self.three_processes()
+ p1, p2, p3 = Process(None), Process(None), Process(None)
+ for p in [p1, p2, p3]:
+ p.save(self.db_conn)
assert isinstance(p1.id_, int)
assert isinstance(p2.id_, int)
assert isinstance(p3.id_, int)
step = ProcessStep(None, p2.id_, p1.id_, None)
p2.set_steps(self.db_conn, [step])
step_id = step.id_
- with self.assertRaises(HandledException):
- p1.remove(self.db_conn)
p2.set_steps(self.db_conn, [])
with self.assertRaises(NotFoundException):
+ # check unset ProcessSteps actually cannot be found anymore
assert step_id is not None
ProcessStep.by_id(self.db_conn, step_id)
p1.remove(self.db_conn)
step = ProcessStep(None, p2.id_, p3.id_, None)
p2.set_steps(self.db_conn, [step])
step_id = step.id_
+ # check _can_ remove Process pointed to by ProcessStep.owner_id, and …
p2.remove(self.db_conn)
with self.assertRaises(NotFoundException):
+ # … being dis-owned eliminates ProcessStep
assert step_id is not None
ProcessStep.by_id(self.db_conn, step_id)
- todo = Todo(None, p3, False, '2024-01-01')
- todo.save(self.db_conn)
- with self.assertRaises(HandledException):
- p3.remove(self.db_conn)
- todo.remove(self.db_conn)
- p3.remove(self.db_conn)
class TestsWithDBForProcessStep(TestCaseWithDB):
self.check_identity_with_cache_and_db([])
+class ExpectedGetProcess(Expected):
+ """Builder of expectations for GET /processes."""
+ _default_dict = {'is_new': False, 'preset_top_step': None, 'n_todos': 0}
+ _on_empty_make_temp = ('Process', 'proc_as_dict')
+
+ def __init__(self,
+ proc_id: int,
+ *args: Any, **kwargs: Any) -> None:
+ self._fields = {'process': proc_id, 'steps': []}
+ super().__init__(*args, **kwargs)
+
+ @staticmethod
+ def stepnode_as_dict(step_id: int,
+ proc_id: int,
+ seen: bool = False,
+ steps: None | list[dict[str, object]] = None,
+ is_explicit: bool = True,
+ is_suppressed: bool = False) -> dict[str, object]:
+ # pylint: disable=too-many-arguments
+ """Return JSON of ProcessStepNode to expect."""
+ return {'step': step_id,
+ 'process': proc_id,
+ 'seen': seen,
+ 'steps': steps if steps else [],
+ 'is_explicit': is_explicit,
+ 'is_suppressed': is_suppressed}
+
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
+ super().recalc()
+ self._fields['process_candidates'] = self.as_ids(
+ self.lib_all('Process'))
+ self._fields['condition_candidates'] = self.as_ids(
+ self.lib_all('Condition'))
+ self._fields['owners'] = [
+ s['owner_id'] for s in self.lib_all('ProcessStep')
+ if s['step_process_id'] == self._fields['process']]
+
+
+class ExpectedGetProcesses(Expected):
+ """Builder of expectations for GET /processes."""
+ _default_dict = {'sort_by': 'title', 'pattern': ''}
+
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
+ super().recalc()
+ self._fields['processes'] = self.as_ids(self.lib_all('Process'))
+
+
class TestsWithServer(TestCaseWithServer):
"""Module tests against our HTTP server/handler (and database)."""
+ checked_class = Process
- def test_do_POST_process(self) -> None:
+ def test_fail_POST_process(self) -> None:
"""Test POST /process and its effect on the database."""
- self.assertEqual(0, len(Process.all(self.db_conn)))
- form_data = self.post_process()
- self.assertEqual(1, len(Process.all(self.db_conn)))
- self.check_post(form_data, '/process?id=FOO', 400)
- self.check_post(form_data | {'effort': 'foo'}, '/process?id=', 400)
- self.check_post({}, '/process?id=', 400)
- self.check_post({'title': '', 'description': ''}, '/process?id=', 400)
- self.check_post({'title': '', 'effort': 1.1}, '/process?id=', 400)
- self.check_post({'description': '', 'effort': 1.0},
- '/process?id=', 400)
- self.assertEqual(1, len(Process.all(self.db_conn)))
- form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.0}
- self.post_process(2, form_data | {'conditions': []})
- self.check_post(form_data | {'conditions': [1]}, '/process?id=', 404)
- self.check_post({'title': 'foo', 'description': 'foo',
- 'is_active': False},
- '/condition', 302, '/condition?id=1')
- self.post_process(3, form_data | {'conditions': [1]})
- self.post_process(4, form_data | {'disables': [1]})
- self.post_process(5, form_data | {'enables': [1]})
- form_data['delete'] = ''
- self.check_post(form_data, '/process?id=', 404)
- self.check_post(form_data, '/process?id=6', 404)
- self.check_post(form_data, '/process?id=5', 302, '/processes')
+ valid_post = {'title': '', 'description': '', 'effort': 1.0}
+ # check payloads lacking minimum expecteds
+ self.check_minimal_inputs('/process', valid_post)
+ # check payloads of bad data types
+ self.check_post(valid_post | {'effort': ''}, '/process', 400)
+ # check references to non-existant items
+ self.check_post(valid_post | {'conditions': [1]}, '/process', 404)
+ self.check_post(valid_post | {'disables': [1]}, '/process', 404)
+ self.check_post(valid_post | {'blockers': [1]}, '/process', 404)
+ self.check_post(valid_post | {'enables': [1]}, '/process', 404)
+ self.check_post(valid_post | {'new_top_step': 2}, '/process', 404)
+ # check deletion of non-existant
+ self.check_post({'delete': ''}, '/process?id=1', 404)
+
+ def test_basic_POST_process(self) -> None:
+ """Test basic GET/POST /process operations."""
+ # check on un-saved
+ exp = ExpectedGetProcess(1)
+ exp.force('process_candidates', [])
+ exp.set('is_new', True)
+ self.check_json_get('/process?id=1', exp)
+ # check on minimal payload post
+ exp = ExpectedGetProcess(1)
+ self.post_exp_process([exp], {}, 1)
+ self.check_json_get('/process?id=1', exp)
+ # check boolean 'calendarize'
+ self.post_exp_process([exp], {'calendarize': True}, 1)
+ self.check_json_get('/process?id=1', exp)
+ self.post_exp_process([exp], {}, 1)
+ self.check_json_get('/process?id=1', exp)
+ # check conditions posting
+ for i in range(3):
+ self.post_exp_cond([exp], {}, i+1)
+ p = {'conditions': [1, 2], 'disables': [1],
+ 'blockers': [3], 'enables': [2, 3]}
+ self.post_exp_process([exp], p, 1)
+ self.check_json_get('/process?id=1', exp)
+ # check n_todos field
+ self.post_exp_day([], {'new_todo': ['1']}, '2024-01-01')
+ self.post_exp_day([], {'new_todo': ['1']}, '2024-01-02')
+ exp.set('n_todos', 2)
+ self.check_json_get('/process?id=1', exp)
+ # check cannot delete if Todos to Process
+ self.check_post({'delete': ''}, '/process?id=1', 500)
+ # check cannot delete if some ProcessStep's .step_process_id
+ self.post_exp_process([exp], {}, 2)
+ self.post_exp_process([exp], {'new_top_step': 2}, 3)
+ self.check_post({'delete': ''}, '/process?id=2', 500)
+ # check successful deletion
+ self.post_exp_process([exp], {}, 4)
+ self.check_post({'delete': ''}, '/process?id=4', 302, '/processes')
+ exp = ExpectedGetProcess(4)
+ exp.set('is_new', True)
+ for i in range(3):
+ self.post_exp_cond([exp], {}, i+1)
+ self.post_exp_process([exp], {}, i+1)
+ exp.force('process_candidates', [1, 2, 3])
+ self.check_json_get('/process?id=4', exp)
- def test_do_POST_process_steps(self) -> None:
+ def test_POST_process_steps(self) -> None:
"""Test behavior of ProcessStep posting."""
# pylint: disable=too-many-statements
- form_data_1 = self.post_process(1)
- self.post_process(2)
- self.post_process(3)
- # post first (top-level) step of process 2 to process 1
- form_data_1['new_top_step'] = [2]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 1)
- retrieved_step = retrieved_process.explicit_steps[0]
- retrieved_step_id = retrieved_step.id_
- self.assertEqual(retrieved_step.step_process_id, 2)
- self.assertEqual(retrieved_step.owner_id, 1)
- self.assertEqual(retrieved_step.parent_step_id, None)
- # post empty steps list to process, expect clean slate, and old step to
- # completely disappear
- form_data_1['new_top_step'] = []
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(retrieved_process.explicit_steps, [])
- assert retrieved_step_id is not None
- with self.assertRaises(NotFoundException):
- ProcessStep.by_id(self.db_conn, retrieved_step_id)
- # post new first (top_level) step of process 3 to process 1
- form_data_1['new_top_step'] = [3]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- retrieved_step = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step.step_process_id, 3)
- self.assertEqual(retrieved_step.owner_id, 1)
- self.assertEqual(retrieved_step.parent_step_id, None)
- # post to process steps list without keeps, expect clean slate
- form_data_1['new_top_step'] = []
- form_data_1['steps'] = [retrieved_step.id_]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(retrieved_process.explicit_steps, [])
- # post to process empty steps list but keep, expect 400
- form_data_1['steps'] = []
- form_data_1['keep_step'] = [retrieved_step_id]
- self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
- # post to process steps list with keep on non-created step, expect 400
- form_data_1['steps'] = [retrieved_step_id]
- form_data_1['keep_step'] = [retrieved_step_id]
- self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
- # post to process steps list with keep and process ID, expect 200
- form_data_1[f'step_{retrieved_step_id}_process_id'] = [2]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 1)
- retrieved_step = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step.step_process_id, 2)
- self.assertEqual(retrieved_step.owner_id, 1)
- self.assertEqual(retrieved_step.parent_step_id, None)
- # post nonsense, expect 400 and preservation of previous state
- form_data_1['steps'] = ['foo']
- form_data_1['keep_step'] = []
- self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 1)
- retrieved_step = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step.step_process_id, 2)
- self.assertEqual(retrieved_step.owner_id, 1)
- self.assertEqual(retrieved_step.parent_step_id, None)
- # post to process steps list with keep and process ID, expect 200
- form_data_1['new_top_step'] = [3]
- form_data_1['steps'] = [retrieved_step.id_]
- form_data_1['keep_step'] = [retrieved_step.id_]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 2)
- retrieved_step_0 = retrieved_process.explicit_steps[1]
- self.assertEqual(retrieved_step_0.step_process_id, 3)
- self.assertEqual(retrieved_step_0.owner_id, 1)
- self.assertEqual(retrieved_step_0.parent_step_id, None)
- retrieved_step_1 = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step_1.step_process_id, 2)
- self.assertEqual(retrieved_step_1.owner_id, 1)
- self.assertEqual(retrieved_step_1.parent_step_id, None)
- # post to process steps list with keeps etc., but trigger recursion
- form_data_1['new_top_step'] = []
- form_data_1['steps'] = [retrieved_step_0.id_, retrieved_step_1.id_]
- form_data_1['keep_step'] = [retrieved_step_0.id_, retrieved_step_1.id_]
- form_data_1[f'step_{retrieved_step_0.id_}_process_id'] = [2]
- form_data_1[f'step_{retrieved_step_1.id_}_process_id'] = [1]
- self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
- # check previous status preserved despite failed steps setting
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 2)
- retrieved_step_0 = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step_0.step_process_id, 2)
- self.assertEqual(retrieved_step_0.owner_id, 1)
- self.assertEqual(retrieved_step_0.parent_step_id, None)
- retrieved_step_1 = retrieved_process.explicit_steps[1]
- self.assertEqual(retrieved_step_1.step_process_id, 3)
- self.assertEqual(retrieved_step_1.owner_id, 1)
- self.assertEqual(retrieved_step_1.parent_step_id, None)
- # post sub-step to step
- form_data_1[f'step_{retrieved_step_0.id_}_process_id'] = [3]
- form_data_1[f'new_step_to_{retrieved_step_0.id_}'] = [3]
- self.post_process(1, form_data_1)
- retrieved_process = Process.by_id(self.db_conn, 1)
- self.assertEqual(len(retrieved_process.explicit_steps), 3)
- retrieved_step_0 = retrieved_process.explicit_steps[1]
- self.assertEqual(retrieved_step_0.step_process_id, 2)
- self.assertEqual(retrieved_step_0.owner_id, 1)
- self.assertEqual(retrieved_step_0.parent_step_id, None)
- retrieved_step_1 = retrieved_process.explicit_steps[0]
- self.assertEqual(retrieved_step_1.step_process_id, 3)
- self.assertEqual(retrieved_step_1.owner_id, 1)
- self.assertEqual(retrieved_step_1.parent_step_id, None)
- retrieved_step_2 = retrieved_process.explicit_steps[2]
- self.assertEqual(retrieved_step_2.step_process_id, 3)
- self.assertEqual(retrieved_step_2.owner_id, 1)
- self.assertEqual(retrieved_step_2.parent_step_id, retrieved_step_1.id_)
-
- def test_do_GET(self) -> None:
- """Test /process and /processes response codes."""
- self.check_get('/process', 200)
- self.check_get('/process?id=', 200)
- self.check_get('/process?id=1', 200)
- self.check_get_defaults('/process')
- self.check_get('/processes', 200)
+ url = '/process?id=1'
+ exp = ExpectedGetProcess(1)
+ self.post_exp_process([exp], {}, 1)
+ # post first (top-level) step of proc2 to proc1 by 'step_of' in 2
+ self.post_exp_process([exp], {'step_of': 1}, 2)
+ exp.lib_set('ProcessStep', [exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
+ exp.set('steps', [
+ exp.stepnode_as_dict(
+ step_id=1,
+ proc_id=2)])
+ self.check_json_get(url, exp)
+ # post empty/absent steps list to process, expect clean slate, and old
+ # step to completely disappear
+ self.post_exp_process([exp], {}, 1)
+ exp.lib_wipe('ProcessStep')
+ exp.set('steps', [])
+ self.check_json_get(url, exp)
+ # post anew (as only step yet) step of proc2 to proc1 by 'new_top_step'
+ self.post_exp_process([exp], {'new_top_step': 2}, 1)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(1, owner_id=1, step_process_id=2)])
+ self.post_exp_process([exp], {'kept_steps': [1]}, 1)
+ step_nodes = [exp.stepnode_as_dict(step_id=1, proc_id=2)]
+ exp.set('steps', step_nodes)
+ self.check_json_get(url, exp)
+ # fail on single--step recursion
+ p_min = {'title': '', 'description': '', 'effort': 0}
+ self.check_post(p_min | {'new_top_step': 1}, url, 400)
+ self.check_post(p_min | {'step_of': 1}, url, 400)
+ # post sibling steps
+ self.post_exp_process([exp], {}, 3)
+ self.post_exp_process([exp], {'kept_steps': [1], 'new_top_step': 3}, 1)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(2, owner_id=1, step_process_id=3)])
+ step_nodes += [exp.stepnode_as_dict(step_id=2, proc_id=3)]
+ self.check_json_get(url, exp)
+ # # post implicit sub-step via post to proc2
+ self.post_exp_process([exp], {}, 4)
+ self.post_exp_process([exp], {'step_of': [1], 'new_top_step': 4}, 2)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(3, owner_id=2, step_process_id=4)])
+ step_nodes[0]['steps'] = [
+ exp.stepnode_as_dict(step_id=3, proc_id=4, is_explicit=False)]
+ self.check_json_get(url, exp)
+ # post explicit sub-step via post to proc1
+ p = {'kept_steps': [1, 2], 'new_step_to_2': 4}
+ self.post_exp_process([exp], p, 1)
+ exp.lib_set('ProcessStep', [exp.procstep_as_dict(
+ 4, owner_id=1, step_process_id=4, parent_step_id=2)])
+ step_nodes[1]['steps'] = [
+ exp.stepnode_as_dict(step_id=4, proc_id=4)]
+ self.check_json_get(url, exp)
+ # fail on multi-step recursion via new step(s)
+ self.post_exp_process([exp], {}, 5)
+ self.post_exp_process([exp], {'new_top_step': 1}, 5)
+ exp.lib_set('ProcessStep', [exp.procstep_as_dict(
+ 5, owner_id=5, step_process_id=1)])
+ self.check_post(p_min | {'step_of': 5, 'new_top_step': 5}, url, 400)
+ self.post_exp_process([exp], {}, 6)
+ self.post_exp_process([exp], {'new_top_step': 5}, 6)
+ exp.lib_set('ProcessStep', [exp.procstep_as_dict(
+ 6, owner_id=6, step_process_id=5)])
+ self.check_post(p_min | {'step_of': 5, 'new_top_step': 6}, url, 400)
+ # fail on multi-step recursion via explicit sub-step
+ self.check_json_get(url, exp)
+ p = {'step_of': 5, 'kept_steps': [1, 2, 4], 'new_step_to_2': 6}
+ self.check_post(p_min | p, url, 400)
def test_fail_GET_process(self) -> None:
"""Test invalid GET /process params."""
# check for invalid IDs
- self.check_get('/process?id=foo', 400)
- self.check_get('/process?id=0', 500)
+ self.check_get_defaults('/process')
# check we catch invalid base64
self.check_get('/process?title_b64=foo', 400)
# check failure on references to unknown processes; we create Process
# of ID=1 here so we know the 404 comes from step_to=2 etc. (that tie
# the Process displayed by /process to others), not from not finding
# the main Process itself
- self.post_process(1)
+ self.post_exp_process([], {}, 1)
self.check_get('/process?id=1&step_to=2', 404)
self.check_get('/process?id=1&has_step=2', 404)
- @classmethod
- def GET_processes_dict(cls, procs: list[dict[str, object]]
- ) -> dict[str, object]:
- """Return JSON of GET /processes to expect."""
- library = {'Process': cls.as_refs(procs)} if procs else {}
- d: dict[str, object] = {'processes': cls.as_id_list(procs),
- 'sort_by': 'title',
- 'pattern': '',
- '_library': library}
- return d
-
- @staticmethod
- def procstep_as_dict(id_: int,
- owner_id: int,
- step_process_id: int,
- parent_step_id: int | None = None
- ) -> dict[str, object]:
- """Return JSON of Process to expect."""
- return {'id': id_,
- 'owner_id': owner_id,
- 'step_process_id': step_process_id,
- 'parent_step_id': parent_step_id}
-
def test_GET_processes(self) -> None:
"""Test GET /processes."""
# pylint: disable=too-many-statements
# test empty result on empty DB, default-settings on empty params
- expected = self.GET_processes_dict([])
- self.check_json_get('/processes', expected)
+ exp = ExpectedGetProcesses()
+ self.check_json_get('/processes', exp)
# test on meaningless non-empty params (incl. entirely un-used key),
# that 'sort_by' default to 'title' (even if set to something else, as
# long as without handler) and 'pattern' get preserved
- expected['pattern'] = 'bar' # preserved despite zero effect!
+ exp.set('pattern', 'bar')
url = '/processes?sort_by=foo&pattern=bar&foo=x'
- self.check_json_get(url, expected)
+ self.check_json_get(url, exp)
# test non-empty result, automatic (positive) sorting by title
- post1: dict[str, Any]
- post2: dict[str, Any]
- post3: dict[str, Any]
- post1 = {'title': 'foo', 'description': 'oof', 'effort': 1.0}
- post2 = {'title': 'bar', 'description': 'rab', 'effort': 1.1}
- post2['new_top_step'] = 1
- post3 = {'title': 'baz', 'description': 'zab', 'effort': 0.9}
- post3['new_top_step'] = 1
- self.post_process(1, post1)
- self.post_process(2, post2)
- self.post_process(3, post3)
- post3['new_top_step'] = 2
- post3['keep_step'] = 2
- post3['steps'] = [2]
- post3['step_2_process_id'] = 1
- self.post_process(3, post3)
- proc1 = self.proc_as_dict(1, post1['title'],
- post1['description'], post1['effort'])
- proc2 = self.proc_as_dict(2, post2['title'],
- post2['description'], post2['effort'])
- proc3 = self.proc_as_dict(3, post3['title'],
- post3['description'], post3['effort'])
- proc2['explicit_steps'] = [1]
- proc3['explicit_steps'] = [2, 3]
- step1 = self.procstep_as_dict(1, 2, 1)
- step2 = self.procstep_as_dict(2, 3, 1)
- step3 = self.procstep_as_dict(3, 3, 2)
- expected = self.GET_processes_dict([proc2, proc3, proc1])
- assert isinstance(expected['_library'], dict)
- expected['_library']['ProcessStep'] = self.as_refs([step1, step2,
- step3])
- self.check_json_get('/processes', expected)
+ for i, t in enumerate([('foo', 'oof', 1.0, []),
+ ('bar', 'rab', 1.1, [1]),
+ ('baz', 'zab', 0.9, [1, 2])]):
+ payload = {'title': t[0], 'description': t[1], 'effort': t[2],
+ 'new_top_step': t[3]}
+ self.post_exp_process([exp], payload, i+1)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(1, owner_id=2, step_process_id=1),
+ exp.procstep_as_dict(2, owner_id=3, step_process_id=1),
+ exp.procstep_as_dict(3, owner_id=3, step_process_id=2)])
+ exp.set('pattern', '')
+ self.check_filter(exp, 'processes', 'sort_by', 'title', [2, 3, 1])
# test other sortings
- expected['sort_by'] = '-title'
- expected['processes'] = self.as_id_list([proc1, proc3, proc2])
- self.check_json_get('/processes?sort_by=-title', expected)
- expected['sort_by'] = 'effort'
- expected['processes'] = self.as_id_list([proc3, proc1, proc2])
- self.check_json_get('/processes?sort_by=effort', expected)
- expected['sort_by'] = '-effort'
- expected['processes'] = self.as_id_list([proc2, proc1, proc3])
- self.check_json_get('/processes?sort_by=-effort', expected)
- expected['sort_by'] = 'steps'
- expected['processes'] = self.as_id_list([proc1, proc2, proc3])
- self.check_json_get('/processes?sort_by=steps', expected)
- expected['sort_by'] = '-steps'
- expected['processes'] = self.as_id_list([proc3, proc2, proc1])
- self.check_json_get('/processes?sort_by=-steps', expected)
- expected['sort_by'] = 'owners'
- expected['processes'] = self.as_id_list([proc3, proc2, proc1])
- self.check_json_get('/processes?sort_by=owners', expected)
- expected['sort_by'] = '-owners'
- expected['processes'] = self.as_id_list([proc1, proc2, proc3])
- self.check_json_get('/processes?sort_by=-owners', expected)
+ self.check_filter(exp, 'processes', 'sort_by', '-title', [1, 3, 2])
+ self.check_filter(exp, 'processes', 'sort_by', 'effort', [3, 1, 2])
+ self.check_filter(exp, 'processes', 'sort_by', '-effort', [2, 1, 3])
+ self.check_filter(exp, 'processes', 'sort_by', 'steps', [1, 2, 3])
+ self.check_filter(exp, 'processes', 'sort_by', '-steps', [3, 2, 1])
+ self.check_filter(exp, 'processes', 'sort_by', 'owners', [3, 2, 1])
+ self.check_filter(exp, 'processes', 'sort_by', '-owners', [1, 2, 3])
# test pattern matching on title
- expected = self.GET_processes_dict([proc2, proc3])
- assert isinstance(expected['_library'], dict)
- expected['pattern'] = 'ba'
- expected['_library']['ProcessStep'] = self.as_refs([step1, step2,
- step3])
- self.check_json_get('/processes?pattern=ba', expected)
+ exp.set('sort_by', 'title')
+ exp.lib_del('Process', '1')
+ self.check_filter(exp, 'processes', 'pattern', 'ba', [2, 3])
# test pattern matching on description
- expected['processes'] = self.as_id_list([proc1])
- expected['_library'] = {'Process': self.as_refs([proc1])}
- expected['pattern'] = 'of'
- self.check_json_get('/processes?pattern=of', expected)
+ exp.lib_wipe('Process')
+ exp.lib_wipe('ProcessStep')
+ self.post_exp_process([exp], {'description': 'oof', 'effort': 1.0}, 1)
+ self.check_filter(exp, 'processes', 'pattern', 'of', [1])
"""Test Todos module."""
-from tests.utils import TestCaseSansDB, TestCaseWithDB, TestCaseWithServer
-from plomtask.todos import Todo, TodoNode
-from plomtask.processes import Process, ProcessStep
-from plomtask.conditions import Condition
-from plomtask.exceptions import (NotFoundException, BadFormatException,
- HandledException)
+from typing import Any
+from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer,
+ Expected)
+from plomtask.todos import Todo
+from plomtask.processes import Process
+from plomtask.exceptions import BadFormatException, HandledException
class TestsWithDB(TestCaseWithDB, TestCaseSansDB):
def setUp(self) -> None:
super().setUp()
- self.date1 = '2024-01-01'
- self.date2 = '2024-01-02'
self.proc = Process(None)
self.proc.save(self.db_conn)
- self.cond1 = Condition(None)
- self.cond1.save(self.db_conn)
- self.cond2 = Condition(None)
- self.cond2.save(self.db_conn)
self.default_init_kwargs['process'] = self.proc
- def test_Todo_init(self) -> None:
- """Test creation of Todo and what they default to."""
- process = Process(None)
- with self.assertRaises(NotFoundException):
- Todo(None, process, False, self.date1)
- process.save(self.db_conn)
- assert isinstance(self.cond1.id_, int)
- assert isinstance(self.cond2.id_, int)
- process.set_conditions(self.db_conn, [self.cond1.id_, self.cond2.id_])
- process.set_enables(self.db_conn, [self.cond1.id_])
- process.set_disables(self.db_conn, [self.cond2.id_])
- todo_no_id = Todo(None, process, False, self.date1)
- self.assertEqual(todo_no_id.conditions, [self.cond1, self.cond2])
- self.assertEqual(todo_no_id.enables, [self.cond1])
- self.assertEqual(todo_no_id.disables, [self.cond2])
- todo_yes_id = Todo(5, process, False, self.date1)
- self.assertEqual(todo_yes_id.conditions, [])
- self.assertEqual(todo_yes_id.enables, [])
- self.assertEqual(todo_yes_id.disables, [])
-
def test_Todo_by_date(self) -> None:
"""Test findability of Todos by date."""
- t1 = Todo(None, self.proc, False, self.date1)
+ date1, date2 = '2024-01-01', '2024-01-02'
+ t1 = Todo(None, self.proc, False, date1)
t1.save(self.db_conn)
- t2 = Todo(None, self.proc, False, self.date1)
+ t2 = Todo(None, self.proc, False, date1)
t2.save(self.db_conn)
- self.assertEqual(Todo.by_date(self.db_conn, self.date1), [t1, t2])
- self.assertEqual(Todo.by_date(self.db_conn, self.date2), [])
+ self.assertEqual(Todo.by_date(self.db_conn, date1), [t1, t2])
+ self.assertEqual(Todo.by_date(self.db_conn, date2), [])
with self.assertRaises(BadFormatException):
self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
"""Test .by_date_range_with_limits."""
self.check_by_date_range_with_limits('day')
- def test_Todo_on_conditions(self) -> None:
- """Test effect of Todos on Conditions."""
- assert isinstance(self.cond1.id_, int)
- assert isinstance(self.cond2.id_, int)
- todo = Todo(None, self.proc, False, self.date1)
- todo.save(self.db_conn)
- todo.set_enables(self.db_conn, [self.cond1.id_])
- todo.set_disables(self.db_conn, [self.cond2.id_])
- todo.is_done = True
- self.assertEqual(self.cond1.is_active, True)
- self.assertEqual(self.cond2.is_active, False)
- todo.is_done = False
- self.assertEqual(self.cond1.is_active, True)
- self.assertEqual(self.cond2.is_active, False)
-
def test_Todo_children(self) -> None:
"""Test Todo.children relations."""
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_2 = Todo(None, self.proc, False, self.date1)
+ date1 = '2024-01-01'
+ todo_1 = Todo(None, self.proc, False, date1)
+ todo_2 = Todo(None, self.proc, False, date1)
todo_2.save(self.db_conn)
+ # check un-saved Todo cannot parent
with self.assertRaises(HandledException):
todo_1.add_child(todo_2)
todo_1.save(self.db_conn)
- todo_3 = Todo(None, self.proc, False, self.date1)
+ todo_3 = Todo(None, self.proc, False, date1)
+ # check un-saved Todo cannot be parented
with self.assertRaises(HandledException):
todo_1.add_child(todo_3)
- todo_3.save(self.db_conn)
- todo_1.add_child(todo_3)
- todo_1.save(self.db_conn)
- assert isinstance(todo_1.id_, int)
- todo_retrieved = Todo.by_id(self.db_conn, todo_1.id_)
- self.assertEqual(todo_retrieved.children, [todo_3])
- with self.assertRaises(BadFormatException):
- todo_3.add_child(todo_1)
- def test_Todo_conditioning(self) -> None:
- """Test Todo.doability conditions."""
- assert isinstance(self.cond1.id_, int)
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_1.save(self.db_conn)
- todo_2 = Todo(None, self.proc, False, self.date1)
- todo_2.save(self.db_conn)
- todo_2.add_child(todo_1)
- with self.assertRaises(BadFormatException):
- todo_2.is_done = True
- todo_1.is_done = True
- todo_2.is_done = True
- todo_2.is_done = False
- todo_2.set_conditions(self.db_conn, [self.cond1.id_])
- with self.assertRaises(BadFormatException):
- todo_2.is_done = True
- self.cond1.is_active = True
- todo_2.is_done = True
- def test_Todo_step_tree(self) -> None:
- """Test self-configuration of TodoStepsNode tree for Day view."""
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_1.save(self.db_conn)
- assert isinstance(todo_1.id_, int)
- # test minimum
- node_0 = TodoNode(todo_1, False, [])
- self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
- # test non_emtpy seen_todo does something
- node_0.seen = True
- self.assertEqual(todo_1.get_step_tree({todo_1.id_}).as_dict,
- node_0.as_dict)
- # test child shows up
- todo_2 = Todo(None, self.proc, False, self.date1)
- todo_2.save(self.db_conn)
- assert isinstance(todo_2.id_, int)
- todo_1.add_child(todo_2)
- node_2 = TodoNode(todo_2, False, [])
- node_0.children = [node_2]
- node_0.seen = False
- self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
- # test child shows up with child
- todo_3 = Todo(None, self.proc, False, self.date1)
- todo_3.save(self.db_conn)
- assert isinstance(todo_3.id_, int)
- todo_2.add_child(todo_3)
- node_3 = TodoNode(todo_3, False, [])
- node_2.children = [node_3]
- self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
- # test same todo can be child-ed multiple times at different locations
- todo_1.add_child(todo_3)
- node_4 = TodoNode(todo_3, True, [])
- node_0.children += [node_4]
- self.assertEqual(todo_1.get_step_tree(set()).as_dict, node_0.as_dict)
+class ExpectedGetTodo(Expected):
+ """Builder of expectations for GET /todo."""
- def test_Todo_create_with_children(self) -> None:
- """Test parenthood guaranteeds of Todo.create_with_children."""
- assert isinstance(self.proc.id_, int)
- proc2 = Process(None)
- proc2.save(self.db_conn)
- assert isinstance(proc2.id_, int)
- proc3 = Process(None)
- proc3.save(self.db_conn)
- assert isinstance(proc3.id_, int)
- proc4 = Process(None)
- proc4.save(self.db_conn)
- assert isinstance(proc4.id_, int)
- # make proc4 step of proc3
- step = ProcessStep(None, proc3.id_, proc4.id_, None)
- proc3.set_steps(self.db_conn, [step])
- # give proc2 three steps; 2× proc1, 1× proc3
- step1 = ProcessStep(None, proc2.id_, self.proc.id_, None)
- step2 = ProcessStep(None, proc2.id_, self.proc.id_, None)
- step3 = ProcessStep(None, proc2.id_, proc3.id_, None)
- proc2.set_steps(self.db_conn, [step1, step2, step3])
- # test mere creation does nothing
- todo_ignore = Todo(None, proc2, False, self.date1)
- todo_ignore.save(self.db_conn)
- self.assertEqual(todo_ignore.children, [])
- # test create_with_children on step-less does nothing
- todo_1 = Todo.create_with_children(self.db_conn, self.proc.id_,
- self.date1)
- self.assertEqual(todo_1.children, [])
- self.assertEqual(len(Todo.all(self.db_conn)), 2)
- # test create_with_children adopts and creates, and down tree too
- todo_2 = Todo.create_with_children(self.db_conn, proc2.id_, self.date1)
- self.assertEqual(3, len(todo_2.children))
- self.assertEqual(todo_1, todo_2.children[0])
- self.assertEqual(self.proc, todo_2.children[2].process)
- self.assertEqual(proc3, todo_2.children[1].process)
- todo_3 = todo_2.children[1]
- self.assertEqual(len(todo_3.children), 1)
- self.assertEqual(todo_3.children[0].process, proc4)
+ def __init__(self,
+ todo_id: int,
+ *args: Any, **kwargs: Any) -> None:
+ self._fields = {'todo': todo_id,
+ 'steps_todo_to_process': []}
+ super().__init__(*args, **kwargs)
- def test_Todo_remove(self) -> None:
- """Test removal."""
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_1.save(self.db_conn)
- assert todo_1.id_ is not None
- todo_0 = Todo(None, self.proc, False, self.date1)
- todo_0.save(self.db_conn)
- todo_0.add_child(todo_1)
- todo_2 = Todo(None, self.proc, False, self.date1)
- todo_2.save(self.db_conn)
- todo_1.add_child(todo_2)
- todo_1_id = todo_1.id_
- todo_1.remove(self.db_conn)
- with self.assertRaises(NotFoundException):
- Todo.by_id(self.db_conn, todo_1_id)
- self.assertEqual(todo_0.children, [])
- self.assertEqual(todo_2.parents, [])
- todo_2.comment = 'foo'
- with self.assertRaises(HandledException):
- todo_2.remove(self.db_conn)
- todo_2.comment = ''
- todo_2.effort = 5
- with self.assertRaises(HandledException):
- todo_2.remove(self.db_conn)
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
- def test_Todo_autoremoval(self) -> None:
- """"Test automatic removal for Todo.effort < 0."""
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_1.save(self.db_conn)
- todo_1.comment = 'foo'
- todo_1.effort = -0.1
- todo_1.save(self.db_conn)
- assert todo_1.id_ is not None
- Todo.by_id(self.db_conn, todo_1.id_)
- todo_1.comment = ''
- todo_1_id = todo_1.id_
- todo_1.save(self.db_conn)
- with self.assertRaises(NotFoundException):
- Todo.by_id(self.db_conn, todo_1_id)
+ def walk_steps(step: dict[str, Any]) -> None:
+ if not step['todo']:
+ proc_id = step['process']
+ cands = self.as_ids(
+ [t for t in todos if proc_id == t['process_id']
+ and t['id'] in self._fields['todo_candidates']])
+ self._fields['adoption_candidates_for'][str(proc_id)] = cands
+ for child in step['children']:
+ walk_steps(child)
+
+ super().recalc()
+ self.lib_wipe('Day')
+ todos = self.lib_all('Todo')
+ procs = self.lib_all('Process')
+ conds = self.lib_all('Condition')
+ self._fields['todo_candidates'] = self.as_ids(
+ [t for t in todos if t['id'] != self._fields['todo']])
+ self._fields['process_candidates'] = self.as_ids(procs)
+ self._fields['condition_candidates'] = self.as_ids(conds)
+ self._fields['adoption_candidates_for'] = {}
+ for step in self._fields['steps_todo_to_process']:
+ walk_steps(step)
+
+ @staticmethod
+ def step_as_dict(node_id: int,
+ process: int | None = None,
+ todo: int | None = None,
+ fillable: bool = False,
+ children: None | list[dict[str, object]] = None
+ ) -> dict[str, object]:
+ """Return JSON of TodoOrProcStepsNode to expect."""
+ return {'node_id': node_id,
+ 'children': children if children is not None else [],
+ 'process': process,
+ 'fillable': fillable,
+ 'todo': todo}
class TestsWithServer(TestCaseWithServer):
"""Tests against our HTTP server/handler (and database)."""
+ checked_class = Todo
- def test_do_POST_day(self) -> None:
- """Test Todo posting of POST /day."""
- self.post_process()
- self.post_process(2)
- proc = Process.by_id(self.db_conn, 1)
- proc2 = Process.by_id(self.db_conn, 2)
- form_data = {'day_comment': '', 'make_type': 'full'}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
- proc = Process.by_id(self.db_conn, 1)
- form_data['new_todo'] = str(proc.id_)
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todos = Todo.by_date(self.db_conn, '2024-01-01')
- self.assertEqual(1, len(todos))
- todo1 = todos[0]
- self.assertEqual(todo1.id_, 1)
- proc = Process.by_id(self.db_conn, 1)
- self.assertEqual(todo1.process.id_, proc.id_)
- self.assertEqual(todo1.is_done, False)
- proc2 = Process.by_id(self.db_conn, 2)
- form_data['new_todo'] = str(proc2.id_)
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todos = Todo.by_date(self.db_conn, '2024-01-01')
- todo1 = todos[1]
- self.assertEqual(todo1.id_, 2)
- proc2 = Process.by_id(self.db_conn, 1)
- todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
- self.assertEqual(todo1.id_, 1)
- self.assertEqual(todo1.process.id_, proc2.id_)
- self.assertEqual(todo1.is_done, False)
-
- def test_do_POST_todo(self) -> None:
- """Test POST /todo."""
- def post_and_reload(form_data: dict[str, object], status: int = 302,
- redir_url: str = '/todo?id=1') -> Todo:
- self.check_post(form_data, '/todo?id=1', status, redir_url)
- return Todo.by_date(self.db_conn, '2024-01-01')[0]
- # test minimum
- self.post_process()
- self.check_post({'day_comment': '', 'new_todo': 1,
- 'make_type': 'full'},
- '/day?date=2024-01-01&make_type=full', 302)
- # test posting to bad URLs
- self.check_post({}, '/todo=', 404)
- self.check_post({}, '/todo?id=', 404)
+ def test_basic_fail_POST_todo(self) -> None:
+ """Test basic malformed/illegal POST /todo requests."""
+ self.post_exp_process([], {}, 1)
+ # test we cannot just POST into non-existing Todo
+ self.check_post({}, '/todo', 404)
self.check_post({}, '/todo?id=FOO', 400)
- self.check_post({}, '/todo?id=0', 404)
- # test posting naked entity
- todo1 = post_and_reload({})
- self.assertEqual(todo1.children, [])
- self.assertEqual(todo1.parents, [])
- self.assertEqual(todo1.is_done, False)
- # test posting doneness
- todo1 = post_and_reload({'done': ''})
- self.assertEqual(todo1.is_done, True)
- # test implicitly posting non-doneness
- todo1 = post_and_reload({})
- self.assertEqual(todo1.is_done, False)
- # test malformed adoptions
- self.check_post({'adopt': 'foo'}, '/todo?id=1', 400)
- self.check_post({'adopt': 1}, '/todo?id=1', 400)
- self.check_post({'adopt': 2}, '/todo?id=1', 404)
- # test posting second todo of same process
- self.check_post({'day_comment': '', 'new_todo': 1,
- 'make_type': 'full'},
- '/day?date=2024-01-01&make_type=full', 302)
- # test todo 1 adopting todo 2
- todo1 = post_and_reload({'adopt': 2})
- todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
- self.assertEqual(todo1.children, [todo2])
- self.assertEqual(todo1.parents, [])
- self.assertEqual(todo2.children, [])
- self.assertEqual(todo2.parents, [todo1])
- # test todo1 cannot be set done with todo2 not done yet
- todo1 = post_and_reload({'done': '', 'adopt': 2}, 400)
- self.assertEqual(todo1.is_done, False)
- # test todo1 un-adopting todo 2 by just not sending an adopt
- todo1 = post_and_reload({}, 302)
- todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
- self.assertEqual(todo1.children, [])
- self.assertEqual(todo1.parents, [])
- self.assertEqual(todo2.children, [])
- self.assertEqual(todo2.parents, [])
- # test todo1 deletion
- todo1 = post_and_reload({'delete': ''}, 302, '/')
-
- def test_do_POST_day_todo_adoption(self) -> None:
- """Test Todos posted to Day view may adopt existing Todos."""
- form_data = self.post_process()
- form_data = self.post_process(2, form_data | {'new_top_step': 1})
- form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- form_data['new_todo'] = 2
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
- todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
- self.assertEqual(todo1.children, [])
- self.assertEqual(todo1.parents, [todo2])
- self.assertEqual(todo2.children, [todo1])
- self.assertEqual(todo2.parents, [])
-
- def test_do_POST_day_todo_multiple(self) -> None:
- """Test multiple Todos can be posted to Day view."""
- form_data = self.post_process()
- form_data = self.post_process(2)
- form_data = {'day_comment': '', 'new_todo': [1, 2],
- 'make_type': 'full'}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
- todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
- self.assertEqual(todo1.process.id_, 1)
- self.assertEqual(todo2.process.id_, 2)
-
- def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
- """Test multiple Todos can be posted to Day view w. inner adoption."""
+ self.check_post({}, '/todo?id=0', 400)
+ self.check_post({}, '/todo?id=1', 404)
+ # test malformed values on existing Todo
+ self.post_exp_day([], {'new_todo': [1]})
+ for name in ['adopt', 'effort', 'make_full', 'make_empty',
+ 'conditions', 'disables', 'blockers', 'enables']:
+ self.check_post({name: 'x'}, '/todo?id=1', 400, '/todo')
+ for prefix in ['make_', '']:
+ for suffix in ['', 'x', '1.1']:
+ self.check_post({'step_filler_to_1': [f'{prefix}{suffix}']},
+ '/todo?id=1', 400, '/todo')
+ for suffix in ['', 'x', '1.1']:
+ self.check_post({'step_filler_to_{suffix}': ['1']},
+ '/todo?id=1', 400, '/todo')
- def key_order_func(t: Todo) -> int:
- assert isinstance(t.process.id_, int)
- return t.process.id_
+ def test_basic_POST_todo(self) -> None:
+ """Test basic POST /todo manipulations."""
+ exp = ExpectedGetTodo(1)
+ self.post_exp_process([exp], {'calendarize': 0}, 1)
+ self.post_exp_day([exp], {'new_todo': [1]})
+ # test posting naked entity at first changes nothing
+ self.check_json_get('/todo?id=1', exp)
+ self.check_post({}, '/todo?id=1')
+ self.check_json_get('/todo?id=1', exp)
+ # test posting doneness, comment, calendarization, effort
+ todo_post = {'is_done': 1, 'calendarize': 1,
+ 'comment': 'foo', 'effort': 2.3}
+ self.post_exp_todo([exp], todo_post, 1)
+ self.check_json_get('/todo?id=1', exp)
+ # test implicitly un-setting comment/calendarize/is_done by empty post
+ self.post_exp_todo([exp], {}, 1)
+ self.check_json_get('/todo?id=1', exp)
+ # test effort post can be explicitly unset by "effort":"" post
+ self.check_post({'effort': ''}, '/todo?id=1')
+ exp.lib_get('Todo', 1)['effort'] = None
+ self.check_json_get('/todo?id=1', exp)
+ # test Condition posts
+ c1_post = {'title': 'foo', 'description': 'oof', 'is_active': 0}
+ c2_post = {'title': 'bar', 'description': 'rab', 'is_active': 1}
+ self.post_exp_cond([exp], c1_post, 1)
+ self.post_exp_cond([exp], c2_post, 2)
+ self.check_json_get('/todo?id=1', exp)
+ todo_post = {'conditions': [1], 'disables': [1],
+ 'blockers': [2], 'enables': [2]}
+ self.post_exp_todo([exp], todo_post, 1)
+ self.check_json_get('/todo?id=1', exp)
- def check_adoption(date: str, new_todos: list[int]) -> None:
- form_data = {'day_comment': '', 'new_todo': new_todos,
- 'make_type': 'full'}
- self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
- day_todos = Todo.by_date(self.db_conn, date)
- day_todos.sort(key=key_order_func)
- todo1 = day_todos[0]
- todo2 = day_todos[1]
- self.assertEqual(todo1.children, [])
- self.assertEqual(todo1.parents, [todo2])
- self.assertEqual(todo2.children, [todo1])
- self.assertEqual(todo2.parents, [])
+ def test_POST_todo_deletion(self) -> None:
+ """Test deletions via POST /todo."""
+ exp = ExpectedGetTodo(1)
+ self.post_exp_process([exp], {}, 1)
+ # test failure of deletion on non-existing Todo
+ self.check_post({'delete': ''}, '/todo?id=2', 404, '/')
+ # test deletion of existing Todo
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+ self.check_get('/todo?id=1', 404)
+ exp.lib_del('Todo', 1)
+ # test deletion of adopted Todo
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.check_post({'adopt': 2}, '/todo?id=1')
+ self.check_post({'delete': ''}, '/todo?id=2', 302, '/')
+ exp.lib_del('Todo', 2)
+ self.check_get('/todo?id=2', 404)
+ self.check_json_get('/todo?id=1', exp)
+ # test deletion of adopting Todo
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.check_post({'adopt': 2}, '/todo?id=1')
+ self.check_post({'delete': ''}, '/todo?id=1', 302, '/')
+ exp.set('todo', 2)
+ exp.lib_del('Todo', 1)
+ self.check_json_get('/todo?id=2', exp)
+ # test cannot delete Todo with comment or effort
+ self.check_post({'comment': 'foo'}, '/todo?id=2')
+ self.check_post({'delete': ''}, '/todo?id=2', 500, '/')
+ self.check_post({'effort': 5}, '/todo?id=2')
+ self.check_post({'delete': ''}, '/todo?id=2', 500, '/')
+ # test deletion via effort < 0, but only if deletable
+ self.check_post({'effort': -1, 'comment': 'foo'}, '/todo?id=2')
+ self.check_post({}, '/todo?id=2')
+ self.check_get('/todo?id=2', 404)
- def check_nesting_adoption(process_id: int, date: str,
- new_top_steps: list[int]) -> None:
- form_data = {'title': '', 'description': '', 'effort': 1,
- 'step_of': [2]}
- form_data = self.post_process(1, form_data)
- form_data['new_top_step'] = new_top_steps
- form_data['step_of'] = []
- form_data = self.post_process(process_id, form_data)
- form_data = {'day_comment': '', 'new_todo': [process_id],
- 'make_type': 'full'}
- self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
- day_todos = Todo.by_date(self.db_conn, date)
- day_todos.sort(key=key_order_func, reverse=True)
- self.assertEqual(len(day_todos), 3)
- todo1 = day_todos[0] # process of process_id
- todo2 = day_todos[1] # process 2
- todo3 = day_todos[2] # process 1
- self.assertEqual(sorted(todo1.children), sorted([todo2, todo3]))
- self.assertEqual(todo1.parents, [])
- self.assertEqual(todo2.children, [todo3])
- self.assertEqual(todo2.parents, [todo1])
- self.assertEqual(todo3.children, [])
- self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
-
- form_data = self.post_process()
- form_data = self.post_process(2, form_data | {'new_top_step': 1})
- check_adoption('2024-01-01', [1, 2])
- check_adoption('2024-01-02', [2, 1])
- check_nesting_adoption(3, '2024-01-03', [1, 2])
- check_nesting_adoption(4, '2024-01-04', [2, 1])
+ def test_POST_todo_adoption(self) -> None:
+ """Test adoption via POST /todo with "adopt"."""
+ # post two Todos to Day, have first adopt second
+ exp = ExpectedGetTodo(1)
+ self.post_exp_process([exp], {}, 1)
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.post_exp_todo([exp], {'adopt': 2}, 1)
+ exp.set('steps_todo_to_process', [
+ exp.step_as_dict(node_id=1, process=None, todo=2)])
+ self.check_json_get('/todo?id=1', exp)
+ # test Todo un-adopting by just not sending an adopt
+ self.post_exp_todo([exp], {}, 1)
+ exp.set('steps_todo_to_process', [])
+ self.check_json_get('/todo?id=1', exp)
+ # test fail on trying to adopt non-existing Todo
+ self.check_post({'adopt': 3}, '/todo?id=1', 404)
+ # test cannot self-adopt
+ self.check_post({'adopt': 1}, '/todo?id=1', 400)
+ # test cannot do 1-step circular adoption
+ self.post_exp_todo([exp], {'adopt': 1}, 2)
+ self.check_post({'adopt': 2}, '/todo?id=1', 400)
+ # test cannot do 2-step circular adoption
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.post_exp_todo([exp], {'adopt': 2}, 3)
+ self.check_post({'adopt': 3}, '/todo?id=1', 400)
+ # test can adopt Todo into ProcessStep chain via its Process (with key
+ # 'step_filler' equivalent to single-element 'adopt' if intable)
+ self.post_exp_process([exp], {}, 2)
+ self.post_exp_process([exp], {}, 3)
+ self.post_exp_process([exp], {'new_top_step': [2, 3]}, 1)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(1, owner_id=1, step_process_id=2),
+ exp.procstep_as_dict(2, owner_id=1, step_process_id=3)])
+ slots = [
+ exp.step_as_dict(node_id=1, process=2, todo=None, fillable=True),
+ exp.step_as_dict(node_id=2, process=3, todo=None, fillable=True)]
+ exp.set('steps_todo_to_process', slots)
+ self.post_exp_day([exp], {'new_todo': [2]})
+ self.post_exp_day([exp], {'new_todo': [3]})
+ self.check_json_get('/todo?id=1', exp)
+ self.post_exp_todo([exp], {'step_filler_to_1': 5, 'adopt': [4]}, 1)
+ exp.lib_get('Todo', 1)['children'] += [5]
+ slots[0]['todo'] = 4
+ slots[1]['todo'] = 5
+ self.check_json_get('/todo?id=1', exp)
+ # test 'ignore' values for 'step_filler' are ignored, and intable
+ # 'step_filler' values are interchangeable with those of 'adopt'
+ todo_post = {'adopt': 5, 'step_filler_to_1': ['ignore', 4]}
+ self.check_post(todo_post, '/todo?id=1')
+ self.check_json_get('/todo?id=1', exp)
+ # test cannot adopt into non-top-level elements of chain, instead
+ # creating new top-level steps when adopting of respective Process
+ self.post_exp_process([exp], {}, 4)
+ self.post_exp_process([exp], {'new_top_step': 4, 'step_of': [1]}, 3)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(3, owner_id=3, step_process_id=4)])
+ slots[1]['children'] = [exp.step_as_dict(
+ node_id=3, process=4, todo=None, fillable=True)]
+ self.post_exp_day([exp], {'new_todo': [4]})
+ self.post_exp_todo([exp], {'adopt': [4, 5, 6]}, 1)
+ slots += [exp.step_as_dict(
+ node_id=4, process=None, todo=6, fillable=False)]
+ self.check_json_get('/todo?id=1', exp)
- def test_do_POST_day_todo_doneness(self) -> None:
- """Test Todo doneness can be posted to Day view."""
- self.post_process()
- form_data = {'day_comment': '', 'new_todo': [1], 'make_type': 'full'}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
- form_data = {'day_comment': '', 'todo_id': [1], 'make_type': 'full',
- 'comment': [''], 'done': [], 'effort': ['']}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
- self.assertEqual(todo.is_done, False)
- form_data = {'day_comment': '', 'todo_id': [1], 'done': [1],
- 'make_type': 'full', 'comment': [''], 'effort': ['']}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
- self.assertEqual(todo.is_done, True)
+ def test_POST_todo_make_empty(self) -> None:
+ """Test creation via POST /todo "step_filler_to"/"make"."""
+ # create chain of Processes
+ exp = ExpectedGetTodo(1)
+ self.post_exp_process([exp], {}, 1)
+ for i in range(1, 4):
+ self.post_exp_process([exp], {'new_top_step': i}, i+1)
+ exp.lib_set('ProcessStep',
+ [exp.procstep_as_dict(1, owner_id=2, step_process_id=1),
+ exp.procstep_as_dict(2, owner_id=3, step_process_id=2),
+ exp.procstep_as_dict(3, owner_id=4, step_process_id=3)])
+ # post (childless) Todo of chain end, then make empty on next in line
+ self.post_exp_day([exp], {'new_todo': [4]})
+ slots = [exp.step_as_dict(
+ node_id=1, process=3, todo=None, fillable=True,
+ children=[exp.step_as_dict(
+ node_id=2, process=2, todo=None, fillable=False,
+ children=[exp.step_as_dict(
+ node_id=3, process=1, todo=None, fillable=False)])])]
+ exp.set('steps_todo_to_process', slots)
+ self.check_json_get('/todo?id=1', exp)
+ self.check_post({'step_filler_to_1': 'make_3'}, '/todo?id=1')
+ exp.set_todo_from_post(2, {'process_id': 3})
+ exp.set_todo_from_post(1, {'process_id': 4, 'children': [2]})
+ slots[0]['todo'] = 2
+ assert isinstance(slots[0]['children'], list)
+ slots[0]['children'][0]['fillable'] = True
+ self.check_json_get('/todo?id=1', exp)
+ # make new top-level Todo without chain implied by its Process
+ self.check_post({'make_empty': 2, 'adopt': [2]}, '/todo?id=1')
+ exp.set_todo_from_post(3, {'process_id': 2})
+ exp.set_todo_from_post(1, {'process_id': 4, 'children': [2, 3]})
+ slots += [exp.step_as_dict(
+ node_id=4, process=None, todo=3, fillable=False)]
+ self.check_json_get('/todo?id=1', exp)
+ # fail on trying to call make_empty on non-existing Process
+ self.check_post({'make_full': 5}, '/todo?id=1', 404)
- def test_do_GET_todo(self) -> None:
+ def test_GET_todo(self) -> None:
"""Test GET /todo response codes."""
- self.post_process()
- form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
- self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
- self.check_get('/todo', 404)
- self.check_get('/todo?id=', 404)
- self.check_get('/todo?id=foo', 400)
- self.check_get('/todo?id=0', 404)
- self.check_get('/todo?id=1', 200)
+ # test malformed or illegal parameter values
+ self.check_get_defaults('/todo')
+ # test all existing Processes are shown as available
+ exp = ExpectedGetTodo(1)
+ self.post_exp_process([exp], {}, 1)
+ self.post_exp_day([exp], {'new_todo': [1]})
+ self.post_exp_process([exp], {}, 2)
+ self.check_json_get('/todo?id=1', exp)
+ # test chain of Processes shown as potential step nodes
+ self.post_exp_process([exp], {}, 3)
+ self.post_exp_process([exp], {}, 4)
+ self.post_exp_process([exp], {'new_top_step': 2}, 1)
+ self.post_exp_process([exp], {'new_top_step': 3, 'step_of': [1]}, 2)
+ self.post_exp_process([exp], {'new_top_step': 4, 'step_of': [2]}, 3)
+ exp.lib_set('ProcessStep', [
+ exp.procstep_as_dict(1, owner_id=1, step_process_id=2),
+ exp.procstep_as_dict(2, owner_id=2, step_process_id=3),
+ exp.procstep_as_dict(3, owner_id=3, step_process_id=4)])
+ slots = [exp.step_as_dict(
+ node_id=1, process=2, todo=None, fillable=True,
+ children=[exp.step_as_dict(
+ node_id=2, process=3, todo=None, fillable=False,
+ children=[exp.step_as_dict(
+ node_id=3, process=4, todo=None, fillable=False)])])]
+ exp.set('steps_todo_to_process', slots)
+ self.check_json_get('/todo?id=1', exp)
+ # test display of parallel chains
+ proc_steps_post = {'new_top_step': 4, 'kept_steps': [1, 3]}
+ self.post_exp_process([], proc_steps_post, 1)
+ exp.lib_set('ProcessStep', [
+ exp.procstep_as_dict(4, owner_id=1, step_process_id=4)])
+ slots += [exp.step_as_dict(
+ node_id=4, process=4, todo=None, fillable=True)]
+ self.check_json_get('/todo?id=1', exp)
+
+ def test_POST_todo_doneness_relations(self) -> None:
+ """Test Todo.is_done Condition, adoption relations for /todo POSTs."""
+ self.post_exp_process([], {}, 1)
+ # test Todo with adoptee can only be set done if adoptee is done too
+ self.post_exp_day([], {'new_todo': [1]})
+ self.post_exp_day([], {'new_todo': [1]})
+ self.check_post({'adopt': 2, 'is_done': 1}, '/todo?id=1', 400)
+ self.check_post({'is_done': 1}, '/todo?id=2')
+ self.check_post({'adopt': 2, 'is_done': 1}, '/todo?id=1', 302)
+ # test Todo cannot be set undone with adopted Todo not done yet
+ self.check_post({'is_done': 0}, '/todo?id=2')
+ self.check_post({'adopt': 2, 'is_done': 0}, '/todo?id=1', 400)
+ # test unadoption relieves block
+ self.check_post({'is_done': 0}, '/todo?id=1', 302)
+ # test Condition being set or unset can block doneness setting
+ c1_post = {'title': '', 'description': '', 'is_active': 0}
+ c2_post = {'title': '', 'description': '', 'is_active': 1}
+ self.check_post(c1_post, '/condition', redir='/condition?id=1')
+ self.check_post(c2_post, '/condition', redir='/condition?id=2')
+ self.check_post({'conditions': [1], 'is_done': 1}, '/todo?id=1', 400)
+ self.check_post({'is_done': 1}, '/todo?id=1', 302)
+ self.check_post({'is_done': 0}, '/todo?id=1', 302)
+ self.check_post({'blockers': [2], 'is_done': 1}, '/todo?id=1', 400)
+ self.check_post({'is_done': 1}, '/todo?id=1', 302)
+ # test setting Todo doneness can set/un-set Conditions, but only on
+ # doneness change, not by mere passive state
+ self.check_post({'is_done': 0}, '/todo?id=2', 302)
+ self.check_post({'enables': [1], 'is_done': 1}, '/todo?id=1')
+ self.check_post({'conditions': [1], 'is_done': 1}, '/todo?id=2', 400)
+ self.check_post({'enables': [1], 'is_done': 0}, '/todo?id=1')
+ self.check_post({'enables': [1], 'is_done': 1}, '/todo?id=1')
+ self.check_post({'conditions': [1], 'is_done': 1}, '/todo?id=2')
+ self.check_post({'blockers': [1], 'is_done': 0}, '/todo?id=2', 400)
+ self.check_post({'disables': [1], 'is_done': 1}, '/todo?id=1')
+ self.check_post({'blockers': [1], 'is_done': 0}, '/todo?id=2', 400)
+ self.check_post({'disables': [1]}, '/todo?id=1')
+ self.check_post({'disables': [1], 'is_done': 1}, '/todo?id=1')
+ self.check_post({'blockers': [1]}, '/todo?id=2')
"""Shared test utilities."""
+# pylint: disable=too-many-lines
from __future__ import annotations
from unittest import TestCase
from typing import Mapping, Any, Callable
from http.client import HTTPConnection
from datetime import datetime, timedelta
from time import sleep
-from json import loads as json_loads
+from json import loads as json_loads, dumps as json_dumps
from urllib.parse import urlencode
from uuid import uuid4
from os import remove as remove_file
+from pprint import pprint
from plomtask.db import DatabaseFile, DatabaseConnection
from plomtask.http import TaskHandler, TaskServer
from plomtask.processes import Process, ProcessStep
VERSIONED_VALS: dict[str,
list[str] | list[float]] = {'str': ['A', 'B'],
'float': [0.3, 1.1]}
+VALID_TRUES = {True, 'True', 'true', '1', 'on'}
class TestCaseAugmented(TestCase):
default_init_kwargs: dict[str, Any] = {}
@staticmethod
- def _run_if_checked_class(f: Callable[..., None]) -> Callable[..., None]:
- def wrapper(self: TestCase) -> None:
- if hasattr(self, 'checked_class'):
- f(self)
- return wrapper
-
- @classmethod
- def _run_on_versioned_attributes(cls,
- f: Callable[..., None]
+ def _run_on_versioned_attributes(f: Callable[..., None]
) -> Callable[..., None]:
- @cls._run_if_checked_class
def wrapper(self: TestCase) -> None:
assert isinstance(self, TestCaseAugmented)
for attr_name in self.checked_class.to_save_versioned():
f(self, owner, attr_name, attr, default, to_set)
return wrapper
+ @classmethod
+ def _run_if_sans_db(cls, f: Callable[..., None]) -> Callable[..., None]:
+ def wrapper(self: TestCaseSansDB) -> None:
+ if issubclass(cls, TestCaseSansDB):
+ f(self)
+ return wrapper
+
+ @classmethod
+ def _run_if_with_db_but_not_server(cls,
+ f: Callable[..., None]
+ ) -> Callable[..., None]:
+ def wrapper(self: TestCaseWithDB) -> None:
+ if issubclass(cls, TestCaseWithDB) and\
+ not issubclass(cls, TestCaseWithServer):
+ f(self)
+ return wrapper
+
@classmethod
def _make_from_defaults(cls, id_: float | str | None) -> Any:
return cls.checked_class(id_, **cls.default_init_kwargs)
legal_ids: list[str] | list[int] = [1, 5]
illegal_ids: list[str] | list[int] = [0]
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_sans_db
def test_id_validation(self) -> None:
"""Test .id_ validation/setting."""
for id_ in self.illegal_ids:
obj = self._make_from_defaults(id_)
self.assertEqual(obj.id_, id_)
+ @TestCaseAugmented._run_if_sans_db
@TestCaseAugmented._run_on_versioned_attributes
def test_versioned_set(self,
_: Any,
__: str,
attr: VersionedAttribute,
default: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
"""Test VersionedAttribute.set() behaves as expected."""
attr.set(default)
attr.set(to_set[1])
self.assertEqual(timesorted_vals, expected)
+ @TestCaseAugmented._run_if_sans_db
@TestCaseAugmented._run_on_versioned_attributes
def test_versioned_newest(self,
_: Any,
__: str,
attr: VersionedAttribute,
default: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
"""Test VersionedAttribute.newest."""
# check .newest on empty history returns .default
attr.set(default)
self.assertEqual(attr.newest, default)
+ @TestCaseAugmented._run_if_sans_db
@TestCaseAugmented._run_on_versioned_attributes
def test_versioned_at(self,
_: Any,
__: str,
attr: VersionedAttribute,
default: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
"""Test .at() returns values nearest to queried time, or default."""
# check .at() return default on empty history
class TestCaseWithDB(TestCaseAugmented):
"""Module tests not requiring DB setup."""
- default_ids: tuple[int | str, int | str, int | str] = (1, 2, 3)
+ default_ids: tuple[int, int, int] | tuple[str, str, str] = (1, 2, 3)
def setUp(self) -> None:
Condition.empty_cache()
tomorrow = datetime.now() + timedelta(days=+1)
self.assertEqual(start, yesterday.strftime(DATE_FORMAT))
self.assertEqual(end, tomorrow.strftime(DATE_FORMAT))
- # make dated items for non-empty results
+ # prepare dated items for non-empty results
kwargs_with_date = self.default_init_kwargs.copy()
if set_id_field:
kwargs_with_date['id_'] = None
self.assertEqual(start, end)
self.assertEqual(items, [obj_today])
+ @TestCaseAugmented._run_if_with_db_but_not_server
@TestCaseAugmented._run_on_versioned_attributes
def test_saving_versioned_attributes(self,
owner: Any,
attr_name: str,
attr: VersionedAttribute,
_: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
"""Test storage and initialization of versioned attributes."""
attr_vals_saved = retrieve_attr_vals(attr)
self.assertEqual(to_set, attr_vals_saved)
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_saving_and_caching(self) -> None:
"""Test effects of .cache() and .save()."""
id1 = self.default_ids[0]
obj2.save(self.db_conn)
self.assertEqual(self.checked_class.get_cache(), {id1: obj2})
# NB: we'll only compare hashes because obj2 itself disappears on
- # .from_table_row-trioggered database reload
+ # .from_table_row-triggered database reload
obj2_hash = hash(obj2)
found_in_db += self._load_from_db(id1)
self.assertEqual([hash(o) for o in found_in_db], [obj2_hash])
with self.assertRaises(HandledException):
obj1.save(self.db_conn)
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_by_id(self) -> None:
"""Test .by_id()."""
id1, id2, _ = self.default_ids
obj2.save(self.db_conn)
self.assertEqual(obj2, self.checked_class.by_id(self.db_conn, id2))
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_by_id_or_create(self) -> None:
"""Test .by_id_or_create."""
# check .by_id_or_create fails if wrong class
self.checked_class.by_id(self.db_conn, item.id_)
self.assertEqual(self.checked_class(item.id_), item)
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_from_table_row(self) -> None:
"""Test .from_table_row() properly reads in class directly from DB."""
id_ = self.default_ids[0]
self.assertEqual({retrieved.id_: retrieved},
self.checked_class.get_cache())
+ @TestCaseAugmented._run_if_with_db_but_not_server
@TestCaseAugmented._run_on_versioned_attributes
def test_versioned_history_from_row(self,
owner: Any,
_: str,
attr: VersionedAttribute,
default: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
""""Test VersionedAttribute.history_from_row() knows its DB rows."""
attr.set(to_set[0])
for timestamp, value in attr.history.items():
self.assertEqual(value, loaded_attr.history[timestamp])
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_all(self) -> None:
"""Test .all() and its relation to cache and savings."""
id1, id2, id3 = self.default_ids
self.assertEqual(sorted(self.checked_class.all(self.db_conn)),
sorted([item1, item2, item3]))
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_singularity(self) -> None:
"""Test pointers made for single object keep pointing to it."""
id1 = self.default_ids[0]
retrieved = self.checked_class.by_id(self.db_conn, id1)
self.assertEqual(new_attr, getattr(retrieved, attr_name))
+ @TestCaseAugmented._run_if_with_db_but_not_server
@TestCaseAugmented._run_on_versioned_attributes
def test_versioned_singularity(self,
owner: Any,
attr_name: str,
attr: VersionedAttribute,
_: str | float,
- to_set: list[str | float]
+ to_set: list[str] | list[float]
) -> None:
"""Test singularity of VersionedAttributes on saving."""
owner.save(self.db_conn)
attr_retrieved = getattr(retrieved, attr_name)
self.assertEqual(attr.history, attr_retrieved.history)
- @TestCaseAugmented._run_if_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_remove(self) -> None:
"""Test .remove() effects on DB and cache."""
id_ = self.default_ids[0]
self.check_identity_with_cache_and_db([])
-class TestCaseWithServer(TestCaseWithDB):
- """Module tests against our HTTP server/handler (and database)."""
+class Expected:
+ """Builder of (JSON-like) dict to compare against responses of test server.
- def setUp(self) -> None:
- super().setUp()
- self.httpd = TaskServer(self.db_file, ('localhost', 0), TaskHandler)
- self.server_thread = Thread(target=self.httpd.serve_forever)
- self.server_thread.daemon = True
- self.server_thread.start()
- self.conn = HTTPConnection(str(self.httpd.server_address[0]),
- self.httpd.server_address[1])
- self.httpd.set_json_mode()
+ Collects all items and relations we expect expressed in the server's JSON
+ responses and puts them into the proper json.dumps-friendly dict structure,
+ accessibla via .as_dict, to compare them in TestsWithServer.check_json_get.
- def tearDown(self) -> None:
- self.httpd.shutdown()
- self.httpd.server_close()
- self.server_thread.join()
- super().tearDown()
+ On its own provides for .as_dict output only {"_library": …}, initialized
+ from .__init__ and to be directly manipulated via the .lib* methods.
+ Further structures of the expected response may be added and kept
+ up-to-date by subclassing .__init__, .recalc, and .d.
- @staticmethod
- def as_id_list(items: list[dict[str, object]]) -> list[int | str]:
- """Return list of only 'id' fields of items."""
- id_list = []
- for item in items:
- assert isinstance(item['id'], (int, str))
- id_list += [item['id']]
- return id_list
+ NB: Lots of expectations towards server behavior will be made explicit here
+ (or in the subclasses) rather than in the actual TestCase methods' code.
+ """
+ _default_dict: dict[str, Any]
+ _forced: dict[str, Any]
+ _fields: dict[str, Any]
+ _on_empty_make_temp: tuple[str, str]
+
+ def __init__(self,
+ todos: list[dict[str, Any]] | None = None,
+ procs: list[dict[str, Any]] | None = None,
+ procsteps: list[dict[str, Any]] | None = None,
+ conds: list[dict[str, Any]] | None = None,
+ days: list[dict[str, Any]] | None = None
+ ) -> None:
+ # pylint: disable=too-many-arguments
+ for name in ['_default_dict', '_fields', '_forced']:
+ if not hasattr(self, name):
+ setattr(self, name, {})
+ self._lib = {}
+ for title, items in [('Todo', todos),
+ ('Process', procs),
+ ('ProcessStep', procsteps),
+ ('Condition', conds),
+ ('Day', days)]:
+ if items:
+ self._lib[title] = self._as_refs(items)
+ for k, v in self._default_dict.items():
+ if k not in self._fields:
+ self._fields[k] = v
+
+ def recalc(self) -> None:
+ """Update internal dictionary by subclass-specific rules."""
+ todos = self.lib_all('Todo')
+ for todo in todos:
+ todo['parents'] = []
+ for todo in todos:
+ for child_id in todo['children']:
+ self.lib_get('Todo', child_id)['parents'] += [todo['id']]
+ todo['children'].sort()
+ procsteps = self.lib_all('ProcessStep')
+ procs = self.lib_all('Process')
+ for proc in procs:
+ proc['explicit_steps'] = [s['id'] for s in procsteps
+ if s['owner_id'] == proc['id']]
+
+ @property
+ def as_dict(self) -> dict[str, Any]:
+ """Return dict to compare against test server JSON responses."""
+ make_temp = False
+ if hasattr(self, '_on_empty_make_temp'):
+ category, dicter = getattr(self, '_on_empty_make_temp')
+ id_ = self._fields[category.lower()]
+ make_temp = not bool(self.lib_get(category, id_))
+ if make_temp:
+ self.lib_set(category, [getattr(self, dicter)(id_)])
+ self.recalc()
+ d = {'_library': self._lib}
+ for k, v in self._fields.items():
+ # we expect everything sortable to be sorted
+ if isinstance(v, list) and k not in self._forced:
+ # NB: if we don't test for v being list, sorted() on an empty
+ # dict may return an empty list
+ try:
+ v = sorted(v)
+ except TypeError:
+ pass
+ d[k] = v
+ for k, v in self._forced.items():
+ d[k] = v
+ if make_temp:
+ json = json_dumps(d)
+ id_ = id_ if id_ is not None else '?'
+ self.lib_del(category, id_)
+ d = json_loads(json)
+ return d
+
+ def lib_get(self, category: str, id_: str | int) -> dict[str, Any]:
+ """From library, return item of category and id_, or empty dict."""
+ str_id = str(id_)
+ if category in self._lib and str_id in self._lib[category]:
+ return self._lib[category][str_id]
+ return {}
+
+ def lib_all(self, category: str) -> list[dict[str, Any]]:
+ """From library, return items of category, or [] if none."""
+ if category in self._lib:
+ return list(self._lib[category].values())
+ return []
+
+ def lib_set(self, category: str, items: list[dict[str, object]]) -> None:
+ """Update library for category with items."""
+ if category not in self._lib:
+ self._lib[category] = {}
+ for k, v in self._as_refs(items).items():
+ self._lib[category][k] = v
+
+ def lib_del(self, category: str, id_: str | int) -> None:
+ """Remove category element of id_ from library."""
+ del self._lib[category][str(id_)]
+ if 0 == len(self._lib[category]):
+ del self._lib[category]
+
+ def lib_wipe(self, category: str) -> None:
+ """Remove category from library."""
+ if category in self._lib:
+ del self._lib[category]
+
+ def set(self, field_name: str, value: object) -> None:
+ """Set top-level .as_dict field."""
+ self._fields[field_name] = value
+
+ def force(self, field_name: str, value: object) -> None:
+ """Set ._forced field to ensure value in .as_dict."""
+ self._forced[field_name] = value
+
+ def unforce(self, field_name: str) -> None:
+ """Unset ._forced field."""
+ del self._forced[field_name]
@staticmethod
- def as_refs(items: list[dict[str, object]]
- ) -> dict[str, dict[str, object]]:
+ def _as_refs(items: list[dict[str, object]]
+ ) -> dict[str, dict[str, object]]:
"""Return dictionary of items by their 'id' fields."""
refs = {}
for item in items:
- refs[str(item['id'])] = item
+ id_ = str(item['id']) if item['id'] is not None else '?'
+ refs[id_] = item
return refs
+ @staticmethod
+ def as_ids(items: list[dict[str, Any]]) -> list[int] | list[str]:
+ """Return list of only 'id' fields of items."""
+ return [item['id'] for item in items]
+
+ @staticmethod
+ def day_as_dict(date: str, comment: str = '') -> dict[str, object]:
+ """Return JSON of Day to expect."""
+ return {'id': date, 'comment': comment, 'todos': []}
+
+ def set_day_from_post(self, date: str, d: dict[str, Any]) -> None:
+ """Set Day of date in library based on POST dict d."""
+ day = self.day_as_dict(date)
+ for k, v in d.items():
+ if 'day_comment' == k:
+ day['comment'] = v
+ elif 'new_todo' == k:
+ next_id = 1
+ for todo in self.lib_all('Todo'):
+ if next_id <= todo['id']:
+ next_id = todo['id'] + 1
+ for proc_id in sorted([id_ for id_ in v if id_]):
+ todo = self.todo_as_dict(next_id, proc_id, date)
+ self.lib_set('Todo', [todo])
+ next_id += 1
+ elif 'done' == k:
+ for todo_id in v:
+ self.lib_get('Todo', todo_id)['is_done'] = True
+ elif 'todo_id' == k:
+ for i, todo_id in enumerate(v):
+ t = self.lib_get('Todo', todo_id)
+ if 'comment' in d:
+ t['comment'] = d['comment'][i]
+ if 'effort' in d:
+ effort = d['effort'][i] if d['effort'][i] else None
+ t['effort'] = effort
+ self.lib_set('Day', [day])
+
@staticmethod
def cond_as_dict(id_: int = 1,
is_active: bool = False,
- titles: None | list[str] = None,
- descriptions: None | list[str] = None
+ title: None | str = None,
+ description: None | str = None,
) -> dict[str, object]:
"""Return JSON of Condition to expect."""
+ versioned: dict[str, dict[str, object]]
+ versioned = {'title': {}, 'description': {}}
+ if title is not None:
+ versioned['title']['0'] = title
+ if description is not None:
+ versioned['description']['0'] = description
+ return {'id': id_, 'is_active': is_active, '_versioned': versioned}
+
+ def set_cond_from_post(self, id_: int, d: dict[str, Any]) -> None:
+ """Set Condition of id_ in library based on POST dict d."""
+ if 'delete' in d:
+ self.lib_del('Condition', id_)
+ return
+ cond = self.lib_get('Condition', id_)
+ if cond:
+ cond['is_active'] = 'is_active' in d and\
+ d['is_active'] in VALID_TRUES
+ for category in ['title', 'description']:
+ history = cond['_versioned'][category]
+ if len(history) > 0:
+ last_i = sorted([int(k) for k in history.keys()])[-1]
+ if d[category] != history[str(last_i)]:
+ history[str(last_i + 1)] = d[category]
+ else:
+ history['0'] = d[category]
+ else:
+ cond = self.cond_as_dict(id_, **d)
+ self.lib_set('Condition', [cond])
+
+ @staticmethod
+ def todo_as_dict(id_: int = 1,
+ process_id: int = 1,
+ date: str = '2024-01-01',
+ conditions: None | list[int] = None,
+ disables: None | list[int] = None,
+ blockers: None | list[int] = None,
+ enables: None | list[int] = None,
+ calendarize: bool = False,
+ comment: str = '',
+ is_done: bool = False,
+ effort: float | None = None,
+ children: list[int] | None = None,
+ parents: list[int] | None = None,
+ ) -> dict[str, object]:
+ """Return JSON of Todo to expect."""
+ # pylint: disable=too-many-arguments
d = {'id': id_,
- 'is_active': is_active,
- '_versioned': {
- 'title': {},
- 'description': {}}}
- titles = titles if titles else []
- descriptions = descriptions if descriptions else []
- assert isinstance(d['_versioned'], dict)
- for i, title in enumerate(titles):
- d['_versioned']['title'][i] = title
- for i, description in enumerate(descriptions):
- d['_versioned']['description'][i] = description
+ 'date': date,
+ 'process_id': process_id,
+ 'is_done': is_done,
+ 'calendarize': calendarize,
+ 'comment': comment,
+ 'children': children if children else [],
+ 'parents': parents if parents else [],
+ 'effort': effort,
+ 'conditions': conditions if conditions else [],
+ 'disables': disables if disables else [],
+ 'blockers': blockers if blockers else [],
+ 'enables': enables if enables else []}
return d
+ def set_todo_from_post(self, id_: int, d: dict[str, Any]) -> None:
+ """Set Todo of id_ in library based on POST dict d."""
+ corrected_kwargs: dict[str, Any] = {
+ 'children': [], 'is_done': 0, 'calendarize': 0, 'comment': ''}
+ for k, v in d.items():
+ if k.startswith('step_filler_to_'):
+ continue
+ if 'adopt' == k:
+ new_children = v if isinstance(v, list) else [v]
+ corrected_kwargs['children'] += new_children
+ continue
+ if k in {'is_done', 'calendarize'} and v in VALID_TRUES:
+ v = True
+ corrected_kwargs[k] = v
+ todo = self.lib_get('Todo', id_)
+ if todo:
+ for k, v in corrected_kwargs.items():
+ todo[k] = v
+ else:
+ todo = self.todo_as_dict(id_, **corrected_kwargs)
+ self.lib_set('Todo', [todo])
+
+ @staticmethod
+ def procstep_as_dict(id_: int,
+ owner_id: int,
+ step_process_id: int,
+ parent_step_id: int | None = None
+ ) -> dict[str, object]:
+ """Return JSON of ProcessStep to expect."""
+ return {'id': id_,
+ 'owner_id': owner_id,
+ 'step_process_id': step_process_id,
+ 'parent_step_id': parent_step_id}
+
@staticmethod
def proc_as_dict(id_: int = 1,
- title: str = 'A',
- description: str = '',
- effort: float = 1.0,
+ title: None | str = None,
+ description: None | str = None,
+ effort: None | float = None,
conditions: None | list[int] = None,
disables: None | list[int] = None,
blockers: None | list[int] = None,
- enables: None | list[int] = None
+ enables: None | list[int] = None,
+ explicit_steps: None | list[int] = None
) -> dict[str, object]:
"""Return JSON of Process to expect."""
# pylint: disable=too-many-arguments
+ versioned: dict[str, dict[str, object]]
+ versioned = {'title': {}, 'description': {}, 'effort': {}}
+ if title is not None:
+ versioned['title']['0'] = title
+ if description is not None:
+ versioned['description']['0'] = description
+ if effort is not None:
+ versioned['effort']['0'] = effort
d = {'id': id_,
'calendarize': False,
'suppressed_steps': [],
- 'explicit_steps': [],
- '_versioned': {
- 'title': {0: title},
- 'description': {0: description},
- 'effort': {0: effort}},
+ 'explicit_steps': explicit_steps if explicit_steps else [],
+ '_versioned': versioned,
'conditions': conditions if conditions else [],
'disables': disables if disables else [],
'enables': enables if enables else [],
'blockers': blockers if blockers else []}
return d
+ def set_proc_from_post(self, id_: int, d: dict[str, Any]) -> None:
+ """Set Process of id_ in library based on POST dict d."""
+ proc = self.lib_get('Process', id_)
+ if proc:
+ for category in ['title', 'description', 'effort']:
+ history = proc['_versioned'][category]
+ if len(history) > 0:
+ last_i = sorted([int(k) for k in history.keys()])[-1]
+ if d[category] != history[str(last_i)]:
+ history[str(last_i + 1)] = d[category]
+ else:
+ history['0'] = d[category]
+ else:
+ proc = self.proc_as_dict(id_,
+ d['title'], d['description'], d['effort'])
+ ignore = {'title', 'description', 'effort', 'new_top_step', 'step_of',
+ 'kept_steps'}
+ proc['calendarize'] = False
+ for k, v in d.items():
+ if k in ignore\
+ or k.startswith('step_') or k.startswith('new_step_to'):
+ continue
+ if k in {'calendarize'} and v in VALID_TRUES:
+ v = True
+ elif k in {'suppressed_steps', 'explicit_steps', 'conditions',
+ 'disables', 'enables', 'blockers'}:
+ if not isinstance(v, list):
+ v = [v]
+ proc[k] = v
+ self.lib_set('Process', [proc])
+
+
+class TestCaseWithServer(TestCaseWithDB):
+ """Module tests against our HTTP server/handler (and database)."""
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.httpd = TaskServer(self.db_file, ('localhost', 0), TaskHandler)
+ self.server_thread = Thread(target=self.httpd.serve_forever)
+ self.server_thread.daemon = True
+ self.server_thread.start()
+ self.conn = HTTPConnection(str(self.httpd.server_address[0]),
+ self.httpd.server_address[1])
+ self.httpd.render_mode = 'json'
+
+ def tearDown(self) -> None:
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ self.server_thread.join()
+ super().tearDown()
+
+ def post_exp_cond(self,
+ exps: list[Expected],
+ payload: dict[str, object],
+ id_: int = 1,
+ post_to_id: bool = True,
+ redir_to_id: bool = True
+ ) -> None:
+ """POST /condition(s), appropriately update Expecteds."""
+ # pylint: disable=too-many-arguments
+ target = f'/condition?id={id_}' if post_to_id else '/condition'
+ redir = f'/condition?id={id_}' if redir_to_id else '/conditions'
+ if 'title' not in payload:
+ payload['title'] = 'foo'
+ if 'description' not in payload:
+ payload['description'] = 'foo'
+ self.check_post(payload, target, redir=redir)
+ for exp in exps:
+ exp.set_cond_from_post(id_, payload)
+
+ def post_exp_day(self,
+ exps: list[Expected],
+ payload: dict[str, Any],
+ date: str = '2024-01-01'
+ ) -> None:
+ """POST /day, appropriately update Expecteds."""
+ if 'make_type' not in payload:
+ payload['make_type'] = 'empty'
+ if 'day_comment' not in payload:
+ payload['day_comment'] = ''
+ target = f'/day?date={date}'
+ redir_to = f'{target}&make_type={payload["make_type"]}'
+ self.check_post(payload, target, 302, redir_to)
+ for exp in exps:
+ exp.set_day_from_post(date, payload)
+
+ def post_exp_process(self,
+ exps: list[Expected],
+ payload: dict[str, Any],
+ id_: int,
+ ) -> dict[str, object]:
+ """POST /process, appropriately update Expecteds."""
+ if 'title' not in payload:
+ payload['title'] = 'foo'
+ if 'description' not in payload:
+ payload['description'] = 'foo'
+ if 'effort' not in payload:
+ payload['effort'] = 1.1
+ self.check_post(payload, f'/process?id={id_}',
+ redir=f'/process?id={id_}')
+ for exp in exps:
+ exp.set_proc_from_post(id_, payload)
+ return payload
+
+ def post_exp_todo(self,
+ exps: list[Expected],
+ payload: dict[str, Any],
+ id_: int,
+ ) -> None:
+ """POST /todo, appropriately updated Expecteds."""
+ self.check_post(payload, f'/todo?id={id_}')
+ for exp in exps:
+ exp.set_todo_from_post(id_, payload)
+
+ def check_filter(self, exp: Expected, category: str, key: str,
+ val: str, list_ids: list[int]) -> None:
+ """Check GET /{category}?{key}={val} sorts to list_ids."""
+ # pylint: disable=too-many-arguments
+ exp.set(key, val)
+ exp.force(category, list_ids)
+ self.check_json_get(f'/{category}?{key}={val}', exp)
+
def check_redirect(self, target: str) -> None:
"""Check that self.conn answers with a 302 redirect to target."""
response = self.conn.getresponse()
self.conn.request('GET', target)
self.assertEqual(self.conn.getresponse().status, expected_code)
+ def check_minimal_inputs(self,
+ url: str,
+ minimal_inputs: dict[str, Any]
+ ) -> None:
+ """Check that url 400's unless all of minimal_inputs provided."""
+ for to_hide in minimal_inputs.keys():
+ to_post = {k: v for k, v in minimal_inputs.items() if k != to_hide}
+ self.check_post(to_post, url, 400)
+
def check_post(self, data: Mapping[str, object], target: str,
expected_code: int = 302, redir: str = '') -> None:
"""Check that POST of data to target yields expected_code."""
else:
self.assertEqual(self.conn.getresponse().status, expected_code)
- def check_get_defaults(self, path: str) -> None:
+ def check_get_defaults(self,
+ path: str,
+ default_id: str = '1',
+ id_name: str = 'id'
+ ) -> None:
"""Some standard model paths to test."""
- self.check_get(path, 200)
- self.check_get(f'{path}?id=', 200)
- self.check_get(f'{path}?id=foo', 400)
- self.check_get(f'/{path}?id=0', 500)
- self.check_get(f'{path}?id=1', 200)
-
- def post_process(self, id_: int = 1,
- form_data: dict[str, Any] | None = None
- ) -> dict[str, Any]:
- """POST basic Process."""
- if not form_data:
- form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.1}
- self.check_post(form_data, f'/process?id={id_}',
- redir=f'/process?id={id_}')
- return form_data
+ nonexist_status = 200 if self.checked_class.can_create_by_id else 404
+ self.check_get(path, nonexist_status)
+ self.check_get(f'{path}?{id_name}=', 400)
+ self.check_get(f'{path}?{id_name}=foo', 400)
+ self.check_get(f'/{path}?{id_name}=0', 400)
+ self.check_get(f'{path}?{id_name}={default_id}', nonexist_status)
- def check_json_get(self, path: str, expected: dict[str, object]) -> None:
+ def check_json_get(self, path: str, expected: Expected) -> None:
"""Compare JSON on GET path with expected.
To simplify comparison of VersionedAttribute histories, transforms
- timestamp keys of VersionedAttribute history keys into integers
- counting chronologically forward from 0.
+ timestamp keys of VersionedAttribute history keys into (strings of)
+ integers counting chronologically forward from 0.
"""
def rewrite_history_keys_in(item: Any) -> Any:
if isinstance(item, dict):
if '_versioned' in item.keys():
- for k in item['_versioned']:
- vals = item['_versioned'][k].values()
+ for category in item['_versioned']:
+ vals = item['_versioned'][category].values()
history = {}
for i, val in enumerate(vals):
- history[i] = val
- item['_versioned'][k] = history
- for k in list(item.keys()):
- rewrite_history_keys_in(item[k])
+ history[str(i)] = val
+ item['_versioned'][category] = history
+ for category in list(item.keys()):
+ rewrite_history_keys_in(item[category])
elif isinstance(item, list):
item[:] = [rewrite_history_keys_in(i) for i in item]
return item
+ def walk_diffs(path: str, cmp1: object, cmp2: object) -> None:
+ # pylint: disable=too-many-branches
+ def warn(intro: str, val: object) -> None:
+ if isinstance(val, (str, int, float)):
+ print(intro, val)
+ else:
+ print(intro)
+ pprint(val)
+ if cmp1 != cmp2:
+ if isinstance(cmp1, dict) and isinstance(cmp2, dict):
+ for k, v in cmp1.items():
+ if k not in cmp2:
+ warn(f'DIFF {path}: retrieved lacks {k}', v)
+ elif v != cmp2[k]:
+ walk_diffs(f'{path}:{k}', v, cmp2[k])
+ for k in [k for k in cmp2.keys() if k not in cmp1]:
+ warn(f'DIFF {path}: expected lacks retrieved\'s {k}',
+ cmp2[k])
+ elif isinstance(cmp1, list) and isinstance(cmp2, list):
+ for i, v1 in enumerate(cmp1):
+ if i >= len(cmp2):
+ warn(f'DIFF {path}[{i}] retrieved misses:', v1)
+ elif v1 != cmp2[i]:
+ walk_diffs(f'{path}[{i}]', v1, cmp2[i])
+ if len(cmp2) > len(cmp1):
+ for i, v2 in enumerate(cmp2[len(cmp1):]):
+ warn(f'DIFF {path}[{len(cmp1)+i}] misses:', v2)
+ else:
+ warn(f'DIFF {path} – for expected:', cmp1)
+ warn('… and for retrieved:', cmp2)
+
self.conn.request('GET', path)
response = self.conn.getresponse()
self.assertEqual(response.status, 200)
retrieved = json_loads(response.read().decode())
rewrite_history_keys_in(retrieved)
- self.assertEqual(expected, retrieved)
+ cmp = expected.as_dict
+ try:
+ self.assertEqual(cmp, retrieved)
+ except AssertionError as e:
+ print('EXPECTED:')
+ pprint(cmp)
+ print('RETRIEVED:')
+ pprint(retrieved)
+ walk_diffs('', cmp, retrieved)
+ raise e