home · contact · privacy
Add Conditions for Todos/Processes to be met or undone by other Todos.
authorChristian Heller <c.heller@plomlompom.de>
Tue, 16 Apr 2024 01:08:54 +0000 (03:08 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 16 Apr 2024 01:08:54 +0000 (03:08 +0200)
16 files changed:
plomtask/conditions.py [new file with mode: 0644]
plomtask/db.py
plomtask/http.py
plomtask/misc.py [new file with mode: 0644]
plomtask/processes.py
plomtask/todos.py
scripts/init.sql
templates/base.html
templates/condition.html [new file with mode: 0644]
templates/conditions.html [new file with mode: 0644]
templates/day.html
templates/process.html
templates/todo.html [new file with mode: 0644]
tests/conditions.py [new file with mode: 0644]
tests/processes.py
tests/todos.py

diff --git a/plomtask/conditions.py b/plomtask/conditions.py
new file mode 100644 (file)
index 0000000..5c57d85
--- /dev/null
@@ -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
index 01bc3e940767eafcd7a3e728a5d73caf7b04aa79..4a82132ff9e89d990c5d3f04f1b4f7395bfb0fda 100644 (file)
@@ -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_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."""
 
     def commit(self) -> None:
         """Commit SQL transaction."""
index 5d165ecf90f1ca6ebaed77e55b73a5d55ff428b8..b00ebeba3aa06b38332be265599e43d7e034a857 100644 (file)
@@ -10,6 +10,7 @@ from plomtask.exceptions import HandledException, BadFormatException, \
         NotFoundException
 from plomtask.db import DatabaseConnection, DatabaseFile
 from plomtask.processes import Process
         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'
 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()
         """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')
                 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)
         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(
         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_)
 
     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(
         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:
 
     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 = 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:
 
     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)
             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:
                 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)
         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)
         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:
 
     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.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 = []
         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)
 
             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)
     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 (file)
index 0000000..bf07188
--- /dev/null
@@ -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))
index fe9bd4a63ff36a34afdbcbe0abed36edb891c8f5..dc0613f8dbb2b01f941603203f57cbddb3766693 100644 (file)
@@ -1,23 +1,29 @@
 """Collecting Processes and Process-related items."""
 from __future__ import annotations
 from sqlite3 import Row
 """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 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."""
 
 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_
     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.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:
 
     @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 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
 
         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]:
         """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_
             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:
                     '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
 
             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:
     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)
         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
 
         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
         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))
index 8fa3b91407cbaef08e599faca708d7ffe9b5c8a0..ce83faddff55278c0efe44f604f6a5fddfaa5851 100644 (file)
@@ -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.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)
 
 from plomtask.exceptions import (NotFoundException, BadFormatException,
                                  HandledException)
 
@@ -11,6 +12,8 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
 class Todo:
     """Individual actionable."""
 
 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_
     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.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:
 
     @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 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
 
         assert isinstance(todo, Todo)
         return todo
 
@@ -61,12 +80,39 @@ class Todo:
             todos += [cls.by_id(db_conn, row[0])]
         return todos
 
             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:
     @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 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
         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')
     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"""
 
     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_))
         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_))
index 9f39305d5896b605a8fc09b4f47de073d4a295cd..807e1e7a0fc06b3da97204e99a23b2342306272a 100644 (file)
@@ -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 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 (
 CREATE TABLE process_descriptions (
-    process_id INTEGER NOT NULL,
+    parent_id INTEGER NOT NULL,
     timestamp TEXT NOT NULL,
     description TEXT 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 (
 );
 CREATE TABLE process_efforts (
-    process_id INTEGER NOT NULL,
+    parent_id INTEGER NOT NULL,
     timestamp TEXT NOT NULL,
     effort REAL 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,
 );
 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 (
     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,
     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
 );
 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)
 );
     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,
 CREATE TABLE todos (
     id INTEGER PRIMARY KEY,
     process_id INTEGER NOT NULL,
index 26c78c595e2fba3cf3b214f3b97a84a787cd70b7..399e1cc0dcdd1a86cc7478cf5bf6b31b68ac8fec 100644 (file)
@@ -3,6 +3,7 @@
 <meta charset="UTF-8">
 <body>
 <a href="processes">processes</a>
 <meta charset="UTF-8">
 <body>
 <a href="processes">processes</a>
+<a href="conditions">conditions</a>
 <a href="day">today</a>
 <a href="calendar">calendar</a>
 <hr>
 <a href="day">today</a>
 <a href="calendar">calendar</a>
 <hr>
diff --git a/templates/condition.html b/templates/condition.html
new file mode 100644 (file)
index 0000000..dfdf6ef
--- /dev/null
@@ -0,0 +1,10 @@
+{% extends 'base.html' %}
+
+{% block content %}
+<h3>condition</h3>
+<form action="condition?id={{condition.id_ or ''}}" method="POST">
+title: <input name="title" value="{{condition.title.newest|e}}" />
+description: <input name="description" value="{{condition.description.newest|e}}" />
+<input type="submit" value="OK" />
+{% endblock %}
+
diff --git a/templates/conditions.html b/templates/conditions.html
new file mode 100644 (file)
index 0000000..a717bf0
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends 'base.html' %}
+
+{% block content %}
+<a href="condition">add</a>
+<ul>
+{% for condition in conditions %}
+<li><a href="condition?id={{condition.id_}}">{{condition.title.newest}}</a>
+{% endfor %}
+</ul>
+{% endblock %}
+
+
index 637e08d94ad2e72da8fbcbd009b0c428131edaee..82eaa56fb7b9acfd6aa55b7db39f080f15459ce4 100644 (file)
@@ -5,6 +5,9 @@
 {% for child in todo.children %}
 {{ todo_with_children(child, indent+1) }}
 {% endfor %}
 {% for child in todo.children %}
 {{ todo_with_children(child, indent+1) }}
 {% endfor %}
