home · contact · privacy
Add text-based search/filter for Conditions and Processes.
[plomtask] / plomtask / todos.py
index ebe35ac56bb78af42a10644c9209b34778527c0e..a7af99faa3a298e27548e7a7234699c1cf8038fc 100644 (file)
@@ -1,71 +1,89 @@
 """Actionables."""
 from __future__ import annotations
 """Actionables."""
 from __future__ import annotations
+from dataclasses import dataclass
 from typing import Any
 from sqlite3 import Row
 from plomtask.db import DatabaseConnection, BaseModel
 from plomtask.processes import Process
 from typing import Any
 from sqlite3 import Row
 from plomtask.db import DatabaseConnection, BaseModel
 from plomtask.processes import Process
-from plomtask.conditions import Condition
+from plomtask.versioned_attributes import VersionedAttribute
+from plomtask.conditions import Condition, ConditionsRelations
 from plomtask.exceptions import (NotFoundException, BadFormatException,
                                  HandledException)
 
 
 from plomtask.exceptions import (NotFoundException, BadFormatException,
                                  HandledException)
 
 
-class Todo(BaseModel):
-    """Individual actionable."""
+@dataclass
+class TodoNode:
+    """Collects what's useful to know for Todo/Condition tree display."""
+    todo: Todo
+    seen: bool
+    children: list[TodoNode]
 
 
-    # pylint: disable=too-many-instance-attributes
 
 
+class Todo(BaseModel[int], ConditionsRelations):
+    """Individual actionable."""
+    # pylint: disable=too-many-instance-attributes
     table_name = 'todos'
     table_name = 'todos'
-    to_save = ['process_id', 'is_done', 'date']
-
-    def __init__(self, id_: int | None, process: Process,
-                 is_done: bool, date: str) -> None:
-        self.set_int_id(id_)
+    to_save = ['process_id', 'is_done', 'date', 'comment', 'effort',
+               'calendarize']
+    to_save_relations = [('todo_conditions', 'todo', 'conditions'),
+                         ('todo_blockers', 'todo', 'blockers'),
+                         ('todo_enables', 'todo', 'enables'),
+                         ('todo_disables', 'todo', 'disables'),
+                         ('todo_children', 'parent', 'children'),
+                         ('todo_children', 'child', 'parents')]
+
+    # pylint: disable=too-many-arguments
+    def __init__(self, id_: int | None,
+                 process: Process,
+                 is_done: bool,
+                 date: str, comment: str = '',
+                 effort: None | float = None,
+                 calendarize: bool = False) -> None:
+        BaseModel.__init__(self, id_)
+        ConditionsRelations.__init__(self)
+        if process.id_ is None:
+            raise NotFoundException('Process of Todo without ID (not saved?)')
         self.process = process
         self._is_done = is_done
         self.date = date
         self.process = process
         self._is_done = is_done
         self.date = date
+        self.comment = comment
+        self.effort = effort
         self.children: list[Todo] = []
         self.parents: list[Todo] = []
         self.children: list[Todo] = []
         self.parents: list[Todo] = []
-        self.conditions: list[Condition] = []
-        self.enables: list[Condition] = []
-        self.disables: list[Condition] = []
+        self.calendarize = calendarize
         if not self.id_:
         if not self.id_:
-            self.conditions = process.conditions[:]
-            self.enables = process.enables[:]
-            self.disables = process.disables[:]
+            self.calendarize = self.process.calendarize
+            self.conditions = self.process.conditions[:]
+            self.blockers = self.process.blockers[:]
+            self.enables = self.process.enables[:]
+            self.disables = self.process.disables[:]
 
     @classmethod
     def from_table_row(cls, db_conn: DatabaseConnection,
                        row: Row | list[Any]) -> Todo:
 
     @classmethod
     def from_table_row(cls, db_conn: DatabaseConnection,
                        row: Row | list[Any]) -> Todo:
-        """Make from DB row, write to DB cache."""
+        """Make from DB row, with dependencies."""
         if row[1] == 0:
             raise NotFoundException('calling Todo of '
                                     'unsaved Process')
         row_as_list = list(row)
         row_as_list[1] = Process.by_id(db_conn, row[1])
         todo = super().from_table_row(db_conn, row_as_list)
         if row[1] == 0:
             raise NotFoundException('calling Todo of '
                                     'unsaved Process')
         row_as_list = list(row)
         row_as_list[1] = Process.by_id(db_conn, row[1])
         todo = super().from_table_row(db_conn, row_as_list)
