From 34741b65438149b4e02f1e2bb4f8fdba5df5a667 Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Tue, 16 Apr 2024 03:08:54 +0200 Subject: [PATCH] Add Conditions for Todos/Processes to be met or undone by other Todos. --- plomtask/conditions.py | 74 ++++++++++++++++++++ plomtask/db.py | 1 + plomtask/http.py | 58 ++++++++++++++-- plomtask/misc.py | 53 +++++++++++++++ plomtask/processes.py | 138 +++++++++++++++++++------------------- plomtask/todos.py | 95 +++++++++++++++++++++++++- scripts/init.sql | 78 ++++++++++++++++++--- templates/base.html | 1 + templates/condition.html | 10 +++ templates/conditions.html | 12 ++++ templates/day.html | 17 +++++ templates/process.html | 62 ++++++++++++++--- templates/todo.html | 79 ++++++++++++++++++++++ tests/conditions.py | 55 +++++++++++++++ tests/processes.py | 70 +++++++++++++++++++ tests/todos.py | 121 +++++++++++++++++++++++++++++++++ 16 files changed, 829 insertions(+), 95 deletions(-) create mode 100644 plomtask/conditions.py create mode 100644 plomtask/misc.py create mode 100644 templates/condition.html create mode 100644 templates/conditions.html create mode 100644 templates/todo.html create mode 100644 tests/conditions.py diff --git a/plomtask/conditions.py b/plomtask/conditions.py new file mode 100644 index 0000000..5c57d85 --- /dev/null +++ b/plomtask/conditions.py @@ -0,0 +1,74 @@ +"""Non-doable elements of ProcessStep/Todo chains.""" +from __future__ import annotations +from sqlite3 import Row +from plomtask.db import DatabaseConnection +from plomtask.misc import VersionedAttribute +from plomtask.exceptions import BadFormatException, NotFoundException + + +class Condition: + """Non Process-dependency for ProcessSteps and Todos.""" + + def __init__(self, id_: int | None, is_active: bool = False) -> None: + if (id_ is not None) and id_ < 1: + msg = f'illegal Condition ID, must be >=1: {id_}' + raise BadFormatException(msg) + self.id_ = id_ + self.is_active = is_active + self.title = VersionedAttribute(self, 'condition_titles', 'UNNAMED') + self.description = VersionedAttribute(self, 'condition_descriptions', + '') + + @classmethod + def from_table_row(cls, db_conn: DatabaseConnection, + row: Row) -> Condition: + """Build condition from row, including VersionedAttributes.""" + condition = cls(row[0], row[1]) + for title_row in db_conn.exec('SELECT * FROM condition_titles ' + 'WHERE parent_id = ?', (row[0],)): + condition.title.history[title_row[1]] = title_row[2] + for desc_row in db_conn.exec('SELECT * FROM condition_descriptions ' + 'WHERE parent_id = ?', (row[0],)): + condition.description.history[desc_row[1]] = desc_row[2] + return condition + + @classmethod + def all(cls, db_conn: DatabaseConnection) -> list[Condition]: + """Collect all Conditions and their VersionedAttributes.""" + conditions = {} + for id_, condition in db_conn.cached_conditions.items(): + conditions[id_] = condition + already_recorded = conditions.keys() + for row in db_conn.exec('SELECT id FROM conditions'): + if row[0] not in already_recorded: + condition = cls.by_id(db_conn, row[0]) + conditions[condition.id_] = condition + return list(conditions.values()) + + @classmethod + def by_id(cls, db_conn: DatabaseConnection, id_: int | None, + create: bool = False) -> Condition: + """Collect (or create) Condition and its VersionedAttributes.""" + condition = None + if id_ in db_conn.cached_conditions.keys(): + condition = db_conn.cached_conditions[id_] + else: + for row in db_conn.exec('SELECT * FROM conditions WHERE id = ?', + (id_,)): + condition = cls.from_table_row(db_conn, row) + break + if not condition: + if not create: + raise NotFoundException(f'Condition not found of id: {id_}') + condition = cls(id_, False) + return condition + + def save(self, db_conn: DatabaseConnection) -> None: + """Save self and its VersionedAttributes to DB and cache.""" + cursor = db_conn.exec('REPLACE INTO conditions VALUES (?, ?)', + (self.id_, self.is_active)) + self.id_ = cursor.lastrowid + self.title.save(db_conn) + self.description.save(db_conn) + assert self.id_ is not None + db_conn.cached_conditions[self.id_] = self diff --git a/plomtask/db.py b/plomtask/db.py index 01bc3e9..4a82132 100644 --- a/plomtask/db.py +++ b/plomtask/db.py @@ -53,6 +53,7 @@ class DatabaseConnection: self.cached_days: Dict[str, Any] = {} self.cached_process_steps: Dict[int, Any] = {} self.cached_processes: Dict[int, Any] = {} + self.cached_conditions: Dict[int, Any] = {} def commit(self) -> None: """Commit SQL transaction.""" diff --git a/plomtask/http.py b/plomtask/http.py index 5d165ec..b00ebeb 100644 --- a/plomtask/http.py +++ b/plomtask/http.py @@ -10,6 +10,7 @@ from plomtask.exceptions import HandledException, BadFormatException, \ NotFoundException from plomtask.db import DatabaseConnection, DatabaseFile from plomtask.processes import Process +from plomtask.conditions import Condition from plomtask.todos import Todo TEMPLATES_DIR = 'templates' @@ -111,7 +112,8 @@ class TaskHandler(BaseHTTPRequestHandler): """Handle any GET request.""" try: conn, site, params = self._init_handling() - if site in {'calendar', 'day', 'process', 'processes', 'todo'}: + if site in {'calendar', 'day', 'process', 'processes', 'todo', + 'condition', 'conditions'}: html = getattr(self, f'do_GET_{site}')(conn, params) elif '' == site: self._redirect('/day') @@ -139,17 +141,41 @@ class TaskHandler(BaseHTTPRequestHandler): date = params.get_str('date', todays_date()) day = Day.by_date(conn, date, create=True) todos = Todo.by_date(conn, date) + conditions_listing = [] + for condition in Condition.all(conn): + enablers = Todo.enablers_for_at(conn, condition, date) + disablers = Todo.disablers_for_at(conn, condition, date) + conditions_listing += [{ + 'condition': condition, + 'enablers': enablers, + 'disablers': disablers}] return self.server.jinja.get_template('day.html').render( - day=day, processes=Process.all(conn), todos=todos) + day=day, processes=Process.all(conn), todos=todos, + conditions_listing=conditions_listing) def do_GET_todo(self, conn: DatabaseConnection, params: ParamsParser) -> str: """Show single Todo of ?id=.""" id_ = params.get_int_or_none('id') todo = Todo.by_id(conn, id_) - candidates = Todo.by_date(conn, todo.day.date) + todo_candidates = Todo.by_date(conn, todo.day.date) return self.server.jinja.get_template('todo.html').render( - todo=todo, candidates=candidates) + todo=todo, todo_candidates=todo_candidates, + condition_candidates=Condition.all(conn)) + + def do_GET_conditions(self, conn: DatabaseConnection, + _: ParamsParser) -> str: + """Show all Conditions.""" + return self.server.jinja.get_template('conditions.html').render( + conditions=Condition.all(conn)) + + def do_GET_condition(self, conn: DatabaseConnection, + params: ParamsParser) -> str: + """Show Condition of ?id=.""" + id_ = params.get_int_or_none('id') + condition = Condition.by_id(conn, id_, create=True) + return self.server.jinja.get_template('condition.html').render( + condition=condition) def do_GET_process(self, conn: DatabaseConnection, params: ParamsParser) -> str: @@ -159,7 +185,8 @@ class TaskHandler(BaseHTTPRequestHandler): owners = process.used_as_step_by(conn) return self.server.jinja.get_template('process.html').render( process=process, steps=process.get_steps(conn), - owners=owners, candidates=Process.all(conn)) + owners=owners, process_candidates=Process.all(conn), + condition_candidates=Condition.all(conn)) def do_GET_processes(self, conn: DatabaseConnection, _: ParamsParser) -> str: @@ -175,7 +202,7 @@ class TaskHandler(BaseHTTPRequestHandler): postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=True, strict_parsing=True) form_data = PostvarsParser(postvars) - if site in ('day', 'process', 'todo'): + if site in ('day', 'process', 'todo', 'condition'): getattr(self, f'do_POST_{site}')(conn, params, form_data) conn.commit() else: @@ -209,8 +236,15 @@ class TaskHandler(BaseHTTPRequestHandler): if child_id is not None: child = Todo.by_id(conn, child_id) todo.add_child(child) + todo.set_conditions(conn, form_data.get_all_int('condition')) + todo.set_fulfills(conn, form_data.get_all_int('fulfills')) + todo.set_undoes(conn, form_data.get_all_int('undoes')) todo.is_done = len(form_data.get_all_str('done')) > 0 todo.save(conn) + for condition in todo.fulfills: + condition.save(conn) + for condition in todo.undoes: + condition.save(conn) def do_POST_process(self, conn: DatabaseConnection, params: ParamsParser, form_data: PostvarsParser) -> None: @@ -220,6 +254,9 @@ class TaskHandler(BaseHTTPRequestHandler): process.title.set(form_data.get_str('title')) process.description.set(form_data.get_str('description')) process.effort.set(form_data.get_float('effort')) + process.set_conditions(conn, form_data.get_all_int('condition')) + process.set_fulfills(conn, form_data.get_all_int('fulfills')) + process.set_undoes(conn, form_data.get_all_int('undoes')) process.save_without_steps(conn) assert process.id_ is not None # for mypy process.explicit_steps = [] @@ -236,6 +273,15 @@ class TaskHandler(BaseHTTPRequestHandler): process.add_step(conn, None, step_process_id, None) process.fix_steps(conn) + def do_POST_condition(self, conn: DatabaseConnection, params: ParamsParser, + form_data: PostvarsParser) -> None: + """Update/insert Condition of ?id= and fields defined in postvars.""" + id_ = params.get_int_or_none('id') + condition = Condition.by_id(conn, id_, create=True) + condition.title.set(form_data.get_str('title')) + condition.description.set(form_data.get_str('description')) + condition.save(conn) + def _init_handling(self) -> tuple[DatabaseConnection, str, ParamsParser]: conn = DatabaseConnection(self.server.db) parsed_url = urlparse(self.path) diff --git a/plomtask/misc.py b/plomtask/misc.py new file mode 100644 index 0000000..bf07188 --- /dev/null +++ b/plomtask/misc.py @@ -0,0 +1,53 @@ +"""Attributes whose values are recorded as a timestamped history.""" +from datetime import datetime +from typing import Any +from plomtask.db import DatabaseConnection + + +class VersionedAttribute: + """Attributes whose values are recorded as a timestamped history.""" + + def __init__(self, + parent: Any, table_name: str, default: str | float) -> None: + self.parent = parent + self.table_name = table_name + self.default = default + self.history: dict[str, str | float] = {} + + @property + def _newest_timestamp(self) -> str: + """Return most recent timestamp.""" + return sorted(self.history.keys())[-1] + + @property + def newest(self) -> str | float: + """Return most recent value, or self.default if self.history empty.""" + if 0 == len(self.history): + return self.default + return self.history[self._newest_timestamp] + + def set(self, value: str | float) -> None: + """Add to self.history if and only if not same value as newest one.""" + if 0 == len(self.history) \ + or value != self.history[self._newest_timestamp]: + self.history[datetime.now().strftime('%Y-%m-%d %H:%M:%S')] = value + + def at(self, queried_time: str) -> str | float: + """Retrieve value of timestamp nearest queried_time from the past.""" + sorted_timestamps = sorted(self.history.keys()) + if 0 == len(sorted_timestamps): + return self.default + selected_timestamp = sorted_timestamps[0] + for timestamp in sorted_timestamps[1:]: + if timestamp > queried_time: + break + selected_timestamp = timestamp + return self.history[selected_timestamp] + + def save(self, db_conn: DatabaseConnection) -> None: + """Save as self.history entries, but first wipe old ones.""" + db_conn.exec(f'DELETE FROM {self.table_name} WHERE parent_id = ?', + (self.parent.id_,)) + for timestamp, value in self.history.items(): + db_conn.exec(f'INSERT INTO {self.table_name} VALUES (?, ?, ?)', + (self.parent.id_, timestamp, value)) diff --git a/plomtask/processes.py b/plomtask/processes.py index fe9bd4a..dc0613f 100644 --- a/plomtask/processes.py +++ b/plomtask/processes.py @@ -1,23 +1,29 @@ """Collecting Processes and Process-related items.""" from __future__ import annotations from sqlite3 import Row -from datetime import datetime from typing import Any, Set from plomtask.db import DatabaseConnection +from plomtask.misc import VersionedAttribute +from plomtask.conditions import Condition from plomtask.exceptions import NotFoundException, BadFormatException class Process: """Template for, and metadata for, Todos, and their arrangements.""" + # pylint: disable=too-many-instance-attributes + def __init__(self, id_: int | None) -> None: if (id_ is not None) and id_ < 1: raise BadFormatException(f'illegal Process ID, must be >=1: {id_}') self.id_ = id_ - self.title = VersionedAttribute(self, 'title', 'UNNAMED') - self.description = VersionedAttribute(self, 'description', '') - self.effort = VersionedAttribute(self, 'effort', 1.0) + self.title = VersionedAttribute(self, 'process_titles', 'UNNAMED') + self.description = VersionedAttribute(self, 'process_descriptions', '') + self.effort = VersionedAttribute(self, 'process_efforts', 1.0) self.explicit_steps: list[ProcessStep] = [] + self.conditions: list[Condition] = [] + self.fulfills: list[Condition] = [] + self.undoes: list[Condition] = [] @classmethod def from_table_row(cls, db_conn: DatabaseConnection, row: Row) -> Process: @@ -57,20 +63,28 @@ class Process: if not create: raise NotFoundException(f'Process not found of id: {id_}') process = Process(id_) - if process: - for row in db_conn.exec('SELECT * FROM process_titles ' - 'WHERE process_id = ?', (process.id_,)): - process.title.history[row[1]] = row[2] - for row in db_conn.exec('SELECT * FROM process_descriptions ' - 'WHERE process_id = ?', (process.id_,)): - process.description.history[row[1]] = row[2] - for row in db_conn.exec('SELECT * FROM process_efforts ' - 'WHERE process_id = ?', (process.id_,)): - process.effort.history[row[1]] = row[2] - for row in db_conn.exec('SELECT * FROM process_steps ' - 'WHERE owner_id = ?', (process.id_,)): - process.explicit_steps += [ProcessStep.from_table_row(db_conn, - row)] + for row in db_conn.exec('SELECT * FROM process_titles ' + 'WHERE parent_id = ?', (process.id_,)): + process.title.history[row[1]] = row[2] + for row in db_conn.exec('SELECT * FROM process_descriptions ' + 'WHERE parent_id = ?', (process.id_,)): + process.description.history[row[1]] = row[2] + for row in db_conn.exec('SELECT * FROM process_efforts ' + 'WHERE parent_id = ?', (process.id_,)): + process.effort.history[row[1]] = row[2] + for row in db_conn.exec('SELECT * FROM process_steps ' + 'WHERE owner_id = ?', (process.id_,)): + process.explicit_steps += [ProcessStep.from_table_row(db_conn, + row)] + for row in db_conn.exec('SELECT condition FROM process_conditions ' + 'WHERE process = ?', (process.id_,)): + process.conditions += [Condition.by_id(db_conn, row[0])] + for row in db_conn.exec('SELECT condition FROM process_fulfills ' + 'WHERE process = ?', (process.id_,)): + process.fulfills += [Condition.by_id(db_conn, row[0])] + for row in db_conn.exec('SELECT condition FROM process_undoes ' + 'WHERE process = ?', (process.id_,)): + process.undoes += [Condition.by_id(db_conn, row[0])] assert isinstance(process, Process) return process @@ -87,12 +101,12 @@ class Process: """Return tree of depended-on explicit and implicit ProcessSteps.""" def make_node(step: ProcessStep) -> dict[str, object]: - step_process = self.__class__.by_id(db_conn, step.step_process_id) is_explicit = False if external_owner is not None: is_explicit = step.owner_id == external_owner.id_ - step_steps = step_process.get_steps(db_conn, external_owner) - return {'process': step_process, 'parent_id': step.parent_step_id, + process = self.__class__.by_id(db_conn, step.step_process_id) + step_steps = process.get_steps(db_conn, external_owner) + return {'process': process, 'parent_id': step.parent_step_id, 'is_explicit': is_explicit, 'steps': step_steps} def walk_steps(node_id: int, node: dict[str, Any]) -> None: @@ -117,6 +131,24 @@ class Process: walk_steps(step_id, step_node) return steps + def set_conditions(self, db_conn: DatabaseConnection, ids: list[int], + trgt: str = 'conditions') -> None: + """Set self.[target] to Conditions identified by ids.""" + trgt_list = getattr(self, trgt) + while len(trgt_list) > 0: + trgt_list.pop() + for id_ in ids: + trgt_list += [Condition.by_id(db_conn, id_)] + + def set_fulfills(self, db_conn: DatabaseConnection, + ids: list[int]) -> None: + """Set self.fulfills to Conditions identified by ids.""" + self.set_conditions(db_conn, ids, 'fulfills') + + def set_undoes(self, db_conn: DatabaseConnection, ids: list[int]) -> None: + """Set self.undoes to Conditions identified by ids.""" + self.set_conditions(db_conn, ids, 'undoes') + def add_step(self, db_conn: DatabaseConnection, id_: int | None, step_process_id: int, parent_step_id: int | None) -> ProcessStep: @@ -155,6 +187,21 @@ class Process: self.title.save(db_conn) self.description.save(db_conn) self.effort.save(db_conn) + db_conn.exec('DELETE FROM process_conditions WHERE process = ?', + (self.id_,)) + for condition in self.conditions: + db_conn.exec('INSERT INTO process_conditions VALUES (?,?)', + (self.id_, condition.id_)) + db_conn.exec('DELETE FROM process_fulfills WHERE process = ?', + (self.id_,)) + for condition in self.fulfills: + db_conn.exec('INSERT INTO process_fulfills VALUES (?,?)', + (self.id_, condition.id_)) + db_conn.exec('DELETE FROM process_undoes WHERE process = ?', + (self.id_,)) + for condition in self.undoes: + db_conn.exec('INSERT INTO process_undoes VALUES (?,?)', + (self.id_, condition.id_)) assert self.id_ is not None db_conn.cached_processes[self.id_] = self @@ -218,52 +265,3 @@ class ProcessStep: self.id_ = cursor.lastrowid assert self.id_ is not None db_conn.cached_process_steps[self.id_] = self - - -class VersionedAttribute: - """Attributes whose values are recorded as a timestamped history.""" - - def __init__(self, - parent: Process, name: str, default: str | float) -> None: - self.parent = parent - self.name = name - self.default = default - self.history: dict[str, str | float] = {} - - @property - def _newest_timestamp(self) -> str: - """Return most recent timestamp.""" - return sorted(self.history.keys())[-1] - - @property - def newest(self) -> str | float: - """Return most recent value, or self.default if self.history empty.""" - if 0 == len(self.history): - return self.default - return self.history[self._newest_timestamp] - - def set(self, value: str | float) -> None: - """Add to self.history if and only if not same value as newest one.""" - if 0 == len(self.history) \ - or value != self.history[self._newest_timestamp]: - self.history[datetime.now().strftime('%Y-%m-%d %H:%M:%S')] = value - - def at(self, queried_time: str) -> str | float: - """Retrieve value of timestamp nearest queried_time from the past.""" - sorted_timestamps = sorted(self.history.keys()) - if 0 == len(sorted_timestamps): - return self.default - selected_timestamp = sorted_timestamps[0] - for timestamp in sorted_timestamps[1:]: - if timestamp > queried_time: - break - selected_timestamp = timestamp - return self.history[selected_timestamp] - - def save(self, db_conn: DatabaseConnection) -> None: - """Save as self.history entries, but first wipe old ones.""" - db_conn.exec(f'DELETE FROM process_{self.name}s WHERE process_id = ?', - (self.parent.id_,)) - for timestamp, value in self.history.items(): - db_conn.exec(f'INSERT INTO process_{self.name}s VALUES (?, ?, ?)', - (self.parent.id_, timestamp, value)) diff --git a/plomtask/todos.py b/plomtask/todos.py index 8fa3b91..ce83fad 100644 --- a/plomtask/todos.py +++ b/plomtask/todos.py @@ -4,6 +4,7 @@ from sqlite3 import Row from plomtask.db import DatabaseConnection from plomtask.days import Day from plomtask.processes import Process +from plomtask.conditions import Condition from plomtask.exceptions import (NotFoundException, BadFormatException, HandledException) @@ -11,6 +12,8 @@ from plomtask.exceptions import (NotFoundException, BadFormatException, class Todo: """Individual actionable.""" + # pylint: disable=too-many-instance-attributes + def __init__(self, id_: int | None, process: Process, is_done: bool, day: Day) -> None: self.id_ = id_ @@ -19,6 +22,13 @@ class Todo: self.day = day self.children: list[Todo] = [] self.parents: list[Todo] = [] + self.conditions: list[Condition] = [] + self.fulfills: list[Condition] = [] + self.undoes: list[Condition] = [] + if not self.id_: + self.conditions = process.conditions[:] + self.fulfills = process.fulfills[:] + self.undoes = process.undoes[:] @classmethod def from_table_row(cls, db_conn: DatabaseConnection, row: Row) -> Todo: @@ -50,6 +60,15 @@ class Todo: for row in db_conn.exec('SELECT parent FROM todo_children ' 'WHERE child = ?', (id_,)): todo.parents += [cls.by_id(db_conn, row[0])] + for row in db_conn.exec('SELECT condition FROM todo_conditions ' + 'WHERE todo = ?', (id_,)): + todo.conditions += [Condition.by_id(db_conn, row[0])] + for row in db_conn.exec('SELECT condition FROM todo_fulfills ' + 'WHERE todo = ?', (id_,)): + todo.fulfills += [Condition.by_id(db_conn, row[0])] + for row in db_conn.exec('SELECT condition FROM todo_undoes ' + 'WHERE todo = ?', (id_,)): + todo.undoes += [Condition.by_id(db_conn, row[0])] assert isinstance(todo, Todo) return todo @@ -61,12 +80,39 @@ class Todo: todos += [cls.by_id(db_conn, row[0])] return todos + @classmethod + def enablers_for_at(cls, db_conn: DatabaseConnection, condition: Condition, + date: str) -> list[Todo]: + """Collect all Todos of day that enable condition.""" + enablers = [] + for row in db_conn.exec('SELECT todo FROM todo_fulfills ' + 'WHERE condition = ?', (condition.id_,)): + todo = cls.by_id(db_conn, row[0]) + if todo.day.date == date: + enablers += [todo] + return enablers + + @classmethod + def disablers_for_at(cls, db_conn: DatabaseConnection, + condition: Condition, date: str) -> list[Todo]: + """Collect all Todos of day that disable condition.""" + disablers = [] + for row in db_conn.exec('SELECT todo FROM todo_undoes ' + 'WHERE condition = ?', (condition.id_,)): + todo = cls.by_id(db_conn, row[0]) + if todo.day.date == date: + disablers += [todo] + return disablers + @property def is_doable(self) -> bool: - """Decide whether .is_done can be set to True based on children's.""" + """Decide whether .is_done settable based on children, Conditions.""" for child in self.children: if not child.is_done: return False + for condition in self.conditions: + if not condition.is_active: + return False return True @property @@ -78,7 +124,31 @@ class Todo: def is_done(self, value: bool) -> None: if value != self.is_done and not self.is_doable: raise BadFormatException('cannot change doneness of undoable Todo') - self._is_done = value + if self._is_done != value: + self._is_done = value + if value is True: + for condition in self.fulfills: + condition.is_active = True + for condition in self.undoes: + condition.is_active = False + + def set_undoes(self, db_conn: DatabaseConnection, ids: list[int]) -> None: + """Set self.undoes to Conditions identified by ids.""" + self.set_conditions(db_conn, ids, 'undoes') + + def set_fulfills(self, db_conn: DatabaseConnection, + ids: list[int]) -> None: + """Set self.fulfills to Conditions identified by ids.""" + self.set_conditions(db_conn, ids, 'fulfills') + + 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 add_child(self, child: Todo) -> None: """Add child to self.children, guard against recursion""" @@ -112,3 +182,24 @@ class Todo: for child in self.children: db_conn.exec('INSERT INTO todo_children VALUES (?, ?)', (self.id_, child.id_)) + db_conn.exec('DELETE FROM todo_fulfills WHERE todo = ?', (self.id_,)) + for condition in self.fulfills: + if condition.id_ is None: + raise NotFoundException('Fulfilled Condition of Todo ' + 'without ID (not saved?)') + db_conn.exec('INSERT INTO todo_fulfills VALUES (?, ?)', + (self.id_, condition.id_)) + db_conn.exec('DELETE FROM todo_undoes WHERE todo = ?', (self.id_,)) + for condition in self.undoes: + if condition.id_ is None: + raise NotFoundException('Undone Condition of Todo ' + 'without ID (not saved?)') + db_conn.exec('INSERT INTO todo_undoes VALUES (?, ?)', + (self.id_, condition.id_)) + db_conn.exec('DELETE FROM todo_conditions WHERE todo = ?', (self.id_,)) + for condition in self.conditions: + if condition.id_ is None: + raise NotFoundException('Condition of Todo ' + 'without ID (not saved?)') + db_conn.exec('INSERT INTO todo_conditions VALUES (?, ?)', + (self.id_, condition.id_)) diff --git a/scripts/init.sql b/scripts/init.sql index 9f39305..807e1e7 100644 --- a/scripts/init.sql +++ b/scripts/init.sql @@ -1,20 +1,52 @@ +CREATE TABLE condition_descriptions ( + parent_id INTEGER NOT NULL, + timestamp TEXT NOT NULL, + description TEXT NOT NULL, + PRIMARY KEY (parent_id, timestamp), + FOREIGN KEY (parent_id) REFERENCES conditions(id) +); +CREATE TABLE condition_titles ( + parent_id INTEGER NOT NULL, + timestamp TEXT NOT NULL, + title TEXT NOT NULL, + PRIMARY KEY (parent_id, timestamp), + FOREIGN KEY (parent_id) REFERENCES conditions(id) +); +CREATE TABLE conditions ( + id INTEGER PRIMARY KEY, + is_active BOOLEAN NOT NULL +); CREATE TABLE days ( date TEXT PRIMARY KEY, comment TEXT NOT NULL ); +CREATE TABLE process_conditions ( + process INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY (process, condition), + FOREIGN KEY (process) REFERENCES processes(id), + FOREIGN KEY (condition) REFERENCES conditions(id) +); CREATE TABLE process_descriptions ( - process_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, timestamp TEXT NOT NULL, description TEXT NOT NULL, - PRIMARY KEY (process_id, timestamp), - FOREIGN KEY (process_id) REFERENCES processes(id) + PRIMARY KEY (parent_id, timestamp), + FOREIGN KEY (parent_id) REFERENCES processes(id) ); CREATE TABLE process_efforts ( - process_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, timestamp TEXT NOT NULL, effort REAL NOT NULL, - PRIMARY KEY (process_id, timestamp), - FOREIGN KEY (process_id) REFERENCES processes(id) + PRIMARY KEY (parent_id, timestamp), + FOREIGN KEY (parent_id) REFERENCES processes(id) +); +CREATE TABLE process_fulfills ( + process INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY(process, condition), + FOREIGN KEY (process) REFERENCES processes(id), + FOREIGN KEY (condition) REFERENCES conditions(id) ); CREATE TABLE process_steps ( step_id INTEGER PRIMARY KEY, @@ -26,11 +58,18 @@ CREATE TABLE process_steps ( FOREIGN KEY (parent_step_id) REFERENCES process_steps(step_id) ); CREATE TABLE process_titles ( - process_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL, timestamp TEXT NOT NULL, title TEXT NOT NULL, - PRIMARY KEY (process_id, timestamp), - FOREIGN KEY (process_id) REFERENCES processes(id) + PRIMARY KEY (parent_id, timestamp), + FOREIGN KEY (parent_id) REFERENCES processes(id) +); +CREATE TABLE process_undoes ( + process INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY(process, condition), + FOREIGN KEY (process) REFERENCES processes(id), + FOREIGN KEY (condition) REFERENCES conditions(id) ); CREATE TABLE processes ( id INTEGER PRIMARY KEY @@ -42,6 +81,27 @@ CREATE TABLE todo_children ( FOREIGN KEY (parent) REFERENCES todos(id), FOREIGN KEY (child) REFERENCES todos(id) ); +CREATE TABLE todo_conditions ( + todo INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY(todo, condition), + FOREIGN KEY (todo) REFERENCES todos(id), + FOREIGN KEY (condition) REFERENCES conditions(id) +); +CREATE TABLE todo_fulfills ( + todo INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY(todo, condition), + FOREIGN KEY (todo) REFERENCES todos(id), + FOREIGN KEY (condition) REFERENCES conditions(id) +); +CREATE TABLE todo_undoes ( + todo INTEGER NOT NULL, + condition INTEGER NOT NULL, + PRIMARY KEY(todo, condition), + FOREIGN KEY (todo) REFERENCES todos(id), + FOREIGN KEY (condition) REFERENCES conditions(id) +); CREATE TABLE todos ( id INTEGER PRIMARY KEY, process_id INTEGER NOT NULL, diff --git a/templates/base.html b/templates/base.html index 26c78c5..399e1cc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,6 +3,7 @@ processes +conditions today calendar
diff --git a/templates/condition.html b/templates/condition.html new file mode 100644 index 0000000..dfdf6ef --- /dev/null +++ b/templates/condition.html @@ -0,0 +1,10 @@ +{% extends 'base.html' %} + +{% block content %} +