+{% for condition in todo.conditions %}
+<li>{% for i in range(indent+1) %}+{% endfor %} [{% if condition.is_active %}x{% else %} {% endif %}] <a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+{% endfor %}
 {% endmacro %}
 
 {% block content %}
 {% endmacro %}
 
 {% block content %}
@@ -22,6 +25,20 @@ add todo: <input name="new_todo" list="processes" autocomplete="off" />
 {% endfor %}
 </datalist>
 </form>
 {% endfor %}
 </datalist>
 </form>
+<h4>conditions</h4>
+{% for node in conditions_listing %}
+<li>[{% if node['condition'].is_active %}x{% else %} {% endif %}] <a href="condition?id={{node['condition'].id_}}">{{node['condition'].title.newest|e}}</a>
+<ul>
+{% for enabler in node['enablers'] %}
+<li>[+] {{enabler.process.title.newest|e}}</li>
+{% endfor %}
+{% for disabler in node['disablers'] %}
+<li>[-] {{disabler.process.title.newest|e}}</li>
+{% endfor %}
+</ul>
+</li>
+{% endfor %}
+<h4>todos</h4>
 <ul>
 {% for todo in todos %}
 {{ todo_with_children(todo, 0) }}
 <ul>
 {% for todo in todos %}
 {{ todo_with_children(todo, 0) }}
index 8731f4b7b12646fb9a4cc44e4726ff543f91ba8a..b55ee0756314fcd746b380cfb46eddf8a7fac202 100644 (file)
@@ -1,12 +1,13 @@
 {% extends 'base.html' %}
 
 {% extends 'base.html' %}
 
-{% macro process_with_steps(step_id, step_node, indent) %}
+{% macro step_with_steps(step_id, step_node, indent) %}
 <tr>
 <td>
 <input type="hidden" name="steps" value="{{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_}}" />
 <tr>
 <td>
 <input type="hidden" name="steps" value="{{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}}_condition_id" value="{{step_node.condition.id_}}" />
 <input type="hidden" name="step_{{step_id}}_parent_id" value="{{step_node.parent_id or ''}}" />
 {% endif %}
 </td>
 <input type="hidden" name="step_{{step_id}}_parent_id" value="{{step_node.parent_id or ''}}" />
 {% endif %}
 </td>
@@ -25,7 +26,7 @@ add step: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="
 </tr>
 {% if step_node.is_explicit or not step_node.seen %}
 {% for substep_id, substep in step_node.steps.items() %}
 </tr>
 {% if step_node.is_explicit or not step_node.seen %}
 {% for substep_id, substep in step_node.steps.items() %}
-{{ process_with_steps(substep_id, substep, indent+1) }}
+{{ step_with_steps(substep_id, substep, indent+1) }}
 {% endfor %}
 {% endif %}
 {% endmacro %}
 {% endfor %}
 {% endif %}
 {% endmacro %}
@@ -36,15 +37,62 @@ add step: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="
 title: <input name="title" value="{{process.title.newest|e}}" />
 description: <input name="description" value="{{process.description.newest|e}}" />
 default effort: <input name="effort" type="number" step=0.1 value={{process.effort.newest}} />
 title: <input name="title" value="{{process.title.newest|e}}" />
 description: <input name="description" value="{{process.description.newest|e}}" />
 default effort: <input name="effort" type="number" step=0.1 value={{process.effort.newest}} />
+<h4>conditions</h4>
+<table>
+{% for condition in process.conditions %}
+<tr>
+<td>
+<input type="checkbox" name="condition" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add condition: <input name="condition" list="condition_candidates" autocomplete="off" />
+<datalist id="condition_candidates">
+{% for condition_candidate in condition_candidates %}
+<option value="{{condition_candidate.id_}}">{{condition_candidate.title.newest|e}}</option>
+{% endfor %}
+</datalist>
+<h4>fulfills</h4>
+<table>
+{% for condition in process.fulfills %}
+<tr>
+<td>
+<input type="checkbox" name="fulfills" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add fulfills: <input name="fulfills" list="condition_candidates" autocomplete="off" />
+<h4>conditions</h4>
+<table>
+{% for condition in process.undoes %}
+<tr>
+<td>
+<input type="checkbox" name="undoes" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add undoes: <input name="undoes" list="condition_candidates" autocomplete="off" />
 <h4>steps</h4>
 <table>
 {% for step_id, step_node in steps.items() %}
 <h4>steps</h4>
 <table>
 {% for step_id, step_node in steps.items() %}