-        assert isinstance(todo, Todo)
-        return todo
-
-    @classmethod
-    def by_id(cls, db_conn: DatabaseConnection, id_: int) -> Todo:
-        """Get Todo of .id_=id_ and children (from DB cache if possible)."""
-        todo, from_cache = super()._by_id(db_conn, id_)
-        if todo is None:
-            raise NotFoundException(f'Todo of ID not found: {id_}')
-        if not from_cache:
-            for t_id in db_conn.column_where('todo_children', 'child',
-                                             'parent', id_):
-                todo.children += [cls.by_id(db_conn, t_id)]
-            for t_id in db_conn.column_where('todo_children', 'parent',
-                                             'child', id_):
-                todo.parents += [cls.by_id(db_conn, t_id)]
-            for name in ('conditions', 'enables', 'disables'):
-                table = f'todo_{name}'
-                for cond_id in db_conn.column_where(table, 'condition',
-                                                    'todo', todo.id_):
-                    target = getattr(todo, name)
-                    target += [Condition.by_id(db_conn, cond_id)]
-        assert isinstance(todo, Todo)
+        assert isinstance(todo.id_, int)
+        for t_id in db_conn.column_where('todo_children', 'child',
+                                         'parent', todo.id_):
+            # pylint: disable=no-member
+            todo.children += [cls.by_id(db_conn, t_id)]
+        for t_id in db_conn.column_where('todo_children', 'parent',
+                                         'child', todo.id_):
+            # pylint: disable=no-member
+            todo.parents += [cls.by_id(db_conn, t_id)]
+        for name in ('conditions', 'blockers', 'enables', 'disables'):
+            table = f'todo_{name}'
+            assert isinstance(todo.id_, int)
+            for cond_id in db_conn.column_where(table, 'condition',
+                                                'todo', todo.id_):
+                target = getattr(todo, name)
+                target += [Condition.by_id(db_conn, cond_id)]
         return todo
 
     @classmethod
         return todo
 
     @classmethod
@@ -76,31 +94,6 @@ class Todo(BaseModel):
             todos += [cls.by_id(db_conn, id_)]
         return todos
 
             todos += [cls.by_id(db_conn, id_)]
         return todos
 
-    @staticmethod
-    def _x_ablers_for_at(db_conn: DatabaseConnection, name: str,
-                         cond: Condition, date: str) -> list[Todo]:
-        """Collect all Todos of day that [name] condition."""
-        assert isinstance(cond.id_, int)
-        x_ablers = []
-        table = f'todo_{name}'
-        for id_ in db_conn.column_where(table, 'todo', 'condition', cond.id_):
-            todo = Todo.by_id(db_conn, id_)
-            if todo.date == date:
-                x_ablers += [todo]
-        return x_ablers
-
-    @classmethod
-    def enablers_for_at(cls, db_conn: DatabaseConnection,
-                        condition: Condition, date: str) -> list[Todo]:
-        """Collect all Todos of day that enable condition."""
-        return cls._x_ablers_for_at(db_conn, 'enables', condition, date)
-
-    @classmethod
-    def disablers_for_at(cls, db_conn: DatabaseConnection,
-                         condition: Condition, date: str) -> list[Todo]:
-        """Collect all Todos of day that disable condition."""
-        return cls._x_ablers_for_at(db_conn, 'disables', condition, date)
-
     @property
     def is_doable(self) -> bool:
         """Decide whether .is_done settable based on children, Conditions."""
     @property
     def is_doable(self) -> bool:
         """Decide whether .is_done settable based on children, Conditions."""
@@ -110,13 +103,35 @@ class Todo(BaseModel):
         for condition in self.conditions:
             if not condition.is_active:
                 return False
         for condition in self.conditions:
             if not condition.is_active:
                 return False
+        for condition in self.blockers:
+            if condition.is_active:
+                return False
+        return True
+
+    @property
+    def is_deletable(self) -> bool:
+        """Decide whether self be deletable (not if preserve-worthy values)."""
+        if self.comment:
+            return False
+        if self.effort and self.effort >= 0:
+            return False
         return True
 
     @property
     def process_id(self) -> int | str | None:
         return True
 
     @property
     def process_id(self) -> int | str | None:
-        """Return ID of tasked Process."""
+        """Needed for super().save to save Processes as attributes."""
         return self.process.id_
 
         return self.process.id_
 
+    @property
+    def unsatisfied_dependencies(self) -> list[int]:
+        """Return Process IDs of .process.explicit_steps not in .children."""
+        unsatisfied = [s.step_process_id for s in self.process.explicit_steps
+                       if s.parent_step_id is None]
+        for child_process_id in [c.process.id_ for c in self.children]:
+            if child_process_id in unsatisfied:
+                unsatisfied.remove(child_process_id)
+        return unsatisfied
+
     @property
     def is_done(self) -> bool:
         """Wrapper around self._is_done so we can control its setter."""
     @property
     def is_done(self) -> bool:
         """Wrapper around self._is_done so we can control its setter."""
