class Condition(BaseModel[int]):
"""Non-Process dependency for ProcessSteps and Todos."""
table_name = 'conditions'
- to_save = ['is_active']
- to_save_versioned = ['title', 'description']
+ to_save_simples = ['is_active']
+ versioned_defaults = {'title': 'UNNAMED', 'description': ''}
to_search = ['title.newest', 'description.newest']
can_create_by_id = True
sorters = {'is_active': lambda c: c.is_active,
def __init__(self, id_: int | None, is_active: bool = False) -> None:
super().__init__(id_)
self.is_active = is_active
- self.title = VersionedAttribute(self, 'condition_titles', 'UNNAMED')
- self.description = VersionedAttribute(self, 'condition_descriptions',
- '')
+ for name in ['title', 'description']:
+ attr = VersionedAttribute(self, f'condition_{name}s',
+ self.versioned_defaults[name])
+ setattr(self, name, attr)
def remove(self, db_conn: DatabaseConnection) -> None:
"""Remove from DB, with VersionedAttributes.
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_)]
class Day(BaseModel[str]):
"""Individual days defined by their dates."""
table_name = 'days'
- to_save = ['comment']
+ to_save_simples = ['comment']
add_to_dict = ['todos']
can_create_by_id = True
day.todos = Todo.by_date(db_conn, day.id_)
return day
- @classmethod
- def by_date_range_filled(cls, db_conn: DatabaseConnection,
- start: str, end: str) -> list[Day]:
- """Return days existing and non-existing between dates start/end."""
- ret = cls.by_date_range_with_limits(db_conn, (start, end), 'id')
- days, start_date, end_date = ret
- return cls.with_filled_gaps(days, start_date, end_date)
-
@classmethod
def with_filled_gaps(cls, days: list[Day], start_date: str, end_date: str
) -> list[Day]:
- """In days, fill with (un-saved) Days gaps between start/end_date."""
+ """In days, fill with (un-stored) Days gaps between start/end_date."""
+ days = days[:]
+ start_date, end_date = valid_date(start_date), valid_date(end_date)
if start_date > end_date:
- return days
+ return []
+ days = [d for d in days if d.date >= start_date and d.date <= end_date]
days.sort()
if start_date not in [d.date for d in days]:
days[:] = [Day(start_date)] + days
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
class BaseModel(Generic[BaseModelId]):
"""Template for most of the models we use/derive from the DB."""
table_name = ''
- to_save: list[str] = []
- to_save_versioned: list[str] = []
+ to_save_simples: list[str] = []
to_save_relations: list[tuple[str, str, str, int]] = []
+ versioned_defaults: dict[str, str | float] = {}
add_to_dict: list[str] = []
id_: None | BaseModelId
cache_: dict[BaseModelId, Self]
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:
- hashable = [self.id_] + [getattr(self, name) for name in self.to_save]
+ hashable = [self.id_] + [getattr(self, name)
+ for name in self.to_save_simples]
for definition in self.to_save_relations:
attr = getattr(self, definition[2])
hashable += [tuple(rel.id_ for rel in attr)]
- for name in self.to_save_versioned:
+ for name in self.to_save_versioned():
hashable += [hash(getattr(self, name))]
return hash(tuple(hashable))
assert isinstance(other.id_, int)
return self.id_ < other.id_
+ @classmethod
+ def to_save_versioned(cls) -> list[str]:
+ """Return keys of cls.versioned_defaults assuming we wanna save 'em."""
+ 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}
- for to_save in self.to_save:
- 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
- if len(self.to_save_versioned) > 0:
+ 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:
+ d[to_save] = getattr(self, to_save)
+ if len(self.to_save_versioned()) > 0:
d['_versioned'] = {}
- for k in self.to_save_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:
@classmethod
def sort_by(cls, seq: list[Any], sort_key: str, default: str = 'title'
) -> str:
- """Sort cls list by cls.sorters[sort_key] (reverse if '-'-prefixed)."""
+ """Sort cls list by cls.sorters[sort_key] (reverse if '-'-prefixed).
+
+ Before cls.sorters[sort_key] is applied, seq is sorted by .id_, to
+ ensure predictability where parts of seq are of same sort value.
+ """
reverse = False
if len(sort_key) > 1 and '-' == sort_key[0]:
sort_key = sort_key[1:]
reverse = True
if sort_key not in cls.sorters:
sort_key = default
+ seq.sort(key=lambda x: x.id_, reverse=reverse)
sorter: Callable[..., Any] = cls.sorters[sort_key]
seq.sort(key=sorter, reverse=reverse)
if reverse:
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:
"""Make from DB row (sans relations), update DB cache with it."""
obj = cls(*row)
assert obj.id_ is not None
- for attr_name in cls.to_save_versioned:
+ for attr_name in cls.to_save_versioned():
attr = getattr(obj, attr_name)
table_name = attr.table_name
for row_ in db_conn.row_where(table_name, 'parent', obj.id_):
"""
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],
date_col: str = 'day'
) -> tuple[list[BaseModelInstance], str,
str]:
- """Return list of items in database within (open) date_range interval.
+ """Return list of items in DB within (closed) date_range interval.
If no range values provided, defaults them to 'yesterday' and
'tomorrow'. Knows to properly interpret these and 'today' as value.
"""Write self to DB and cache and ensure .id_.
Write both to DB, and to cache. To DB, write .id_ and attributes
- listed in cls.to_save[_versioned|_relations].
+ listed in cls.to_save_[simples|versioned|_relations].
Ensure self.id_ by setting it to what the DB command returns as the
last saved row's ID (cursor.lastrowid), EXCEPT if self.id_ already
only the case with the Day class, where it's to be a date string.
"""
values = tuple([self.id_] + [getattr(self, key)
- for key in self.to_save])
+ for key in self.to_save_simples])
table_name = self.table_name
cursor = db_conn.exec_on_vals(f'REPLACE INTO {table_name} VALUES',
values)
if not isinstance(self.id_, str):
self.id_ = cursor.lastrowid # type: ignore[assignment]
self.cache()
- for attr_name in self.to_save_versioned:
+ for attr_name in self.to_save_versioned():
getattr(self, attr_name).save(db_conn)
for table, column, attr_name, key_index in self.to_save_relations:
assert isinstance(self.id_, (int, str))
"""Remove from DB and cache, including dependencies."""
if self.id_ is None or self._get_cached(self.id_) is None:
raise HandledException('cannot remove unsaved item')
- for attr_name in self.to_save_versioned:
+ for attr_name in self.to_save_versioned():
getattr(self, attr_name).remove(db_conn)
for table, column, attr_name, _ in self.to_save_relations:
db_conn.delete_where(table, column, self.id_)
"""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):
"""Template for, and metadata for, Todos, and their arrangements."""
# pylint: disable=too-many-instance-attributes
table_name = 'processes'
- to_save = ['calendarize']
- to_save_versioned = ['title', 'description', 'effort']
+ to_save_simples = ['calendarize']
to_save_relations = [('process_conditions', 'process', 'conditions', 0),
('process_blockers', 'process', 'blockers', 0),
('process_enables', 'process', 'enables', 0),
('process_step_suppressions', 'process',
'suppressed_steps', 0)]
add_to_dict = ['explicit_steps']
+ versioned_defaults = {'title': 'UNNAMED', 'description': '', 'effort': 1.0}
to_search = ['title.newest', 'description.newest']
can_create_by_id = True
sorters = {'steps': lambda p: len(p.explicit_steps),
def __init__(self, id_: int | None, calendarize: bool = False) -> None:
BaseModel.__init__(self, id_)
ConditionsRelations.__init__(self)
- self.title = VersionedAttribute(self, 'process_titles', 'UNNAMED')
- self.description = VersionedAttribute(self, 'process_descriptions', '')
- self.effort = VersionedAttribute(self, 'process_efforts', 1.0)
+ for name in ['title', 'description', 'effort']:
+ attr = VersionedAttribute(self, f'process_{name}s',
+ self.versioned_defaults[name])
+ setattr(self, name, attr)
self.explicit_steps: list[ProcessStep] = []
self.suppressed_steps: list[ProcessStep] = []
self.calendarize = calendarize
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)
class ProcessStep(BaseModel[int]):
"""Sub-unit of Processes."""
table_name = 'process_steps'
- to_save = ['owner_id', 'step_process_id', 'parent_step_id']
+ to_save_simples = ['owner_id', 'step_process_id', 'parent_step_id']
def __init__(self, id_: int | None, owner_id: int, step_process_id: int,
parent_step_id: int | None) -> None:
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):
# pylint: disable=too-many-instance-attributes
# pylint: disable=too-many-public-methods
table_name = 'todos'
- to_save = ['process_id', 'is_done', 'date', 'comment', 'effort',
- 'calendarize']
+ to_save_simples = ['process_id', 'is_done', 'date', 'comment', 'effort',
+ 'calendarize']
to_save_relations = [('todo_conditions', 'todo', 'conditions', 0),
('todo_blockers', 'todo', 'blockers', 0),
('todo_enables', 'todo', 'enables', 0),
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
@property
def title(self) -> VersionedAttribute:
"""Shortcut to .process.title."""
+ assert isinstance(self.process.title, VersionedAttribute)
return self.process.title
@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:
parent: Any, table_name: str, default: str | float) -> None:
self.parent = parent
self.table_name = table_name
- self.default = default
+ 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())
- hashable = (self.parent.id_, self.table_name, self.default,
+ hashable = (self.parent.id_, self.table_name, self._default,
history_tuples)
return hash(hashable)
"""Return most recent timestamp."""
return sorted(self.history.keys())[-1]
+ @property
+ def value_type_name(self) -> str:
+ """Return string of name of attribute value type."""
+ return type(self._default).__name__
+
@property
def newest(self) -> str | float:
- """Return most recent value, or self.default if self.history empty."""
+ """Return most recent value, or self._default if self.history empty."""
if 0 == len(self.history):
- return self.default
+ return self._default
return self.history[self._newest_timestamp]
def reset_timestamp(self, old_str: str, new_str: str) -> None:
queried_time += ' 23:59:59.999'
sorted_timestamps = sorted(self.history.keys())
if 0 == len(sorted_timestamps):
- return self.default
+ return self._default
selected_timestamp = sorted_timestamps[0]
for timestamp in sorted_timestamps[1:]:
if timestamp > queried_time:
--- /dev/null
+{% extends '_base.html' %}
+
+{% block content %}
+<h3>calendar</h3>
+
+<p><a href="/calendar">normal view</a></p>
+
+<form action="calendar_txt" method="GET">
+from <input name="start" class="date" value="{{start}}" />
+to <input name="end" class="date" value="{{end}}" />
+<input type="submit" value="OK" />
+</form>
+<table>
+
+<pre>{% for day in days %}{% if day.weekday == "Monday" %}
+---{% endif %}{% if day.comment or day.calendarized_todos %}
+{{day.weekday|truncate(2,True,'',0)}} {{day.date}} {{day.comment|e}}{% endif %}{% if day.calendarized_todos%}{% for todo in day.calendarized_todos %}
+* {{todo.title_then|e}}{% if todo.comment %} / {{todo.comment|e}}{% endif %}{% endfor %}{% endif %}{% endfor %}
+</pre>
+{% endblock %}
-{% 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):
"""Tests requiring no DB setup."""
checked_class = Condition
- versioned_defaults_to_test = {'title': 'UNNAMED', 'description': ''}
class TestsWithDB(TestCaseWithDB):
"""Tests requiring DB, but not server setup."""
checked_class = Condition
- default_init_kwargs = {'is_active': False}
- test_versioneds = {'title': str, 'description': str}
-
- 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')
- for depender in (proc, todo):
- assert hasattr(depender, 'save')
- assert hasattr(depender, 'set_conditions')
- c = Condition(None)
- c.save(self.db_conn)
- depender.save(self.db_conn)
- depender.set_conditions(self.db_conn, [c.id_], 'conditions')
- depender.save(self.db_conn)
- with self.assertRaises(HandledException):
- c.remove(self.db_conn)
- depender.set_conditions(self.db_conn, [], 'conditions')
- 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 invalid 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)
+ # check incomplete POST payloads
+ 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_do_POST_condition(self) -> None:
+ 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', 302, '/condition?id=1')
- # … single /condition
- cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
- assert isinstance(cond['_versioned'], dict)
- expected_single = self.GET_condition_dict(cond)
- self.check_json_get('/condition?id=1', expected_single)
- # … full /conditions
- expected_all = self.GET_conditions_dict([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', 302)
- cond['_versioned']['title'][1] = 'bar'
- cond['is_active'] = True
- self.check_json_get('/condition?id=1', expected_single)
- # test deletion POST's effect on …
- self.check_post({'delete': ''}, '/condition?id=1', 302, '/conditions')
- cond = self.cond_as_dict()
- assert isinstance(expected_single['_library'], dict)
- expected_single['_library']['Condition'] = self.as_refs([cond])
- self.check_json_get('/condition?id=1', expected_single)
- # … full /conditions
- expected_all['conditions'] = []
- expected_all['_library'] = {}
- self.check_json_get('/conditions', expected_all)
-
- def test_do_GET_condition(self) -> None:
+ 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', 302, '/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 = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
- assert isinstance(cond['id'], int)
- proc1 = self.proc_as_dict(conditions=[cond['id']],
- disables=[cond['id']])
- proc2 = self.proc_as_dict(2, 'B',
- blockers=[cond['id']],
- enables=[cond['id']])
- expected = self.GET_condition_dict(cond)
- assert isinstance(expected['_library'], dict)
- expected['enabled_processes'] = self.as_id_list([proc1])
- expected['disabled_processes'] = self.as_id_list([proc2])
- expected['enabling_processes'] = self.as_id_list([proc2])
- expected['disabling_processes'] = self.as_id_list([proc1])
- expected['_library']['Process'] = self.as_refs([proc1, proc2])
- self.check_json_get('/condition?id=1', expected)
-
- def test_do_GET_conditions(self) -> None:
+ # 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 on meaningless non-empty params (incl. entirely un-used 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!
- 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
- post1 = {'is_active': False, 'title': 'foo', 'description': 'oof'}
- post2 = {'is_active': False, 'title': 'bar', 'description': 'rab'}
- post3 = {'is_active': True, 'title': 'baz', 'description': 'zab'}
- self.check_post(post1, '/condition', 302, '/condition?id=1')
- self.check_post(post2, '/condition', 302, '/condition?id=2')
- self.check_post(post3, '/condition', 302, '/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
- # (NB: by .is_active has two items of =False, their order currently
- # is not explicitly made predictable, so mail fail until we do)
- expected['conditions'] = self.as_id_list([cond1, cond3, cond2])
- expected['sort_by'] = '-title'
- self.check_json_get('/conditions?sort_by=-title', expected)
- expected['conditions'] = self.as_id_list([cond1, cond2, cond3])
- expected['sort_by'] = 'is_active'
- self.check_json_get('/conditions?sort_by=is_active', expected)
- expected['conditions'] = self.as_id_list([cond3, cond1, cond2])
- expected['sort_by'] = '-is_active'
- 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['conditions'] = self.as_id_list([cond1])
- expected['_library']['Condition'] = self.as_refs([cond1])
- expected['pattern'] = 'oo'
- self.check_json_get('/conditions?pattern=oo', 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 unittest import TestCase
-from datetime import datetime
-from typing import Callable
-from tests.utils import TestCaseWithDB, TestCaseWithServer
-from plomtask.dating import date_in_n_days
+from datetime import datetime, timedelta
+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'
-class TestsSansDB(TestCase):
+
+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."""
- legal_ids = ['2024-01-01']
- illegal_ids = ['foo', '2024-02-30', '2024-02-01 23:00:00']
+ checked_class = Day
+ legal_ids = ['2024-01-01', '2024-02-29']
+ 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"""
+ for n in [-100, -2, -1, 0, 1, 2, 1000]:
+ date = datetime.now() + timedelta(days=n)
+ 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."""
+ """Test Day's date parsing and neighbourhood resolution."""
self.assertEqual(datetime(2024, 5, 1), Day('2024-05-01').datetime)
self.assertEqual('Sunday', Day('2024-03-17').weekday)
self.assertEqual('March', Day('2024-03-17').month_name)
self.assertEqual('2023-12-31', Day('2024-01-01').prev_date)
self.assertEqual('2023-03-01', Day('2023-02-28').next_date)
- def test_Day_sorting(self) -> None:
- """Test sorting by .__lt__ and Day.__eq__."""
- day1 = Day('2024-01-01')
- day2 = Day('2024-01-02')
- day3 = Day('2024-01-03')
- days = [day3, day1, day2]
- self.assertEqual(sorted(days), [day1, day2, day3])
-
class TestsWithDB(TestCaseWithDB):
"""Tests requiring DB, but not server setup."""
checked_class = Day
default_ids = ('2024-01-01', '2024-01-02', '2024-01-03')
- def test_Day_by_date_range_filled(self) -> None:
- """Test Day.by_date_range_filled."""
- date1, date2, date3 = self.default_ids
- day1 = Day(date1)
- day2 = Day(date2)
- day3 = Day(date3)
- for day in [day1, day2, day3]:
- day.save(self.db_conn)
- # check date range includes limiter days
- self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date3),
- [day1, day2, day3])
- # check first date range value excludes what's earlier
- self.assertEqual(Day.by_date_range_filled(self.db_conn, date2, date3),
- [day2, day3])
- # check second date range value excludes what's later
- self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date2),
- [day1, day2])
- # check swapped (impossible) date range returns emptiness
- self.assertEqual(Day.by_date_range_filled(self.db_conn, date3, date1),
- [])
- # check fill_gaps= instantiates unsaved dates within date range
- # (but does not store them)
- day5 = Day('2024-01-05')
- day6 = Day('2024-01-06')
- day6.save(self.db_conn)
- day7 = Day('2024-01-07')
- self.assertEqual(Day.by_date_range_filled(self.db_conn,
- day5.date, day7.date),
- [day5, day6, day7])
- self.check_identity_with_cache_and_db([day1, day2, day3, day6])
- # check 'today' is interpreted as today's date
- today = Day(date_in_n_days(0))
- self.assertEqual(Day.by_date_range_filled(self.db_conn,
- 'today', 'today'),
- [today])
- prev_day = Day(date_in_n_days(-1))
- next_day = Day(date_in_n_days(1))
- self.assertEqual(Day.by_date_range_filled(self.db_conn,
- 'yesterday', 'tomorrow'),
- [prev_day, today, next_day])
+ def test_Day_by_date_range_with_limits(self) -> None:
+ """Test .by_date_range_with_limits."""
+ self.check_by_date_range_with_limits('id', set_id_field=False)
+
+ def test_Day_with_filled_gaps(self) -> None:
+ """Test .with_filled_gaps."""
+
+ 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 = []
+ days_expected = days_sans_comment[:]
+ for i in indexes_to_provide:
+ day_with_comment = days_with_comment[i]
+ days_provided += [day_with_comment]
+ days_expected[i] = day_with_comment
+ days_expected = days_expected[start_i:end_i+1]
+ start, end = dates[start_i], dates[end_i]
+ days_result = self.checked_class.with_filled_gaps(days_provided,
+ start, end)
+ self.assertEqual(days_result, days_expected)
+
+ # for provided Days we use those from days_with_comment, to identify
+ # 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
+ expect_within_full_range_as_commented((0, 8), [0, 4, 8])
+ # check limited range, but limiting Days provided
+ expect_within_full_range_as_commented((2, 6), [2, 5, 6])
+ # check Days within range but beyond provided Days also filled in
+ expect_within_full_range_as_commented((1, 7), [2, 5])
+ # check provided Days beyond range ignored
+ expect_within_full_range_as_commented((3, 5), [1, 2, 4, 6, 7])
+ # check inversion of start_date and end_date returns empty list
+ expect_within_full_range_as_commented((5, 3), [2, 4, 6])
+ # check empty provision still creates filler elements in interval
+ expect_within_full_range_as_commented((3, 5), [])
+ # check single-element selection creating only filler beyond provided
+ expect_within_full_range_as_commented((1, 1), [2, 4, 6])
+ # check (un-saved) filler Days don't show up in cache or DB
+ 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])
+
+
+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."""
- 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."""
# check illegal date range delimiters
self.check_get('/calendar?start=foo', 400)
self.check_get('/calendar?end=foo', 400)
- # check default range without saved days
- expected = self.GET_calendar_dict(-1, 366)
- self.check_json_get('/calendar', expected)
- self.check_json_get('/calendar?start=&end=', expected)
- # check named days as delimiters
- expected = self.GET_calendar_dict(-1, +1)
- self.check_json_get('/calendar?start=yesterday&end=tomorrow', expected)
+ # check default range for expected selection/order without saved days
+ exp = ExpectedGetCalendar(-1, 366)
+ self.check_json_get('/calendar', exp)
+ self.check_json_get('/calendar?start=&end=', exp)
+ # check with named days as delimiters
+ 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)
- # check saved day shows up in results with 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)
+ 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
+ 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 not requiring DB setup."""
checked_class = Process
- versioned_defaults_to_test = {'title': 'UNNAMED', 'description': '',
- 'effort': 1.0}
class TestsSansDBProcessStep(TestCaseSansDB):
"""Module tests not requiring DB setup."""
checked_class = ProcessStep
- default_init_args = [2, 3, 4]
+ default_init_kwargs = {'owner_id': 2, 'step_process_id': 3,
+ 'parent_step_id': 4}
class TestsWithDB(TestCaseWithDB):
"""Module tests requiring DB setup."""
checked_class = Process
- test_versioneds = {'title': str, 'description': str, 'effort': float}
-
- 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."""
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):
"""Tests requiring DB, but not server setup.
- NB: We subclass TestCaseSansDB too, to pull in its .test_id_validation,
- which for Todo wouldn't run without a DB being set up due to the need for
- Processes with set IDs.
+ NB: We subclass TestCaseSansDB too, to run any tests there that due to any
+ Todo requiring a _saved_ Process wouldn't run without a DB.
"""
checked_class = Todo
default_init_kwargs = {'process': None, 'is_done': False,
'date': '2024-01-01'}
- # solely used for TestCaseSansDB.test_id_setting
- default_init_args = [None, False, '2024-01-01']
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
- self.default_init_args[0] = 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'), [])
- 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_by_date_range_with_limits(self) -> None:
+ """Test .by_date_range_with_limits."""
+ self.check_by_date_range_with_limits('day')
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, '/')
+ 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 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_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 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_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 test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
- """Test multiple Todos can be posted to Day view w. inner adoption."""
-
- def key_order_func(t: Todo) -> int:
- assert isinstance(t.process.id_, int)
- return t.process.id_
-
- 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 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 threading import Thread
from http.client import HTTPConnection
-from json import loads as json_loads
+from datetime import datetime, timedelta
+from time import sleep
+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
from plomtask.conditions import Condition
from plomtask.days import Day
+from plomtask.dating import DATE_FORMAT
from plomtask.todos import Todo
+from plomtask.versioned_attributes import VersionedAttribute, TIMESTAMP_FMT
from plomtask.exceptions import NotFoundException, HandledException
-def _within_checked_class(f: Callable[..., None]) -> Callable[..., None]:
- def wrapper(self: TestCase) -> None:
- if hasattr(self, 'checked_class'):
- f(self)
- return wrapper
+VERSIONED_VALS: dict[str,
+ list[str] | list[float]] = {'str': ['A', 'B'],
+ 'float': [0.3, 1.1]}
+VALID_TRUES = {True, 'True', 'true', '1', 'on'}
-class TestCaseSansDB(TestCase):
- """Tests requiring no DB setup."""
+class TestCaseAugmented(TestCase):
+ """Tester core providing helpful basic internal decorators and methods."""
checked_class: Any
- default_init_args: list[Any] = []
- versioned_defaults_to_test: dict[str, str | float] = {}
- legal_ids = [1, 5]
- illegal_ids = [0]
+ default_init_kwargs: dict[str, Any] = {}
+
+ @staticmethod
+ def _run_on_versioned_attributes(f: Callable[..., None]
+ ) -> Callable[..., None]:
+ def wrapper(self: TestCase) -> None:
+ assert isinstance(self, TestCaseAugmented)
+ for attr_name in self.checked_class.to_save_versioned():
+ default = self.checked_class.versioned_defaults[attr_name]
+ owner = self.checked_class(None, **self.default_init_kwargs)
+ attr = getattr(owner, attr_name)
+ to_set = VERSIONED_VALS[attr.value_type_name]
+ 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)
- @_within_checked_class
+
+class TestCaseSansDB(TestCaseAugmented):
+ """Tests requiring no DB setup."""
+ legal_ids: list[str] | list[int] = [1, 5]
+ illegal_ids: list[str] | list[int] = [0]
+
+ @TestCaseAugmented._run_if_sans_db
def test_id_validation(self) -> None:
"""Test .id_ validation/setting."""
for id_ in self.illegal_ids:
with self.assertRaises(HandledException):
- self.checked_class(id_, *self.default_init_args)
+ self._make_from_defaults(id_)
for id_ in self.legal_ids:
- obj = self.checked_class(id_, *self.default_init_args)
+ obj = self._make_from_defaults(id_)
self.assertEqual(obj.id_, id_)
- @_within_checked_class
- def test_versioned_defaults(self) -> None:
- """Test defaults of VersionedAttributes."""
- id_ = self.legal_ids[0]
- obj = self.checked_class(id_, *self.default_init_args)
- for k, v in self.versioned_defaults_to_test.items():
- self.assertEqual(getattr(obj, k).newest, v)
+ @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] | list[float]
+ ) -> None:
+ """Test VersionedAttribute.set() behaves as expected."""
+ attr.set(default)
+ self.assertEqual(list(attr.history.values()), [default])
+ # check same value does not get set twice in a row,
+ # and that not even its timestamp get updated
+ timestamp = list(attr.history.keys())[0]
+ attr.set(default)
+ self.assertEqual(list(attr.history.values()), [default])
+ self.assertEqual(list(attr.history.keys())[0], timestamp)
+ # check that different value _will_ be set/added
+ attr.set(to_set[0])
+ timesorted_vals = [attr.history[t] for
+ t in sorted(attr.history.keys())]
+ expected = [default, to_set[0]]
+ self.assertEqual(timesorted_vals, expected)
+ # check that a previously used value can be set if not most recent
+ attr.set(default)
+ timesorted_vals = [attr.history[t] for
+ t in sorted(attr.history.keys())]
+ expected = [default, to_set[0], default]
+ self.assertEqual(timesorted_vals, expected)
+ # again check for same value not being set twice in a row, even for
+ # later items
+ attr.set(to_set[1])
+ timesorted_vals = [attr.history[t] for
+ t in sorted(attr.history.keys())]
+ expected = [default, to_set[0], default, to_set[1]]
+ self.assertEqual(timesorted_vals, expected)
+ 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] | list[float]
+ ) -> None:
+ """Test VersionedAttribute.newest."""
+ # check .newest on empty history returns .default
+ self.assertEqual(attr.newest, default)
+ # check newest element always returned
+ for v in [to_set[0], to_set[1]]:
+ attr.set(v)
+ self.assertEqual(attr.newest, v)
+ # check newest element returned even if also early value
+ 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] | list[float]
+ ) -> None:
+ """Test .at() returns values nearest to queried time, or default."""
+ # check .at() return default on empty history
+ timestamp_a = datetime.now().strftime(TIMESTAMP_FMT)
+ self.assertEqual(attr.at(timestamp_a), default)
+ # check value exactly at timestamp returned
+ attr.set(to_set[0])
+ timestamp_b = list(attr.history.keys())[0]
+ self.assertEqual(attr.at(timestamp_b), to_set[0])
+ # check earliest value returned if exists, rather than default
+ self.assertEqual(attr.at(timestamp_a), to_set[0])
+ # check reverts to previous value for timestamps not indexed
+ sleep(0.00001)
+ timestamp_between = datetime.now().strftime(TIMESTAMP_FMT)
+ sleep(0.00001)
+ attr.set(to_set[1])
+ timestamp_c = sorted(attr.history.keys())[-1]
+ self.assertEqual(attr.at(timestamp_c), to_set[1])
+ self.assertEqual(attr.at(timestamp_between), to_set[0])
+ sleep(0.00001)
+ timestamp_after_c = datetime.now().strftime(TIMESTAMP_FMT)
+ self.assertEqual(attr.at(timestamp_after_c), to_set[1])
-class TestCaseWithDB(TestCase):
+
+class TestCaseWithDB(TestCaseAugmented):
"""Module tests not requiring DB setup."""
- checked_class: Any
- default_ids: tuple[int | str, int | str, int | str] = (1, 2, 3)
- default_init_kwargs: dict[str, Any] = {}
- test_versioneds: dict[str, type] = {}
+ default_ids: tuple[int, int, int] | tuple[str, str, str] = (1, 2, 3)
def setUp(self) -> None:
Condition.empty_cache()
return db_found
def _change_obj(self, obj: object) -> str:
- attr_name: str = self.checked_class.to_save[-1]
+ attr_name: str = self.checked_class.to_save_simples[-1]
attr = getattr(obj, attr_name)
new_attr: str | int | float | bool
if isinstance(attr, (int, float)):
hashes_db_found = [hash(x) for x in db_found]
self.assertEqual(sorted(hashes_content), sorted(hashes_db_found))
- @_within_checked_class
- def test_saving_versioned(self) -> None:
+ def check_by_date_range_with_limits(self,
+ date_col: str,
+ set_id_field: bool = True
+ ) -> None:
+ """Test .by_date_range_with_limits."""
+ # pylint: disable=too-many-locals
+ f = self.checked_class.by_date_range_with_limits
+ # check illegal ranges
+ legal_range = ('yesterday', 'tomorrow')
+ for i in [0, 1]:
+ for bad_date in ['foo', '2024-02-30', '2024-01-01 12:00:00']:
+ date_range = list(legal_range[:])
+ date_range[i] = bad_date
+ with self.assertRaises(HandledException):
+ f(self.db_conn, date_range, date_col)
+ # check empty, translation of 'yesterday' and 'tomorrow'
+ items, start, end = f(self.db_conn, legal_range, date_col)
+ self.assertEqual(items, [])
+ yesterday = datetime.now() + timedelta(days=-1)
+ tomorrow = datetime.now() + timedelta(days=+1)
+ self.assertEqual(start, yesterday.strftime(DATE_FORMAT))
+ self.assertEqual(end, tomorrow.strftime(DATE_FORMAT))
+ # prepare dated items for non-empty results
+ kwargs_with_date = self.default_init_kwargs.copy()
+ if set_id_field:
+ kwargs_with_date['id_'] = None
+ objs = []
+ dates = ['2024-01-01', '2024-01-02', '2024-01-04']
+ for date in ['2024-01-01', '2024-01-02', '2024-01-04']:
+ kwargs_with_date['date'] = date
+ obj = self.checked_class(**kwargs_with_date)
+ objs += [obj]
+ # check ranges still empty before saving
+ date_range = [dates[0], dates[-1]]
+ self.assertEqual(f(self.db_conn, date_range, date_col)[0], [])
+ # check all objs displayed within closed interval
+ for obj in objs:
+ obj.save(self.db_conn)
+ self.assertEqual(f(self.db_conn, date_range, date_col)[0], objs)
+ # check that only displayed what exists within interval
+ date_range = ['2023-12-20', '2024-01-03']
+ expected = [objs[0], objs[1]]
+ self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected)
+ date_range = ['2024-01-03', '2024-01-30']
+ expected = [objs[2]]
+ self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected)
+ # check that inverted interval displays nothing
+ date_range = [dates[-1], dates[0]]
+ self.assertEqual(f(self.db_conn, date_range, date_col)[0], [])
+ # check that "today" is interpreted, and single-element interval
+ today_date = datetime.now().strftime(DATE_FORMAT)
+ kwargs_with_date['date'] = today_date
+ obj_today = self.checked_class(**kwargs_with_date)
+ obj_today.save(self.db_conn)
+ date_range = ['today', 'today']
+ items, start, end = f(self.db_conn, date_range, date_col)
+ self.assertEqual(start, today_date)
+ 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] | list[float]
+ ) -> None:
"""Test storage and initialization of versioned attributes."""
- def retrieve_attr_vals() -> list[object]:
+
+ def retrieve_attr_vals(attr: VersionedAttribute) -> list[object]:
attr_vals_saved: list[object] = []
- assert hasattr(retrieved, 'id_')
for row in self.db_conn.row_where(attr.table_name, 'parent',
- retrieved.id_):
+ owner.id_):
attr_vals_saved += [row[2]]
return attr_vals_saved
- for attr_name, type_ in self.test_versioneds.items():
- # fail saving attributes on non-saved owner
- owner = self.checked_class(None, **self.default_init_kwargs)
- vals: list[Any] = ['t1', 't2'] if type_ == str else [0.9, 1.1]
- attr = getattr(owner, attr_name)
- attr.set(vals[0])
- attr.set(vals[1])
- with self.assertRaises(NotFoundException):
- attr.save(self.db_conn)
- owner.save(self.db_conn)
- # check stored attribute is as expected
- retrieved = self._load_from_db(owner.id_)[0]
- attr = getattr(retrieved, attr_name)
- self.assertEqual(sorted(attr.history.values()), vals)
- # check owner.save() created entries in attr table
- attr_vals_saved = retrieve_attr_vals()
- self.assertEqual(vals, attr_vals_saved)
- # check setting new val to attr inconsequential to DB without save
- attr.set(vals[0])
- attr_vals_saved = retrieve_attr_vals()
- self.assertEqual(vals, attr_vals_saved)
- # check save finally adds new val
+
+ attr.set(to_set[0])
+ # check that without attr.save() no rows in DB
+ rows = self.db_conn.row_where(attr.table_name, 'parent', owner.id_)
+ self.assertEqual([], rows)
+ # fail saving attributes on non-saved owner
+ with self.assertRaises(NotFoundException):
attr.save(self.db_conn)
- attr_vals_saved = retrieve_attr_vals()
- self.assertEqual(vals + [vals[0]], attr_vals_saved)
+ # check owner.save() created entries as expected in attr table
+ owner.save(self.db_conn)
+ attr_vals_saved = retrieve_attr_vals(attr)
+ self.assertEqual([to_set[0]], attr_vals_saved)
+ # check changing attr val without save affects owner in memory …
+ attr.set(to_set[1])
+ cmp_attr = getattr(owner, attr_name)
+ self.assertEqual(to_set, list(cmp_attr.history.values()))
+ self.assertEqual(cmp_attr.history, attr.history)
+ # … but does not yet affect DB
+ attr_vals_saved = retrieve_attr_vals(attr)
+ self.assertEqual([to_set[0]], attr_vals_saved)
+ # check individual attr.save also stores new val to DB
+ attr.save(self.db_conn)
+ attr_vals_saved = retrieve_attr_vals(attr)
+ self.assertEqual(to_set, attr_vals_saved)
- @_within_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]
# check failure to cache without ID (if None-ID input possible)
if isinstance(id1, int):
- obj0 = self.checked_class(None, **self.default_init_kwargs)
+ obj0 = self._make_from_defaults(None)
with self.assertRaises(HandledException):
obj0.cache()
# check mere object init itself doesn't even store in cache
- obj1 = self.checked_class(id1, **self.default_init_kwargs)
+ obj1 = self._make_from_defaults(id1)
self.assertEqual(self.checked_class.get_cache(), {})
# check .cache() fills cache, but not DB
obj1.cache()
self.assertEqual(self.checked_class.get_cache(), {id1: obj1})
- db_found = self._load_from_db(id1)
- self.assertEqual(db_found, [])
+ found_in_db = self._load_from_db(id1)
+ self.assertEqual(found_in_db, [])
# check .save() sets ID (for int IDs), updates cache, and fills DB
# (expect ID to be set to id1, despite obj1 already having that as ID:
# it's generated by cursor.lastrowid on the DB table, and with obj1
# not written there, obj2 should get it first!)
id_input = None if isinstance(id1, int) else id1
- obj2 = self.checked_class(id_input, **self.default_init_kwargs)
+ obj2 = self._make_from_defaults(id_input)
obj2.save(self.db_conn)
- obj2_hash = hash(obj2)
self.assertEqual(self.checked_class.get_cache(), {id1: obj2})
- db_found += self._load_from_db(id1)
- self.assertEqual([hash(o) for o in db_found], [obj2_hash])
+ # NB: we'll only compare hashes because obj2 itself disappears on
+ # .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])
# check we cannot overwrite obj2 with obj1 despite its same ID,
# since it has disappeared now
with self.assertRaises(HandledException):
obj1.save(self.db_conn)
- @_within_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_by_id(self) -> None:
"""Test .by_id()."""
id1, id2, _ = self.default_ids
# check failure if not yet saved
- obj1 = self.checked_class(id1, **self.default_init_kwargs)
+ obj1 = self._make_from_defaults(id1)
with self.assertRaises(NotFoundException):
self.checked_class.by_id(self.db_conn, id1)
# check identity of cached and retrieved
obj1.cache()
self.assertEqual(obj1, self.checked_class.by_id(self.db_conn, id1))
# check identity of saved and retrieved
- obj2 = self.checked_class(id2, **self.default_init_kwargs)
+ obj2 = self._make_from_defaults(id2)
obj2.save(self.db_conn)
self.assertEqual(obj2, self.checked_class.by_id(self.db_conn, id2))
- @_within_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)
- @_within_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]
- obj = self.checked_class(id_, **self.default_init_kwargs)
+ obj = self._make_from_defaults(id_)
obj.save(self.db_conn)
assert isinstance(obj.id_, type(id_))
for row in self.db_conn.row_where(self.checked_class.table_name,
'id', obj.id_):
# check .from_table_row reproduces state saved, no matter if obj
# later changed (with caching even)
+ # NB: we'll only compare hashes because obj itself disappears on
+ # .from_table_row-triggered database reload
hash_original = hash(obj)
attr_name = self._change_obj(obj)
obj.cache()
# check cache contains what .from_table_row just produced
self.assertEqual({retrieved.id_: retrieved},
self.checked_class.get_cache())
- # check .from_table_row also reads versioned attributes from DB
- for attr_name, type_ in self.test_versioneds.items():
- owner = self.checked_class(None)
- vals: list[Any] = ['t1', 't2'] if type_ == str else [0.9, 1.1]
- attr = getattr(owner, attr_name)
- attr.set(vals[0])
- attr.set(vals[1])
- owner.save(self.db_conn)
- for row in self.db_conn.row_where(owner.table_name, 'id',
+
+ @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] | list[float]
+ ) -> None:
+ """"Test VersionedAttribute.history_from_row() knows its DB rows."""
+ attr.set(to_set[0])
+ attr.set(to_set[1])
+ owner.save(self.db_conn)
+ # make empty VersionedAttribute, fill from rows, compare to owner's
+ for row in self.db_conn.row_where(owner.table_name, 'id', owner.id_):
+ loaded_attr = VersionedAttribute(owner, attr.table_name, default)
+ for row in self.db_conn.row_where(attr.table_name, 'parent',
owner.id_):
- retrieved = owner.__class__.from_table_row(self.db_conn, row)
- attr = getattr(retrieved, attr_name)
- self.assertEqual(sorted(attr.history.values()), vals)
+ loaded_attr.history_from_row(row)
+ self.assertEqual(len(attr.history.keys()),
+ len(loaded_attr.history.keys()))
+ for timestamp, value in attr.history.items():
+ self.assertEqual(value, loaded_attr.history[timestamp])
- @_within_checked_class
+ @TestCaseAugmented._run_if_with_db_but_not_server
def test_all(self) -> None:
"""Test .all() and its relation to cache and savings."""
- id_1, id_2, id_3 = self.default_ids
- item1 = self.checked_class(id_1, **self.default_init_kwargs)
- item2 = self.checked_class(id_2, **self.default_init_kwargs)
- item3 = self.checked_class(id_3, **self.default_init_kwargs)
+ id1, id2, id3 = self.default_ids
+ item1 = self._make_from_defaults(id1)
+ item2 = self._make_from_defaults(id2)
+ item3 = self._make_from_defaults(id3)
# check .all() returns empty list on un-cached items
self.assertEqual(self.checked_class.all(self.db_conn), [])
# check that all() shows only cached/saved items
self.assertEqual(sorted(self.checked_class.all(self.db_conn)),
sorted([item1, item2, item3]))
- @_within_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]
- obj = self.checked_class(id1, **self.default_init_kwargs)
+ obj = self._make_from_defaults(id1)
obj.save(self.db_conn)
# change object, expect retrieved through .by_id to carry change
attr_name = self._change_obj(obj)
retrieved = self.checked_class.by_id(self.db_conn, id1)
self.assertEqual(new_attr, getattr(retrieved, attr_name))
- @_within_checked_class
- def test_versioned_singularity_title(self) -> None:
- """Test singularity of VersionedAttributes on saving (with .title)."""
- if 'title' in self.test_versioneds:
- obj = self.checked_class(None)
- obj.save(self.db_conn)
- assert isinstance(obj.id_, int)
- # change obj, expect retrieved through .by_id to carry change
- obj.title.set('named')
- retrieved = self.checked_class.by_id(self.db_conn, obj.id_)
- self.assertEqual(obj.title.history, retrieved.title.history)
+ @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] | list[float]
+ ) -> None:
+ """Test singularity of VersionedAttributes on saving."""
+ owner.save(self.db_conn)
+ # change obj, expect retrieved through .by_id to carry change
+ attr.set(to_set[0])
+ retrieved = self.checked_class.by_id(self.db_conn, owner.id_)
+ attr_retrieved = getattr(retrieved, attr_name)
+ self.assertEqual(attr.history, attr_retrieved.history)
- @_within_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]
- obj = self.checked_class(id_, **self.default_init_kwargs)
+ obj = self._make_from_defaults(id_)
# check removal only works after saving
with self.assertRaises(HandledException):
obj.remove(self.db_conn)
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, redirect_location: str = '') -> None:
+ expected_code: int = 302, redir: str = '') -> None:
"""Check that POST of data to target yields expected_code."""
encoded_form_data = urlencode(data, doseq=True).encode('utf-8')
headers = {'Content-Type': 'application/x-www-form-urlencoded',
self.conn.request('POST', target,
body=encoded_form_data, headers=headers)
if 302 == expected_code:
- if redirect_location == '':
- redirect_location = target
- self.check_redirect(redirect_location)
+ redir = target if redir == '' else redir
+ self.check_redirect(redir)
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_}', 302,
- f'/process?id={id_}')
- return form_data
-
- def check_json_get(self, path: str, expected: dict[str, object]) -> None:
+ 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: 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
+++ /dev/null
-""""Test Versioned Attributes in the abstract."""
-from unittest import TestCase
-from time import sleep
-from datetime import datetime
-from tests.utils import TestCaseWithDB
-from plomtask.versioned_attributes import VersionedAttribute, TIMESTAMP_FMT
-from plomtask.db import BaseModel
-
-SQL_TEST_TABLE_STR = '''
-CREATE TABLE versioned_tests (
- parent INTEGER NOT NULL,
- timestamp TEXT NOT NULL,
- value TEXT NOT NULL,
- PRIMARY KEY (parent, timestamp)
-);
-'''
-SQL_TEST_TABLE_FLOAT = '''
-CREATE TABLE versioned_tests (
- parent INTEGER NOT NULL,
- timestamp TEXT NOT NULL,
- value REAL NOT NULL,
- PRIMARY KEY (parent, timestamp)
-);
-'''
-
-
-class TestParentType(BaseModel[int]):
- """Dummy abstracting whatever may use VersionedAttributes."""
-
-
-class TestsSansDB(TestCase):
- """Tests not requiring DB setup."""
-
- def test_VersionedAttribute_set(self) -> None:
- """Test .set() behaves as expected."""
- # check value gets set even if already is the default
- attr = VersionedAttribute(None, '', 'A')
- attr.set('A')
- self.assertEqual(list(attr.history.values()), ['A'])
- # check same value does not get set twice in a row,
- # and that not even its timestamp get updated
- timestamp = list(attr.history.keys())[0]
- attr.set('A')
- self.assertEqual(list(attr.history.values()), ['A'])
- self.assertEqual(list(attr.history.keys())[0], timestamp)
- # check that different value _will_ be set/added
- attr.set('B')
- self.assertEqual(sorted(attr.history.values()), ['A', 'B'])
- # check that a previously used value can be set if not most recent
- attr.set('A')
- self.assertEqual(sorted(attr.history.values()), ['A', 'A', 'B'])
- # again check for same value not being set twice in a row, even for
- # later items
- attr.set('D')
- self.assertEqual(sorted(attr.history.values()), ['A', 'A', 'B', 'D'])
- attr.set('D')
- self.assertEqual(sorted(attr.history.values()), ['A', 'A', 'B', 'D'])
-
- def test_VersionedAttribute_newest(self) -> None:
- """Test .newest returns newest element, or default on empty."""
- attr = VersionedAttribute(None, '', 'A')
- self.assertEqual(attr.newest, 'A')
- attr.set('B')
- self.assertEqual(attr.newest, 'B')
- attr.set('C')
-
- def test_VersionedAttribute_at(self) -> None:
- """Test .at() returns values nearest to queried time, or default."""
- # check .at() return default on empty history
- attr = VersionedAttribute(None, '', 'A')
- timestamp_a = datetime.now().strftime(TIMESTAMP_FMT)
- self.assertEqual(attr.at(timestamp_a), 'A')
- # check value exactly at timestamp returned
- attr.set('B')
- timestamp_b = list(attr.history.keys())[0]
- self.assertEqual(attr.at(timestamp_b), 'B')
- # check earliest value returned if exists, rather than default
- self.assertEqual(attr.at(timestamp_a), 'B')
- # check reverts to previous value for timestamps not indexed
- sleep(0.00001)
- timestamp_between = datetime.now().strftime(TIMESTAMP_FMT)
- sleep(0.00001)
- attr.set('C')
- timestamp_c = sorted(attr.history.keys())[-1]
- self.assertEqual(attr.at(timestamp_c), 'C')
- self.assertEqual(attr.at(timestamp_between), 'B')
- sleep(0.00001)
- timestamp_after_c = datetime.now().strftime(TIMESTAMP_FMT)
- self.assertEqual(attr.at(timestamp_after_c), 'C')
-
-
-class TestsWithDBStr(TestCaseWithDB):
- """Module tests requiring DB setup."""
- default_vals: list[str | float] = ['A', 'B', 'C']
- init_sql = SQL_TEST_TABLE_STR
-
- def setUp(self) -> None:
- super().setUp()
- self.db_conn.exec(self.init_sql)
- self.test_parent = TestParentType(1)
- self.attr = VersionedAttribute(self.test_parent,
- 'versioned_tests', self.default_vals[0])
-
- def test_VersionedAttribute_save(self) -> None:
- """Test .save() to write to DB."""
- # check mere .set() calls do not by themselves reflect in the DB
- self.attr.set(self.default_vals[1])
- self.assertEqual([],
- self.db_conn.row_where('versioned_tests',
- 'parent', 1))
- # check .save() makes history appear in DB
- self.attr.save(self.db_conn)
- vals_found = []
- for row in self.db_conn.row_where('versioned_tests', 'parent', 1):
- vals_found += [row[2]]
- self.assertEqual([self.default_vals[1]], vals_found)
- # check .save() also updates history in DB
- self.attr.set(self.default_vals[2])
- self.attr.save(self.db_conn)
- vals_found = []
- for row in self.db_conn.row_where('versioned_tests', 'parent', 1):
- vals_found += [row[2]]
- self.assertEqual([self.default_vals[1], self.default_vals[2]],
- sorted(vals_found))
-
- def test_VersionedAttribute_history_from_row(self) -> None:
- """"Test .history_from_row() properly interprets DB rows."""
- self.attr.set(self.default_vals[1])
- self.attr.set(self.default_vals[2])
- self.attr.save(self.db_conn)
- loaded_attr = VersionedAttribute(self.test_parent, 'versioned_tests',
- self.default_vals[0])
- for row in self.db_conn.row_where('versioned_tests', 'parent', 1):
- loaded_attr.history_from_row(row)
- for timestamp, value in self.attr.history.items():
- self.assertEqual(value, loaded_attr.history[timestamp])
- self.assertEqual(len(self.attr.history.keys()),
- len(loaded_attr.history.keys()))
-
-
-class TestsWithDBFloat(TestsWithDBStr):
- """Module tests requiring DB setup."""
- default_vals: list[str | float] = [0.9, 1.1, 2]
- init_sql = SQL_TEST_TABLE_FLOAT