-{{ process_with_steps(step_id, step_node, 0) }}
+{{ step_with_steps(step_id, step_node, 0) }}
 {% endfor %}
 </table>
 {% endfor %}
 </table>
-add step: <input name="new_top_step" list="candidates" autocomplete="off" />
-<datalist id="candidates">
-{% for candidate in candidates %}
+add step: <input name="new_top_step" list="step_candidates" autocomplete="off" />
+<datalist id="step_candidates">
+{% for candidate in step_candidates %}
 <option value="{{candidate.id_}}">{{candidate.title.newest|e}}</option>
 {% endfor %}
 </datalist>
 <option value="{{candidate.id_}}">{{candidate.title.newest|e}}</option>
 {% endfor %}
 </datalist>
@@ -58,5 +106,3 @@ add step: <input name="new_top_step" list="candidates" autocomplete="off" />
 {% endfor %}
 </ul>
 {% endblock %}
 {% endfor %}
 </ul>
 {% endblock %}
-
-
diff --git a/templates/todo.html b/templates/todo.html
new file mode 100644 (file)
index 0000000..9be13a8
--- /dev/null
@@ -0,0 +1,79 @@
+{% extends 'base.html' %}
+
+{% block content %}
+<h3>Todo: {{todo.process.title.newest|e}}</h3>
+<form action="todo?id={{todo.id_}}" method="POST">
+<p>
+id: {{todo.id_}}<br />
+day: <a href="day?date={{todo.day.date}}">{{todo.day.date}}</a><br />
+process: <a href="process?id={{todo.process.id_}}">{{todo.process.title.newest|e}}</a><br />
+done: <input type="checkbox" name="done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/><br /> 
+</p>
+<h4>conditions</h4>
+<table>
+{% for condition in todo.conditions %}
+<tr>
+<td>
+<input type="checkbox" name="condition" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add condition: <input name="condition" list="condition_candidates" autocomplete="off" />
+<datalist id="condition_candidates">
+{% for condition_candidate in condition_candidates %}
+<option value="{{condition_candidate.id_}}">{{condition_candidate.title.newest|e}}</option>
+{% endfor %}
+</datalist>
+<h4>fulfills</h4>
+<table>
+{% for condition in todo.fulfills %}
+<tr>
+<td>
+<input type="checkbox" name="fulfills" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add fulfills: <input name="fulfills" list="condition_candidates" autocomplete="off" />
+<h4>undoes</h4>
+<table>
+{% for condition in todo.undoes%}
+<tr>
+<td>
+<input type="checkbox" name="undoes" value="{{condition.id_}}" checked />
+</td>
+<td>
+<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+add undoes: <input name="undoes" list="condition_candidates" autocomplete="off" />
+<h4>parents</h4>
+<ul>
+{% for parent in todo.parents %}
+<li><a href="todo?id={{parent.id_}}">{{parent.process.title.newest|e}}</a>
+{% endfor %}
+</ul>
+<h4>children</h4>
+<ul>
+{% for child in todo.children %}
+<li><a href="todo?id={{child.id_}}">{{child.process.title.newest|e}}</a>
+{% endfor %}
+</ul>
+adopt: <input name="adopt" list="todo_candidates" autocomplete="off" />
+<datalist id="todo_candidates">
+{% for candidate in todo_candidates %}
+<option value="{{candidate.id_}}">{{candidate.process.title.newest|e}} ({{candidate.id_}})</option>
+{% endfor %}
+</datalist>
+<input type="submit" value="OK" />
+</form
+{% endblock %}
diff --git a/tests/conditions.py b/tests/conditions.py
new file mode 100644 (file)
index 0000000..4781246
--- /dev/null
@@ -0,0 +1,55 @@
+"""Test Conditions module."""
+from tests.utils import TestCaseWithDB, TestCaseWithServer
+from plomtask.conditions import Condition
+from plomtask.exceptions import NotFoundException
+
+
+class TestsWithDB(TestCaseWithDB):
+    """Tests requiring DB, but not server setup."""
+
+    def test_Condition_by_id(self) -> 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)
index bda62750442501445fae1883f9a8a2b280aa18f8..539d86f47bea49d639ff7ae377272e89059a9dcf 100644 (file)
@@ -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 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
 
 
 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])
 
         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):
     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 = {'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."""
 
     def test_do_GET(self) -> None:
         """Test /process and /processes response codes."""
index 6fbb944b92eafd353518773ac555152ead43b421..98c2aaf8da3e158f2709a4ef1f3251d63f7b9068 100644 (file)
@@ -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.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)
 
 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'), [])
 
         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')
     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)
 
         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')
     def test_Todo_singularity(self) -> None:
         """Test pointers made for single object keep pointing to it."""
         day = Day('2024-01-01')