@@ -134,32 +149,53 @@ class Todo(BaseModel):
                 for condition in self.disables:
                     condition.is_active = False
 
                 for condition in self.disables:
                     condition.is_active = False
 
-    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_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_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_)]
+    @property
+    def title(self) -> VersionedAttribute:
+        """Shortcut to .process.title."""
+        return self.process.title
+
+    def adopt_from(self, todos: list[Todo]) -> bool:
+        """As far as possible, fill unsatisfied dependencies from todos."""
+        adopted = False
+        for process_id in self.unsatisfied_dependencies:
+            for todo in [t for t in todos if t.process.id_ == process_id
+                         and t not in self.children]:
+                self.add_child(todo)
+                adopted = True
+                break
+        return adopted
+
+    def make_missing_children(self, db_conn: DatabaseConnection) -> None:
+        """Fill unsatisfied dependencies with new Todos."""
+        for process_id in self.unsatisfied_dependencies:
+            process = Process.by_id(db_conn, process_id)
+            todo = self.__class__(None, process, False, self.date)
+            todo.save(db_conn)
+            self.add_child(todo)
+
+    def get_step_tree(self, seen_todos: set[int]) -> TodoNode:
+        """Return tree of depended-on Todos."""
+
+        def make_node(todo: Todo) -> TodoNode:
+            children = []
+            seen = todo.id_ in seen_todos
+            assert isinstance(todo.id_, int)
+            seen_todos.add(todo.id_)
+            for child in todo.children:
+                children += [make_node(child)]
+            return TodoNode(todo, seen, children)
+
+        return make_node(self)
 
     def add_child(self, child: Todo) -> None:
 
     def add_child(self, child: Todo) -> None:
-        """Add child to self.children, guard against recursion"""
+        """Add child to self.children, avoid recursion, update parenthoods."""
+
         def walk_steps(node: Todo) -> None:
             if node.id_ == self.id_:
                 raise BadFormatException('bad child choice causes recursion')
             for child in node.children:
                 walk_steps(child)
         def walk_steps(node: Todo) -> None:
             if node.id_ == self.id_:
                 raise BadFormatException('bad child choice causes recursion')
             for child in node.children:
                 walk_steps(child)
+
         if self.id_ is None:
             raise HandledException('Can only add children to saved Todos.')
         if child.id_ is None:
         if self.id_ is None:
             raise HandledException('Can only add children to saved Todos.')
         if child.id_ is None:
@@ -170,18 +206,28 @@ class Todo(BaseModel):
         self.children += [child]
         child.parents += [self]
 
         self.children += [child]
         child.parents += [self]
 
+    def remove_child(self, child: Todo) -> None:
+        """Remove child from self.children, update counter relations."""
+        if child not in self.children:
+            raise HandledException('Cannot remove un-parented child.')
+        self.children.remove(child)
+        child.parents.remove(self)
+
     def save(self, db_conn: DatabaseConnection) -> None:
     def save(self, db_conn: DatabaseConnection) -> None:
-        """Write self and children to DB and its cache."""
-        if self.process.id_ is None:
-            raise NotFoundException('Process of Todo without ID (not saved?)')
-        self.save_core(db_conn)
-        assert isinstance(self.id_, int)
-        db_conn.cached_todos[self.id_] = self
-        db_conn.rewrite_relations('todo_children', 'parent', self.id_,
-                                  [[c.id_] for c in self.children])
-        db_conn.rewrite_relations('todo_conditions', 'todo', self.id_,
-                                  [[c.id_] for c in self.conditions])
-        db_conn.rewrite_relations('todo_enables', 'todo', self.id_,
-                                  [[c.id_] for c in self.enables])
-        db_conn.rewrite_relations('todo_disables', 'todo', self.id_,
-                                  [[c.id_] for c in self.disables])
+        """On save calls, also check if auto-deletion by effort < 0."""
+        if self.effort and self.effort < 0 and self.is_deletable:
+            self.remove(db_conn)
+            return
+        super().save(db_conn)
+
+    def remove(self, db_conn: DatabaseConnection) -> None:
+        """Remove from DB, including relations."""
+        if not self.is_deletable:
+            raise HandledException('Cannot remove non-deletable Todo.')
+        children_to_remove = self.children[:]
+        parents_to_remove = self.parents[:]
+        for child in children_to_remove:
+            self.remove_child(child)
+        for parent in parents_to_remove:
+            parent.remove_child(self)
+        super().remove(db_conn)