condition

+
+title: +description: + +{% endblock %} + diff --git a/templates/conditions.html b/templates/conditions.html new file mode 100644 index 0000000..a717bf0 --- /dev/null +++ b/templates/conditions.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block content %} +add + +{% endblock %} + + diff --git a/templates/day.html b/templates/day.html index 637e08d..82eaa56 100644 --- a/templates/day.html +++ b/templates/day.html @@ -5,6 +5,9 @@ {% for child in todo.children %} {{ todo_with_children(child, indent+1) }} {% endfor %} +{% for condition in todo.conditions %} +
  • {% for i in range(indent+1) %}+{% endfor %} [{% if condition.is_active %}x{% else %} {% endif %}] {{condition.title.newest|e}} +{% endfor %} {% endmacro %} {% block content %} @@ -22,6 +25,20 @@ add todo: {% endfor %}
  • +

    conditions

    +{% for node in conditions_listing %} +
  • [{% if node['condition'].is_active %}x{% else %} {% endif %}] {{node['condition'].title.newest|e}} + +
  • +{% endfor %} +

    todos

    {% endblock %} - - diff --git a/templates/todo.html b/templates/todo.html new file mode 100644 index 0000000..9be13a8 --- /dev/null +++ b/templates/todo.html @@ -0,0 +1,79 @@ +{% extends 'base.html' %} + +{% block content %} +

    Todo: {{todo.process.title.newest|e}}

    +
    +

    +id: {{todo.id_}}
    +day: {{todo.day.date}}
    +process: {{todo.process.title.newest|e}}
    +done:
    +

    +

    conditions

    + +{% for condition in todo.conditions %} + + + + +{% endfor %} +
    + + +{{condition.title.newest|e}} +
    +add condition: + +{% for condition_candidate in condition_candidates %} + +{% endfor %} + +

    fulfills

    + +{% for condition in todo.fulfills %} + + + + +{% endfor %} +
    + + +{{condition.title.newest|e}} +
    +add fulfills: +

    undoes

    + +{% for condition in todo.undoes%} + + + + +{% endfor %} +
    + + +{{condition.title.newest|e}} +
    +add undoes: +

    parents

    + +

    children

    + +adopt: + +{% for candidate in todo_candidates %} + +{% endfor %} + + +
    None: + """Test creation and findability.""" + condition = Condition(None, False) + condition.save(self.db_conn) + self.assertEqual(Condition.by_id(self.db_conn, 1), condition) + with self.assertRaises(NotFoundException): + self.assertEqual(Condition.by_id(self.db_conn, 0), condition) + with self.assertRaises(NotFoundException): + self.assertEqual(Condition.by_id(self.db_conn, 2), condition) + + def test_Condition_all(self) -> None: + """Test .all().""" + self.assertEqual(Condition.all(self.db_conn), []) + condition_1 = Condition(None, False) + condition_1.save(self.db_conn) + self.assertEqual(Condition.all(self.db_conn), [condition_1]) + condition_2 = Condition(None, False) + condition_2.save(self.db_conn) + self.assertEqual(Condition.all(self.db_conn), [condition_1, + condition_2]) + + def test_Condition_singularity(self) -> None: + """Test pointers made for single object keep pointing to it.""" + condition_1 = Condition(None, False) + condition_1.save(self.db_conn) + condition_1.is_active = True + condition_retrieved = Condition.by_id(self.db_conn, 1) + self.assertEqual(True, condition_retrieved.is_active) + + +class TestsWithServer(TestCaseWithServer): + """Module tests against our HTTP server/handler (and database).""" + + def test_do_POST_condition(self) -> None: + """Test POST /condition and its effect on the database.""" + form_data = {'title': 'foo', 'description': 'foo'} + self.check_post(form_data, '/condition', 302, '/') + self.assertEqual(1, len(Condition.all(self.db_conn))) + + def test_do_GET(self) -> None: + """Test /condition and /conditions response codes.""" + self.check_get('/condition', 200) + self.check_get('/condition?id=', 200) + self.check_get('/condition?id=0', 400) + self.check_get('/condition?id=FOO', 400) + self.check_get('/conditions', 200) diff --git a/tests/processes.py b/tests/processes.py index bda6275..539d86f 100644 --- a/tests/processes.py +++ b/tests/processes.py @@ -3,6 +3,7 @@ from unittest import TestCase from typing import Any from tests.utils import TestCaseWithDB, TestCaseWithServer from plomtask.processes import Process, ProcessStep +from plomtask.conditions import Condition from plomtask.exceptions import NotFoundException, BadFormatException @@ -93,6 +94,63 @@ class TestsWithDB(TestCaseWithDB): self.assertEqual(p_2.used_as_step_by(self.db_conn), [p_1]) self.assertEqual(p_3.used_as_step_by(self.db_conn), [p_1, p_2]) + def test_Process_undoes(self) -> None: + """Test setting Process.undoes""" + p = Process(None) + p.set_undoes(self.db_conn, []) + p.set_undoes(self.db_conn, []) + self.assertEqual(p.undoes, []) + c1 = Condition(None, False) + c1.save(self.db_conn) + assert c1.id_ is not None + p.set_undoes(self.db_conn, [c1.id_]) + self.assertEqual(p.undoes, [c1]) + c2 = Condition(None, False) + c2.save(self.db_conn) + assert c2.id_ is not None + p.set_undoes(self.db_conn, [c2.id_]) + self.assertEqual(p.undoes, [c2]) + p.set_undoes(self.db_conn, [c1.id_, c2.id_]) + self.assertEqual(p.undoes, [c1, c2]) + + def test_Process_fulfills(self) -> None: + """Test setting Process.fulfills""" + p = Process(None) + p.set_fulfills(self.db_conn, []) + p.set_fulfills(self.db_conn, []) + self.assertEqual(p.fulfills, []) + c1 = Condition(None, False) + c1.save(self.db_conn) + assert c1.id_ is not None + p.set_fulfills(self.db_conn, [c1.id_]) + self.assertEqual(p.fulfills, [c1]) + c2 = Condition(None, False) + c2.save(self.db_conn) + assert c2.id_ is not None + p.set_fulfills(self.db_conn, [c2.id_]) + self.assertEqual(p.fulfills, [c2]) + p.set_fulfills(self.db_conn, [c1.id_, c2.id_]) + self.assertEqual(p.fulfills, [c1, c2]) + + def test_Process_conditions(self) -> None: + """Test setting Process.conditions""" + p = Process(None) + p.set_conditions(self.db_conn, []) + p.set_conditions(self.db_conn, []) + self.assertEqual(p.conditions, []) + c1 = Condition(None, False) + c1.save(self.db_conn) + assert c1.id_ is not None + p.set_conditions(self.db_conn, [c1.id_]) + self.assertEqual(p.conditions, [c1]) + c2 = Condition(None, False) + c2.save(self.db_conn) + assert c2.id_ is not None + p.set_conditions(self.db_conn, [c2.id_]) + self.assertEqual(p.conditions, [c2]) + p.set_conditions(self.db_conn, [c1.id_, c2.id_]) + self.assertEqual(p.conditions, [c1, c2]) + def test_Process_by_id(self) -> None: """Test Process.by_id().""" with self.assertRaises(NotFoundException): @@ -170,6 +228,18 @@ class TestsWithServer(TestCaseWithServer): form_data = {'description': '', 'effort': 1.0} self.check_post(form_data, '/process?id=', 400) self.assertEqual(1, len(Process.all(self.db_conn))) + form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.0, + 'condition': []} + self.check_post(form_data, '/process?id=', 302, '/') + form_data['condition'] = [1] + self.check_post(form_data, '/process?id=', 404) + form_data_cond = {'title': 'foo', 'description': 'foo'} + self.check_post(form_data_cond, '/condition', 302, '/') + self.check_post(form_data, '/process?id=', 302, '/') + form_data['undoes'] = [1] + self.check_post(form_data, '/process?id=', 302, '/') + form_data['fulfills'] = [1] + self.check_post(form_data, '/process?id=', 302, '/') def test_do_GET(self) -> None: """Test /process and /processes response codes.""" diff --git a/tests/todos.py b/tests/todos.py index 6fbb944..98c2aaf 100644 --- a/tests/todos.py +++ b/tests/todos.py @@ -3,6 +3,7 @@ from tests.utils import TestCaseWithDB, TestCaseWithServer from plomtask.todos import Todo from plomtask.days import Day from plomtask.processes import Process +from plomtask.conditions import Condition from plomtask.exceptions import (NotFoundException, BadFormatException, HandledException) @@ -39,6 +40,102 @@ class TestsWithDB(TestCaseWithDB): self.assertEqual(Todo.by_date(self.db_conn, day2.date), []) self.assertEqual(Todo.by_date(self.db_conn, 'foo'), []) + def test_Todo_from_process(self) -> None: + """Test spawning of Todo attributes from Process.""" + day = Day('2024-01-01') + process = Process(None) + c1 = Condition(None, False) + c1.save(self.db_conn) + assert c1.id_ is not None + c2 = Condition(None, True) + c2.save(self.db_conn) + assert c2.id_ is not None + process.set_conditions(self.db_conn, [c1.id_]) + todo = Todo(None, process, False, day) + self.assertEqual(todo.conditions, [c1]) + todo.set_conditions(self.db_conn, [c2.id_]) + self.assertEqual(todo.conditions, [c2]) + self.assertEqual(process.conditions, [c1]) + process.set_fulfills(self.db_conn, [c1.id_]) + todo = Todo(None, process, False, day) + self.assertEqual(todo.fulfills, [c1]) + todo.set_fulfills(self.db_conn, [c2.id_]) + self.assertEqual(todo.fulfills, [c2]) + self.assertEqual(process.fulfills, [c1]) + process.set_undoes(self.db_conn, [c1.id_]) + todo = Todo(None, process, False, day) + self.assertEqual(todo.undoes, [c1]) + todo.set_undoes(self.db_conn, [c2.id_]) + self.assertEqual(todo.undoes, [c2]) + self.assertEqual(process.undoes, [c1]) + + def test_Todo_on_conditions(self) -> None: + """Test effect of Todos on Conditions.""" + day = Day('2024-01-01') + process = Process(None) + process.save_without_steps(self.db_conn) + c1 = Condition(None, False) + c2 = Condition(None, True) + c1.save(self.db_conn) + c2.save(self.db_conn) + assert c1.id_ is not None + assert c2.id_ is not None + todo = Todo(None, process, False, day) + todo.save(self.db_conn) + todo.set_fulfills(self.db_conn, [c1.id_]) + todo.set_undoes(self.db_conn, [c2.id_]) + todo.is_done = True + self.assertEqual(c1.is_active, True) + self.assertEqual(c2.is_active, False) + todo.is_done = False + self.assertEqual(c1.is_active, True) + self.assertEqual(c2.is_active, False) + + def test_Todo_enablers_disablers(self) -> None: + """Test Todo.enablers_for_at/disablers_for_at.""" + day1 = Day('2024-01-01') + day2 = Day('2024-01-02') + process = Process(None) + process.save_without_steps(self.db_conn) + c1 = Condition(None, False) + c2 = Condition(None, True) + c1.save(self.db_conn) + c2.save(self.db_conn) + todo1 = Todo(None, process, False, day1) + todo1.save(self.db_conn) + assert c1.id_ is not None + assert c2.id_ is not None + todo1.set_fulfills(self.db_conn, [c1.id_]) + todo1.set_undoes(self.db_conn, [c2.id_]) + todo1.save(self.db_conn) + assert todo1.id_ is not None + todo2 = Todo(None, process, False, day1) + todo2.save(self.db_conn) + assert todo2.id_ is not None + todo2.set_fulfills(self.db_conn, [c2.id_]) + todo2.save(self.db_conn) + todo3 = Todo(None, process, False, day2) + todo3.save(self.db_conn) + assert todo3.id_ is not None + todo3.set_fulfills(self.db_conn, [c2.id_]) + todo3.save(self.db_conn) + self.assertEqual(Todo.enablers_for_at(self.db_conn, c1, day1.date), + [todo1]) + self.assertEqual(Todo.enablers_for_at(self.db_conn, c1, day2.date), + []) + self.assertEqual(Todo.disablers_for_at(self.db_conn, c1, day1.date), + []) + self.assertEqual(Todo.disablers_for_at(self.db_conn, c1, day2.date), + []) + self.assertEqual(Todo.enablers_for_at(self.db_conn, c2, day1.date), + [todo2]) + self.assertEqual(Todo.enablers_for_at(self.db_conn, c2, day2.date), + [todo3]) + self.assertEqual(Todo.disablers_for_at(self.db_conn, c2, day1.date), + [todo1]) + self.assertEqual(Todo.disablers_for_at(self.db_conn, c2, day2.date), + []) + def test_Todo_children(self) -> None: """Test Todo.children relations.""" day = Day('2024-01-01') @@ -59,6 +156,30 @@ class TestsWithDB(TestCaseWithDB): with self.assertRaises(BadFormatException): todo_2.add_child(todo_1) + def test_Todo_conditioning(self) -> None: + """Test Todo.doability conditions.""" + day = Day('2024-01-01') + process = Process(None) + process.save_without_steps(self.db_conn) + todo_1 = Todo(None, process, False, day) + todo_1.save(self.db_conn) + todo_2 = Todo(None, process, False, day) + 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 + condition = Condition(None) + condition.save(self.db_conn) + assert condition.id_ is not None + todo_2.set_conditions(self.db_conn, [condition.id_]) + with self.assertRaises(BadFormatException): + todo_2.is_done = True + condition.is_active = True + todo_2.is_done = True + def test_Todo_singularity(self) -> None: """Test pointers made for single object keep pointing to it.""" day = Day('2024-01-01') -- 2.30.2