home · contact · privacy
Fix broken Day template layout. master
authorChristian Heller <c.heller@plomlompom.de>
Sat, 18 May 2024 06:08:43 +0000 (08:08 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 18 May 2024 06:08:43 +0000 (08:08 +0200)
43 files changed:
migrations/0_init.sql [new file with mode: 0644]
migrations/1_add_Todo_comment.sql [new file with mode: 0644]
migrations/2_add_Todo_effort.sql [new file with mode: 0644]
migrations/3_add_Todo_and_Process_calendarize.sql [new file with mode: 0644]
migrations/init_3.sql [new file with mode: 0644]
plomtask/days.py
plomtask/db.py
plomtask/http.py
plomtask/processes.py
plomtask/todos.py
run.py
scripts/init.sql [deleted file]
scripts/pre-commit
templates/_base.html [new file with mode: 0644]
templates/_macros.html [new file with mode: 0644]
templates/base.html [deleted file]
templates/calendar.html
templates/condition.html
templates/condition_descriptions.html [new file with mode: 0644]
templates/condition_titles.html [new file with mode: 0644]
templates/conditions.html
templates/day.html
templates/msg.html
templates/process.html
templates/process_descriptions.html [new file with mode: 0644]
templates/process_efforts.html [new file with mode: 0644]
templates/process_titles.html [new file with mode: 0644]
templates/processes.html
templates/todo.html
tests/__pycache__/__init__.cpython-311.pyc [deleted file]
tests/__pycache__/conditions.cpython-311.pyc [deleted file]
tests/__pycache__/days.cpython-311.pyc [deleted file]
tests/__pycache__/misc.cpython-311.pyc [deleted file]
tests/__pycache__/processes.cpython-311.pyc [deleted file]
tests/__pycache__/test_days.cpython-311.pyc [deleted file]
tests/__pycache__/todos.cpython-311.pyc [deleted file]
tests/__pycache__/utils.cpython-311.pyc [deleted file]
tests/__pycache__/versioned_attributes.cpython-311.pyc [deleted file]
tests/conditions.py
tests/days.py
tests/processes.py
tests/todos.py
tests/utils.py

diff --git a/migrations/0_init.sql b/migrations/0_init.sql
new file mode 100644 (file)
index 0000000..b2979a5
--- /dev/null
@@ -0,0 +1,112 @@
+CREATE TABLE condition_descriptions (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    description TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES conditions(id)
+);
+CREATE TABLE condition_titles (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    title TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES conditions(id)
+);
+CREATE TABLE conditions (
+    id INTEGER PRIMARY KEY,
+    is_active BOOLEAN NOT NULL
+);
+CREATE TABLE days (
+    id 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 (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    description TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE process_disables (
+    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_efforts (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    effort REAL NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE process_enables (
+    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 (
+    id INTEGER PRIMARY KEY,
+    owner INTEGER NOT NULL,
+    step_process INTEGER NOT NULL,
+    parent_step INTEGER,
+    FOREIGN KEY (owner) REFERENCES processes(id),
+    FOREIGN KEY (step_process) REFERENCES processes(id),
+    FOREIGN KEY (parent_step) REFERENCES process_steps(step_id)
+);
+CREATE TABLE process_titles (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    title TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE processes (
+    id INTEGER PRIMARY KEY
+);
+CREATE TABLE todo_children (
+    parent INTEGER NOT NULL,
+    child INTEGER NOT NULL,
+    PRIMARY KEY (parent, child),
+    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_disables (
+    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_enables (
+    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 INTEGER NOT NULL,
+    is_done BOOLEAN NOT NULL,
+    day TEXT NOT NULL,
+    FOREIGN KEY (process) REFERENCES processes(id),
+    FOREIGN KEY (day) REFERENCES days(id)
+);
diff --git a/migrations/1_add_Todo_comment.sql b/migrations/1_add_Todo_comment.sql
new file mode 100644 (file)
index 0000000..0c58335
--- /dev/null
@@ -0,0 +1 @@
+ALTER TABLE todos ADD COLUMN comment TEXT NOT NULL DEFAULT "";
diff --git a/migrations/2_add_Todo_effort.sql b/migrations/2_add_Todo_effort.sql
new file mode 100644 (file)
index 0000000..0506431
--- /dev/null
@@ -0,0 +1 @@
+ALTER TABLE todos ADD COLUMN effort REAL;
diff --git a/migrations/3_add_Todo_and_Process_calendarize.sql b/migrations/3_add_Todo_and_Process_calendarize.sql
new file mode 100644 (file)
index 0000000..dcd65b2
--- /dev/null
@@ -0,0 +1,2 @@
+ALTER TABLE todos ADD COLUMN calendarize BOOLEAN NOT NULL DEFAULT FALSE;
+ALTER TABLE processes ADD COLUMN calendarize BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/migrations/init_3.sql b/migrations/init_3.sql
new file mode 100644 (file)
index 0000000..f261fd7
--- /dev/null
@@ -0,0 +1,116 @@
+CREATE TABLE condition_descriptions (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    description TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES conditions(id)
+);
+CREATE TABLE condition_titles (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    title TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES conditions(id)
+);
+CREATE TABLE conditions (
+    id INTEGER PRIMARY KEY,
+    is_active BOOLEAN NOT NULL
+);
+CREATE TABLE days (
+    id 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 (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    description TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE process_disables (
+    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_efforts (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    effort REAL NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE process_enables (
+    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 (
+    id INTEGER PRIMARY KEY,
+    owner INTEGER NOT NULL,
+    step_process INTEGER NOT NULL,
+    parent_step INTEGER,
+    FOREIGN KEY (owner) REFERENCES processes(id),
+    FOREIGN KEY (step_process) REFERENCES processes(id),
+    FOREIGN KEY (parent_step) REFERENCES process_steps(step_id)
+);
+CREATE TABLE process_titles (
+    parent INTEGER NOT NULL,
+    timestamp TEXT NOT NULL,
+    title TEXT NOT NULL,
+    PRIMARY KEY (parent, timestamp),
+    FOREIGN KEY (parent) REFERENCES processes(id)
+);
+CREATE TABLE processes (
+    id INTEGER PRIMARY KEY,
+    calendarize BOOLEAN NOT NULL DEFAULT FALSE
+);
+CREATE TABLE todo_children (
+    parent INTEGER NOT NULL,
+    child INTEGER NOT NULL,
+    PRIMARY KEY (parent, child),
+    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_disables (
+    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_enables (
+    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 INTEGER NOT NULL,
+    is_done BOOLEAN NOT NULL,
+    day TEXT NOT NULL,
+    comment TEXT NOT NULL DEFAULT "",
+    effort REAL,
+    calendarize BOOLEAN NOT NULL DEFAULT FALSE,
+    FOREIGN KEY (process) REFERENCES processes(id),
+    FOREIGN KEY (day) REFERENCES days(id)
+);
index 0e07bf7aeb8837fc28031d2d924d65d17b0256bd..fe1ba44e80e9a1c4182130b870d6d531dc9c9c06 100644 (file)
@@ -3,6 +3,7 @@ from __future__ import annotations
 from datetime import datetime, timedelta
 from plomtask.exceptions import BadFormatException
 from plomtask.db import DatabaseConnection, BaseModel
+from plomtask.todos import Todo
 
 DATE_FORMAT = '%Y-%m-%d'
 MIN_RANGE_DATE = '2024-01-01'
@@ -36,6 +37,7 @@ class Day(BaseModel[str]):
         super().__init__(id_)
         self.datetime = datetime.strptime(self.date, DATE_FORMAT)
         self.comment = comment
+        self.calendarized_todos: list[Todo] = []
 
     def __lt__(self, other: Day) -> bool:
         return self.date < other.date
@@ -78,6 +80,17 @@ class Day(BaseModel[str]):
         assert isinstance(self.id_, str)
         return self.id_
 
+    @property
+    def first_of_month(self) -> bool:
+        """Return what month self.date is part of."""
+        assert isinstance(self.id_, str)
+        return self.id_[-2:] == '01'
+
+    @property
+    def month_name(self) -> str:
+        """Return what month self.date is part of."""
+        return self.datetime.strftime('%B')
+
     @property
     def weekday(self) -> str:
         """Return what weekday matches self.date."""
@@ -94,3 +107,8 @@ class Day(BaseModel[str]):
         """Return date succeeding date of this Day."""
         next_datetime = self.datetime + timedelta(days=1)
         return next_datetime.strftime(DATE_FORMAT)
+
+    def collect_calendarized_todos(self, db_conn: DatabaseConnection) -> None:
+        """Fill self.calendarized_todos."""
+        self.calendarized_todos = [t for t in Todo.by_date(db_conn, self.date)
+                                   if t.calendarize]
index e4d5f6e9a42fe4726c5dff35e9488e13b61eea13..b4dc3e982c496833e7962ab02dc643c027235c1e 100644 (file)
@@ -1,13 +1,20 @@
 """Database management."""
 from __future__ import annotations
+from os import listdir
 from os.path import isfile
 from difflib import Differ
 from sqlite3 import connect as sql_connect, Cursor, Row
 from typing import Any, Self, TypeVar, Generic
 from plomtask.exceptions import HandledException, NotFoundException
 
-PATH_DB_SCHEMA = 'scripts/init.sql'
-EXPECTED_DB_VERSION = 0
+EXPECTED_DB_VERSION = 3
+MIGRATIONS_DIR = 'migrations'
+FILENAME_DB_SCHEMA = f'init_{EXPECTED_DB_VERSION}.sql'
+PATH_DB_SCHEMA = f'{MIGRATIONS_DIR}/{FILENAME_DB_SCHEMA}'
+
+
+class UnmigratedDbException(HandledException):
+    """To identify case of unmigrated DB file."""
 
 
 class DatabaseFile:  # pylint: disable=too-few-public-methods
@@ -17,43 +24,131 @@ class DatabaseFile:  # pylint: disable=too-few-public-methods
         self.path = path
         self._check()
 
-    def remake(self) -> None:
-        """Create tables in self.path file as per PATH_DB_SCHEMA sql file."""
-        with sql_connect(self.path) as conn:
+    @classmethod
+    def create_at(cls, path: str) -> DatabaseFile:
+        """Make new DB file at path."""
+        with sql_connect(path) as conn:
             with open(PATH_DB_SCHEMA, 'r', encoding='utf-8') as f:
                 conn.executescript(f.read())
-        self._check()
+            conn.execute(f'PRAGMA user_version = {EXPECTED_DB_VERSION}')
+        return cls(path)
+
+    @classmethod
+    def migrate(cls, path: str) -> DatabaseFile:
+        """Apply migrations from_version to EXPECTED_DB_VERSION."""
+        migrations = cls._available_migrations()
+        from_version = cls.get_version_of_db(path)
+        migrations_todo = migrations[from_version+1:]
+        for j, filename in enumerate(migrations_todo):
+            with sql_connect(path) as conn:
+                with open(f'{MIGRATIONS_DIR}/{filename}', 'r',
+                          encoding='utf-8') as f:
+                    conn.executescript(f.read())
+            user_version = from_version + j + 1
+            with sql_connect(path) as conn:
+                conn.execute(f'PRAGMA user_version = {user_version}')
+        return cls(path)
 
     def _check(self) -> None:
         """Check file exists, and is of proper DB version and schema."""
-        self.exists = isfile(self.path)
-        if self.exists:
-            self._validate_user_version()
-            self._validate_schema()
+        if not isfile(self.path):
+            raise NotFoundException
+        if self.user_version != EXPECTED_DB_VERSION:
+            raise UnmigratedDbException()
+        self._validate_schema()
+
+    @staticmethod
+    def _available_migrations() -> list[str]:
+        """Validate migrations directory and return sorted entries."""
+        msg_too_big = 'Migration directory points beyond expected DB version.'
+        msg_bad_entry = 'Migration directory contains unexpected entry: '
+        msg_missing = 'Migration directory misses migration of number: '
+        migrations = {}
+        for entry in listdir(MIGRATIONS_DIR):
+            if entry == FILENAME_DB_SCHEMA:
+                continue
+            toks = entry.split('_', 1)
+            if len(toks) < 2:
+                raise HandledException(msg_bad_entry + entry)
+            try:
+                i = int(toks[0])
+            except ValueError as e:
+                raise HandledException(msg_bad_entry + entry) from e
+            if i > EXPECTED_DB_VERSION:
+                raise HandledException(msg_too_big)
+            migrations[i] = toks[1]
+        migrations_list = []
+        for i in range(EXPECTED_DB_VERSION + 1):
+            if i not in migrations:
+                raise HandledException(msg_missing + str(i))
+            migrations_list += [f'{i}_{migrations[i]}']
+        return migrations_list
 
-    def _validate_user_version(self) -> None:
-        """Compare DB user_version with EXPECTED_DB_VERSION."""
+    @staticmethod
+    def get_version_of_db(path: str) -> int:
+        """Get DB user_version, fail if outside expected range."""
         sql_for_db_version = 'PRAGMA user_version'
-        with sql_connect(self.path) as conn:
+        with sql_connect(path) as conn:
             db_version = list(conn.execute(sql_for_db_version))[0][0]
-            if db_version != EXPECTED_DB_VERSION:
-                msg = f'Wrong DB version, expected '\
-                        f'{EXPECTED_DB_VERSION}, got {db_version}.'
-                raise HandledException(msg)
+        if db_version > EXPECTED_DB_VERSION:
+            msg = f'Wrong DB version, expected '\
+                    f'{EXPECTED_DB_VERSION}, got unknown {db_version}.'
+            raise HandledException(msg)
+        assert isinstance(db_version, int)
+        return db_version
+
+    @property
+    def user_version(self) -> int:
+        """Get DB user_version."""
+        return self.__class__.get_version_of_db(self.path)
 
     def _validate_schema(self) -> None:
         """Compare found schema with what's stored at PATH_DB_SCHEMA."""
+
+        def reformat_rows(rows: list[str]) -> list[str]:
+            new_rows = []
+            for row in rows:
+                new_row = []
+                for subrow in row.split('\n'):
+                    subrow = subrow.rstrip()
+                    in_parentheses = 0
+                    split_at = []
+                    for i, c in enumerate(subrow):
+                        if '(' == c:
+                            in_parentheses += 1
+                        elif ')' == c:
+                            in_parentheses -= 1
+                        elif ',' == c and 0 == in_parentheses:
+                            split_at += [i + 1]
+                    prev_split = 0
+                    for i in split_at:
+                        segment = subrow[prev_split:i].strip()
+                        if len(segment) > 0:
+                            new_row += [f'    {segment}']
+                        prev_split = i
+                    segment = subrow[prev_split:].strip()
+                    if len(segment) > 0:
+                        new_row += [f'    {segment}']
+                new_row[0] = new_row[0].lstrip()
+                new_row[-1] = new_row[-1].lstrip()
+                if new_row[-1] != ')' and new_row[-3][-1] != ',':
+                    new_row[-3] = new_row[-3] + ','
+                    new_row[-2:] = ['    ' + new_row[-1][:-1]] + [')']
+                new_rows += ['\n'.join(new_row)]
+            return new_rows
+
         sql_for_schema = 'SELECT sql FROM sqlite_master ORDER BY sql'
         msg_err = 'Database has wrong tables schema. Diff:\n'
         with sql_connect(self.path) as conn:
             schema_rows = [r[0] for r in conn.execute(sql_for_schema) if r[0]]
-            retrieved_schema = ';\n'.join(schema_rows) + ';'
-            with open(PATH_DB_SCHEMA, 'r', encoding='utf-8') as f:
-                stored_schema = f.read().rstrip()
-                if stored_schema != retrieved_schema:
-                    diff_msg = Differ().compare(retrieved_schema.splitlines(),
-                                                stored_schema.splitlines())
-                    raise HandledException(msg_err + '\n'.join(diff_msg))
+        schema_rows = reformat_rows(schema_rows)
+        retrieved_schema = ';\n'.join(schema_rows) + ';'
+        with open(PATH_DB_SCHEMA, 'r', encoding='utf-8') as f:
+            stored_schema = f.read().rstrip()
+        if stored_schema != retrieved_schema:
+            diff_msg = Differ().compare(retrieved_schema.splitlines(),
+                                        stored_schema.splitlines())
+            raise HandledException(msg_err + '\n'.join(diff_msg))
 
 
 class DatabaseConnection:
index adac957f5a1090267fb7807875eabcec2c8c37d3..080af8ce1a127880368c8c4bedb3cf4e0aebaffc 100644 (file)
@@ -1,5 +1,5 @@
 """Web server stuff."""
-from typing import Any, NamedTuple
+from typing import Any
 from http.server import BaseHTTPRequestHandler
 from http.server import HTTPServer
 from urllib.parse import urlparse, parse_qs
@@ -94,8 +94,7 @@ class TaskHandler(BaseHTTPRequestHandler):
         """Handle any GET request."""
         try:
             self._init_handling()
-            if self.site in {'calendar', 'day', 'process', 'processes', 'todo',
-                             'condition', 'conditions'}:
+            if hasattr(self, f'do_GET_{self.site}'):
                 template = f'{self.site}.html'
                 ctx = getattr(self, f'do_GET_{self.site}')()
                 html = self.server.jinja.get_template(template).render(**ctx)
@@ -114,33 +113,31 @@ class TaskHandler(BaseHTTPRequestHandler):
         start = self.params.get_str('start')
         end = self.params.get_str('end')
         days = Day.all(self.conn, date_range=(start, end), fill_gaps=True)
+        for day in days:
+            day.collect_calendarized_todos(self.conn)
         return {'start': start, 'end': end, 'days': days}
 
     def do_GET_day(self) -> dict[str, object]:
         """Show single Day of ?date=."""
-
-        class ConditionListing(NamedTuple):
-            """Listing of Condition augmented with its enablers, disablers."""
-            condition: Condition
-            enablers: list[Todo]
-            disablers: list[Todo]
-
         date = self.params.get_str('date', todays_date())
-        top_todos = [t for t in Todo.by_date(self.conn, date) if not t.parents]
-        todo_trees = [t.get_undone_steps_tree() for t in top_todos]
-        done_trees = []
-        for t in top_todos:
-            done_trees += t.get_done_steps_tree()
-        condition_listings: list[ConditionListing] = []
-        for cond in Condition.all(self.conn):
-            enablers = Todo.enablers_for_at(self.conn, cond, date)
-            disablers = Todo.disablers_for_at(self.conn, cond, date)
-            condition_listings += [ConditionListing(cond, enablers, disablers)]
+        todays_todos = Todo.by_date(self.conn, date)
+        conditions_present = []
+        enablers_for = {}
+        for todo in todays_todos:
+            for condition in todo.conditions:
+                if condition not in conditions_present:
+                    conditions_present += [condition]
+                    enablers_for[condition.id_] = [p for p in
+                                                   Process.all(self.conn)
+                                                   if condition in p.enables]
+        seen_todos: set[int] = set()
+        top_nodes = [t.get_step_tree(seen_todos)
+                     for t in todays_todos if not t.parents]
         return {'day': Day.by_id(self.conn, date, create=True),
-                'todo_trees': todo_trees,
-                'done_trees': done_trees,
-                'processes': Process.all(self.conn),
-                'condition_listings': condition_listings}
+                'top_nodes': top_nodes,
+                'enablers_for': enablers_for,
+                'conditions_present': conditions_present,
+                'processes': Process.all(self.conn)}
 
     def do_GET_todo(self) -> dict[str, object]:
         """Show single Todo of ?id=."""
@@ -152,15 +149,37 @@ class TaskHandler(BaseHTTPRequestHandler):
 
     def do_GET_conditions(self) -> dict[str, object]:
         """Show all Conditions."""
-        return {'conditions': Condition.all(self.conn)}
+        conditions = Condition.all(self.conn)
+        sort_by = self.params.get_str('sort_by')
+        if sort_by == 'is_active':
+            conditions.sort(key=lambda c: c.is_active)
+        elif sort_by == '-is_active':
+            conditions.sort(key=lambda c: c.is_active, reverse=True)
+        elif sort_by == '-title':
+            conditions.sort(key=lambda c: c.title.newest, reverse=True)
+        else:
+            conditions.sort(key=lambda c: c.title.newest)
+        return {'conditions': conditions, 'sort_by': sort_by}
 
     def do_GET_condition(self) -> dict[str, object]:
         """Show Condition of ?id=."""
         id_ = self.params.get_int_or_none('id')
         return {'condition': Condition.by_id(self.conn, id_, create=True)}
 
+    def do_GET_condition_titles(self) -> dict[str, object]:
+        """Show title history of Condition of ?id=."""
+        id_ = self.params.get_int_or_none('id')
+        condition = Condition.by_id(self.conn, id_)
+        return {'condition': condition}
+
+    def do_GET_condition_descriptions(self) -> dict[str, object]:
+        """Show description historys of Condition of ?id=."""
+        id_ = self.params.get_int_or_none('id')
+        condition = Condition.by_id(self.conn, id_)
+        return {'condition': condition}
+
     def do_GET_process(self) -> dict[str, object]:
-        """Show process of ?id=."""
+        """Show Process of ?id=."""
         id_ = self.params.get_int_or_none('id')
         process = Process.by_id(self.conn, id_, create=True)
         return {'process': process,
@@ -169,9 +188,37 @@ class TaskHandler(BaseHTTPRequestHandler):
                 'step_candidates': Process.all(self.conn),
                 'condition_candidates': Condition.all(self.conn)}
 
+    def do_GET_process_titles(self) -> dict[str, object]:
+        """Show title history of Process of ?id=."""
+        id_ = self.params.get_int_or_none('id')
+        process = Process.by_id(self.conn, id_)
+        return {'process': process}
+
+    def do_GET_process_descriptions(self) -> dict[str, object]:
+        """Show description historys of Process of ?id=."""
+        id_ = self.params.get_int_or_none('id')
+        process = Process.by_id(self.conn, id_)
+        return {'process': process}
+
+    def do_GET_process_efforts(self) -> dict[str, object]:
+        """Show default effort history of Process of ?id=."""
+        id_ = self.params.get_int_or_none('id')
+        process = Process.by_id(self.conn, id_)
+        return {'process': process}
+
     def do_GET_processes(self) -> dict[str, object]:
         """Show all Processes."""
-        return {'processes': Process.all(self.conn)}
+        processes = Process.all(self.conn)
+        sort_by = self.params.get_str('sort_by')
+        if sort_by == 'steps':
+            processes.sort(key=lambda c: len(c.explicit_steps))
+        elif sort_by == '-steps':
+            processes.sort(key=lambda c: len(c.explicit_steps), reverse=True)
+        elif sort_by == '-title':
+            processes.sort(key=lambda c: c.title.newest, reverse=True)
+        else:
+            processes.sort(key=lambda c: c.title.newest)
+        return {'processes': processes, 'sort_by': sort_by}
 
     def do_POST(self) -> None:
         """Handle any POST request."""
@@ -182,7 +229,7 @@ class TaskHandler(BaseHTTPRequestHandler):
             postvars = parse_qs(self.rfile.read(length).decode(),
                                 keep_blank_values=True, strict_parsing=True)
             self.form_data = InputsParser(postvars)
-            if self.site in ('day', 'process', 'todo', 'condition'):
+            if hasattr(self, f'do_POST_{self.site}'):
                 redir_target = getattr(self, f'do_POST_{self.site}')()
                 self.conn.commit()
             else:
@@ -198,19 +245,33 @@ class TaskHandler(BaseHTTPRequestHandler):
         """Update or insert Day of date and Todos mapped to it."""
         date = self.params.get_str('date')
         day = Day.by_id(self.conn, date, create=True)
-        day.comment = self.form_data.get_str('comment')
+        day.comment = self.form_data.get_str('day_comment')
         day.save(self.conn)
-        existing_todos = Todo.by_date(self.conn, date)
+        new_todos = []
         for process_id in self.form_data.get_all_int('new_todo'):
             process = Process.by_id(self.conn, process_id)
             todo = Todo(None, process, False, day.date)
             todo.save(self.conn)
-            todo.adopt_from(existing_todos)
-            todo.make_missing_children(self.conn)
-            todo.save(self.conn)
-        for todo_id in self.form_data.get_all_int('done'):
+            new_todos += [todo]
+        adopted = True
+        while adopted:
+            adopted = False
+            existing_todos = Todo.by_date(self.conn, date)
+            for todo in new_todos:
+                if todo.adopt_from(existing_todos):
+                    adopted = True
+                todo.make_missing_children(self.conn)
+                todo.save(self.conn)
+        done_ids = self.form_data.get_all_int('done')
+        comments = self.form_data.get_all_str('comment')
+        efforts = self.form_data.get_all_str('effort')
+        for i, todo_id in enumerate(self.form_data.get_all_int('todo_id')):
             todo = Todo.by_id(self.conn, todo_id)
-            todo.is_done = True
+            todo.is_done = todo_id in done_ids
+            if len(comments) > 0:
+                todo.comment = comments[i]
+            if len(efforts) > 0:
+                todo.effort = float(efforts[i]) if efforts[i] else None
             todo.save(self.conn)
             for condition in todo.enables:
                 condition.save(self.conn)
@@ -237,10 +298,14 @@ class TaskHandler(BaseHTTPRequestHandler):
                 continue
             child = Todo.by_id(self.conn, child_id)
             todo.add_child(child)
+        effort = self.form_data.get_str('effort', ignore_strict=True)
+        todo.effort = float(effort) if effort else None
         todo.set_conditions(self.conn, self.form_data.get_all_int('condition'))
         todo.set_enables(self.conn, self.form_data.get_all_int('enables'))
         todo.set_disables(self.conn, self.form_data.get_all_int('disables'))
         todo.is_done = len(self.form_data.get_all_str('done')) > 0
+        todo.calendarize = len(self.form_data.get_all_str('calendarize')) > 0
+        todo.comment = self.form_data.get_str('comment', ignore_strict=True)
         todo.save(self.conn)
         for condition in todo.enables:
             condition.save(self.conn)
@@ -263,6 +328,7 @@ class TaskHandler(BaseHTTPRequestHandler):
                                self.form_data.get_all_int('condition'))
         process.set_enables(self.conn, self.form_data.get_all_int('enables'))
         process.set_disables(self.conn, self.form_data.get_all_int('disables'))
+        process.calendarize = self.form_data.get_all_str('calendarize') != []
         process.save(self.conn)
         process.explicit_steps = []
         steps: list[tuple[int | None, int, int | None]] = []
index 1778e4f73b8eff992322b9b1e166f6987047e05d..e1364215cfe57e5c66d376cd95cf721fe4f6f65a 100644 (file)
@@ -22,20 +22,21 @@ class ProcessStepsNode:
 
 class Process(BaseModel[int], ConditionsRelations):
     """Template for, and metadata for, Todos, and their arrangements."""
+    # pylint: disable=too-many-instance-attributes
     table_name = 'processes'
+    to_save = ['calendarize']
     to_save_versioned = ['title', 'description', 'effort']
     to_save_relations = [('process_conditions', 'process', 'conditions'),
                          ('process_enables', 'process', 'enables'),
                          ('process_disables', 'process', 'disables')]
 
-    # pylint: disable=too-many-instance-attributes
-
-    def __init__(self, id_: int | None) -> None:
+    def __init__(self, id_: int | None, calendarize: bool = False) -> None:
         super().__init__(id_)
         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.calendarize = calendarize
         self.conditions: list[Condition] = []
         self.enables: list[Condition] = []
         self.disables: list[Condition] = []
index fcb8617d71a055f49549d657b0dcedd294dd9b4d..0fea23445ac1881a18ea87eca95cba7f03510f95 100644 (file)
@@ -5,46 +5,55 @@ from typing import Any
 from sqlite3 import Row
 from plomtask.db import DatabaseConnection, BaseModel
 from plomtask.processes import Process
+from plomtask.versioned_attributes import VersionedAttribute
 from plomtask.conditions import Condition, ConditionsRelations
 from plomtask.exceptions import (NotFoundException, BadFormatException,
                                  HandledException)
 
 
 @dataclass
-class TodoStepsNode:
+class TodoNode:
     """Collects what's useful to know for Todo/Condition tree display."""
-    item: Todo | Condition
-    is_todo: bool
-    children: list[TodoStepsNode]
+    todo: Todo
     seen: bool
-    hide: bool
+    children: list[TodoNode]
 
 
 class Todo(BaseModel[int], ConditionsRelations):
     """Individual actionable."""
     # pylint: disable=too-many-instance-attributes
     table_name = 'todos'
-    to_save = ['process_id', 'is_done', 'date']
+    to_save = ['process_id', 'is_done', 'date', 'comment', 'effort',
+               'calendarize']
     to_save_relations = [('todo_conditions', 'todo', 'conditions'),
                          ('todo_enables', 'todo', 'enables'),
                          ('todo_disables', 'todo', 'disables'),
                          ('todo_children', 'parent', 'children'),
                          ('todo_children', 'child', 'parents')]
 
-    def __init__(self, id_: int | None, process: Process,
-                 is_done: bool, date: str) -> None:
+    # 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:
         super().__init__(id_)
         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.comment = comment
+        self.effort = effort
         self.children: list[Todo] = []
         self.parents: list[Todo] = []
+        self.calendarize = calendarize
         self.conditions: list[Condition] = []
         self.enables: list[Condition] = []
         self.disables: list[Condition] = []
         if not self.id_:
+            self.calendarize = self.process.calendarize
             self.conditions = self.process.conditions[:]
             self.enables = self.process.enables[:]
             self.disables = self.process.disables[:]
@@ -85,31 +94,6 @@ class Todo(BaseModel[int], ConditionsRelations):
             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."""
@@ -123,7 +107,7 @@ class Todo(BaseModel[int], ConditionsRelations):
 
     @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_
 
     @property
@@ -153,13 +137,21 @@ class Todo(BaseModel[int], ConditionsRelations):
                 for condition in self.disables:
                     condition.is_active = False
 
-    def adopt_from(self, todos: list[Todo]) -> None:
+    @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."""
@@ -169,69 +161,19 @@ class Todo(BaseModel[int], ConditionsRelations):
             todo.save(db_conn)
             self.add_child(todo)
 
-    def get_step_tree(self, seen_todos: set[int],
-                      seen_conditions: set[int]) -> TodoStepsNode:
-        """Return tree of depended-on Todos and Conditions."""
+    def get_step_tree(self, seen_todos: set[int]) -> TodoNode:
+        """Return tree of depended-on Todos."""
 
-        def make_node(step: Todo | Condition) -> TodoStepsNode:
-            assert isinstance(step.id_, int)
-            is_todo = isinstance(step, Todo)
+        def make_node(todo: Todo) -> TodoNode:
             children = []
-            if is_todo:
-                assert isinstance(step, Todo)
-                seen = step.id_ in seen_todos
-                seen_todos.add(step.id_)
-                potentially_enabled = set()
-                for child in step.children:
-                    for condition in child.enables:
-                        potentially_enabled.add(condition.id_)
-                    children += [make_node(child)]
-                for condition in [c for c in step.conditions
-                                  if (not c.is_active)
-                                  and (c.id_ not in potentially_enabled)]:
-                    children += [make_node(condition)]
-            else:
-                seen = step.id_ in seen_conditions
-                seen_conditions.add(step.id_)
-            return TodoStepsNode(step, is_todo, children, seen, False)
-
-        node = make_node(self)
-        return node
-
-    def get_undone_steps_tree(self) -> TodoStepsNode:
-        """Return tree of depended-on undone Todos and Conditions."""
-
-        def walk_tree(node: TodoStepsNode) -> None:
-            if isinstance(node.item, Todo) and node.item.is_done:
-                node.hide = True
-            for child in node.children:
-                walk_tree(child)
-
-        seen_todos: set[int] = set()
-        seen_conditions: set[int] = set()
-        step_tree = self.get_step_tree(seen_todos, seen_conditions)
-        walk_tree(step_tree)
-        return step_tree
-
-    def get_done_steps_tree(self) -> list[TodoStepsNode]:
-        """Return tree of depended-on done Todos."""
-
-        def make_nodes(node: TodoStepsNode) -> list[TodoStepsNode]:
-            children: list[TodoStepsNode] = []
-            if not isinstance(node.item, Todo):
-                return children
-            for child in node.children:
-                children += make_nodes(child)
-            if node.item.is_done:
-                node.children = children
-                return [node]
-            return 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)
 
-        seen_todos: set[int] = set()
-        seen_conditions: set[int] = set()
-        step_tree = self.get_step_tree(seen_todos, seen_conditions)
-        nodes = make_nodes(step_tree)
-        return nodes
+        return make_node(self)
 
     def add_child(self, child: Todo) -> None:
         """Add child to self.children, avoid recursion, update parenthoods."""
diff --git a/run.py b/run.py
index e1bbe5dafa4a43664f3b6839fe2659dc80ff0f8b..c69dc6ab061c522c75ee2d1420df0fdc44a2f1e1 100755 (executable)
--- a/run.py
+++ b/run.py
@@ -2,28 +2,36 @@
 """Call this to start the application."""
 from sys import exit as sys_exit
 from os import environ
-from plomtask.exceptions import HandledException
+from plomtask.exceptions import HandledException, NotFoundException
 from plomtask.http import TaskHandler, TaskServer
-from plomtask.db import DatabaseFile
+from plomtask.db import DatabaseFile, UnmigratedDbException
 
 PLOMTASK_DB_PATH = environ.get('PLOMTASK_DB_PATH')
 HTTP_PORT = 8082
 DB_CREATION_ASK = 'Database file not found. Create? Y/n\n'
+DB_MIGRATE_ASK = 'Database file needs migration. Migrate? Y/n\n'
+
+
+def yes_or_fail(question: str, fail_msg: str) -> None:
+    """Ask question, raise HandledException(fail_msg) if reply not yes."""
+    reply = input(question)
+    if not reply.lower() in {'y', 'yes', 'yes.', 'yes!'}:
+        print('Not recognizing reply as "yes".')
+        raise HandledException(fail_msg)
 
 
 if __name__ == '__main__':
     try:
         if not PLOMTASK_DB_PATH:
             raise HandledException('PLOMTASK_DB_PATH not set.')
-        db_file = DatabaseFile(PLOMTASK_DB_PATH)
-        if not db_file.exists:
-            legal_yesses = {'y', 'yes', 'yes.', 'yes!'}
-            reply = input(DB_CREATION_ASK)
-            if reply.lower() in legal_yesses:
-                db_file.remake()
-            else:
-                print('Not recognizing reply as "yes".')
-                raise HandledException('Cannot run without database.')
+        try:
+            db_file = DatabaseFile(PLOMTASK_DB_PATH)
+        except NotFoundException:
+            yes_or_fail(DB_CREATION_ASK, 'Cannot run without DB.')
+            db_file = DatabaseFile.create_at(PLOMTASK_DB_PATH)
+        except UnmigratedDbException:
+            yes_or_fail(DB_MIGRATE_ASK, 'Cannot run with unmigrated DB.')
+            db_file = DatabaseFile.migrate(PLOMTASK_DB_PATH)
         server = TaskServer(db_file, ('localhost', HTTP_PORT), TaskHandler)
         print(f'running at port {HTTP_PORT}')
         try:
diff --git a/scripts/init.sql b/scripts/init.sql
deleted file mode 100644 (file)
index b2979a5..0000000
+++ /dev/null
@@ -1,112 +0,0 @@
-CREATE TABLE condition_descriptions (
-    parent INTEGER NOT NULL,
-    timestamp TEXT NOT NULL,
-    description TEXT NOT NULL,
-    PRIMARY KEY (parent, timestamp),
-    FOREIGN KEY (parent) REFERENCES conditions(id)
-);
-CREATE TABLE condition_titles (
-    parent INTEGER NOT NULL,
-    timestamp TEXT NOT NULL,
-    title TEXT NOT NULL,
-    PRIMARY KEY (parent, timestamp),
-    FOREIGN KEY (parent) REFERENCES conditions(id)
-);
-CREATE TABLE conditions (
-    id INTEGER PRIMARY KEY,
-    is_active BOOLEAN NOT NULL
-);
-CREATE TABLE days (
-    id 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 (
-    parent INTEGER NOT NULL,
-    timestamp TEXT NOT NULL,
-    description TEXT NOT NULL,
-    PRIMARY KEY (parent, timestamp),
-    FOREIGN KEY (parent) REFERENCES processes(id)
-);
-CREATE TABLE process_disables (
-    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_efforts (
-    parent INTEGER NOT NULL,
-    timestamp TEXT NOT NULL,
-    effort REAL NOT NULL,
-    PRIMARY KEY (parent, timestamp),
-    FOREIGN KEY (parent) REFERENCES processes(id)
-);
-CREATE TABLE process_enables (
-    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 (
-    id INTEGER PRIMARY KEY,
-    owner INTEGER NOT NULL,
-    step_process INTEGER NOT NULL,
-    parent_step INTEGER,
-    FOREIGN KEY (owner) REFERENCES processes(id),
-    FOREIGN KEY (step_process) REFERENCES processes(id),
-    FOREIGN KEY (parent_step) REFERENCES process_steps(step_id)
-);
-CREATE TABLE process_titles (
-    parent INTEGER NOT NULL,
-    timestamp TEXT NOT NULL,
-    title TEXT NOT NULL,
-    PRIMARY KEY (parent, timestamp),
-    FOREIGN KEY (parent) REFERENCES processes(id)
-);
-CREATE TABLE processes (
-    id INTEGER PRIMARY KEY
-);
-CREATE TABLE todo_children (
-    parent INTEGER NOT NULL,
-    child INTEGER NOT NULL,
-    PRIMARY KEY (parent, child),
-    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_disables (
-    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_enables (
-    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 INTEGER NOT NULL,
-    is_done BOOLEAN NOT NULL,
-    day TEXT NOT NULL,
-    FOREIGN KEY (process) REFERENCES processes(id),
-    FOREIGN KEY (day) REFERENCES days(id)
-);
index 2aaccb027d613c3d960b634e1bf7747ff0627a2b..6f84c41524e31d1aabd689c71dd37550e094ca0c 100755 (executable)
@@ -1,7 +1,6 @@
 #!/bin/sh
 set -e
-# for dir in $(echo '.' 'plomtask' 'tests'); do
-for dir in $(echo 'tests'); do
+for dir in $(echo '.' 'plomtask' 'tests'); do
     echo "Running mypy on ${dir}/ …."
     python3 -m mypy --strict ${dir}/*.py
     echo "Running flake8 on ${dir}/ …"
diff --git a/templates/_base.html b/templates/_base.html
new file mode 100644 (file)
index 0000000..0070630
--- /dev/null
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+<meta charset="UTF-8">
+<style>
+body {
+  font-family: monospace;
+  text-align: left;
+  padding: 0;
+}
+input.btn-harmless {
+  color: green;
+}
+input.btn-dangerous {
+  color: red;
+}
+div.btn-to-right {
+  float: right;
+  text-align: right;
+}
+td, th, tr, table {
+  vertical-align: top;
+  padding: 0;
+}
+{% block css %}
+{% endblock %}
+</style>
+<body>
+<a href="processes">processes</a>
+<a href="conditions">conditions</a>
+<a href="day">today</a>
+<a href="calendar">calendar</a>
+<hr>
+{% block content %}
+{% endblock %}
+</body>
+</html>
diff --git a/templates/_macros.html b/templates/_macros.html
new file mode 100644 (file)
index 0000000..25d94c5
--- /dev/null
@@ -0,0 +1,55 @@
+{% macro edit_buttons() %}
+<input class="btn-harmless" type="submit" name="update" value="update" />
+<div class="btn-to-right">
+<input class="btn-dangerous" type="submit" name="delete" value="delete" />
+</div>
+{% endmacro %}
+
+
+
+{% macro datalist_of_titles(title, candidates) %}
+<datalist id="{{title}}">
+{% for candidate in candidates %}
+<option value="{{candidate.id_}}">{{candidate.title.newest|e}}</option>
+{% endfor %}
+</datalist>
+{% endmacro %}
+
+
+
+{% macro simple_checkbox_table(title, items, type_name, list_name, add_string="add") %}
+<table>
+{% for item in items %}
+<tr>
+<td>
+<input type="checkbox" name="{{title}}" value="{{item.id_}}" checked />
+</td>
+<td>
+<a href="{{type_name}}?id={{item.id_}}">{{item.title.newest|e}}</a>
+</td>
+</tr>
+{% endfor %}
+</table>
+{{add_string}}: <input name="{{title}}" list="{{list_name}}" autocomplete="off" />
+{% endmacro %}
+
+
+
+{% macro history_page(item_name, item, attribute_name, attribute, as_pre=false) %}
+<h3>{{item_name}} {{attribute_name}} history</h3>
+<table>
+
+<tr>
+<th>{{item_name}}</th>
+<td><a href="{{item_name}}?id={{item.id_}}">{{item.title.newest|e}}</a></td>
+</tr>
+
+{% for date in attribute.history.keys() | sort(reverse=True) %}
+<tr>
+<th>{{date | truncate(19, True, '') }}</th>
+<td>{% if as_pre %}<pre>{% endif %}{{attribute.history[date]}}{% if as_pre %}</pre>{% endif %}</td>
+</tr>
+{% endfor %}
+
+</table>
+{% endmacro %}
diff --git a/templates/base.html b/templates/base.html
deleted file mode 100644 (file)
index 87b1864..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!DOCTYPE html>
-<html>
-<meta charset="UTF-8">
-<style>
-body {
-  font-family: monospace;
-}
-input.btn-harmless {
-  color: green;
-}
-input.btn-dangerous {
-  color: red;
-}
-div.btn-to-right {
-  float: right;
-  text-align: right;
-}
-{% block css %}
-{% endblock %}
-</style>
-<body>
-<a href="processes">processes</a>
-<a href="conditions">conditions</a>
-<a href="day">today</a>
-<a href="calendar">calendar</a>
-<hr>
-{% block content %}
-{% endblock %}
-</body>
-</html>
index 3acdbc6fc2205e67e0c4e514130e4e33ad292e66..77242037982762100a20e21bf0d8b453877c7fe3 100644 (file)
@@ -1,14 +1,62 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+
+
+
+{% block css %}
+tr.week_row td {
+  height: 0.1em;
+  background-color: black;
+  padding: 0;
+  margin: 0;
+}
+tr.month_row td {
+  border: 0.1em solid black;
+  text-align: center;
+}
+td.day_name {
+  padding-right: 0.5em;
+}
+{% endblock %}
+
+
 
 {% block content %}
+<h3>calendar</h3>
+
 <form action="calendar" method="GET">
 from <input name="start" value="{{start}}" />
 to <input name="end" value="{{end}}" />
 <input type="submit" value="OK" />
 </form>
-<ul>
+<table>
 {% for day in days %}
-<li><a href="day?date={{day.date}}">{{day.date}}</a> ({{day.weekday}}) {{day.comment|e}}
+
+{% if day.first_of_month %}
+<tr class="month_row">
+<td colspan=3>{{ day.month_name }}</td>
+</tr>
+{% endif %}
+
+{% if day.weekday == "Monday" %}
+<tr class="week_row">
+<td colspan=3></td>
+</tr>
+{% endif %}
+
+<tr>
+<td class="day_name">{{day.weekday|truncate(2,True,'',0)}}</td>
+<td><a href="day?date={{day.date}}">{{day.date}}</a></td>
+<td>{{day.comment|e}}</td>
+</tr>
+
+{% for todo in day.calendarized_todos %}
+<tr>
+<td>[{% if todo.is_done %}X{% else %} {% endif %}]</td>
+<td><a href="todo?id={{todo.id_}}">{{todo.title.newest|e}}</td>
+<td>{{todo.comment|e}}</td>
+</tr>
+{% endfor %}
+
 {% endfor %}
-</ul>
+</table>
 {% endblock %}
index 92f04eb9688c2aad9992d810778f3780fad8d542..1fc5902025c7cc21a04a656880a973ed5b6ed312 100644 (file)
@@ -1,16 +1,29 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
 
 {% 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}}" />
-is active: <input name="is_active" type="checkbox" {% if condition.is_active %}checked{% endif %} />
+<table>
+
+<tr>
+<th>title</th>
+<td><input name="title" value="{{condition.title.newest|e}}" />{% if condition.id_ %} [<a href="condition_titles?id={{condition.id_}}">history</a>]{% endif %}</td>
+<tr/>
+
+<tr>
+<th>is active</th>
+<td><input name="is_active" type="checkbox" {% if condition.is_active %}checked{% endif %} /></td>
+<tr/>
 
-<input class="btn-harmless" type="submit" name="update" value="update" />
-<div class="btn-to-right">
-<input class="btn-dangerous" type="submit" name="delete" value="delete" />
-</div>
+<tr>
+<th>description</th>
+<td><textarea name="description">{{condition.description.newest|e}}</textarea>{% if condition.id_ %} [<a href="condition_descriptions?id={{condition.id_}}">history</a>]{% endif %}</td>
+<tr/>
 
+</table>
+{{ macros.edit_buttons() }}
 {% endblock %}
 
diff --git a/templates/condition_descriptions.html b/templates/condition_descriptions.html
new file mode 100644 (file)
index 0000000..17dd066
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+{{ macros.history_page("condition", condition, "description", condition.description, true) }}
+{% endblock %}
diff --git a/templates/condition_titles.html b/templates/condition_titles.html
new file mode 100644 (file)
index 0000000..6ab8ca9
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+{{ macros.history_page("condition", condition, "title", condition.title) }}
+{% endblock %}
index a717bf0b2fd887bc8adbf5a30abc4caed3c303d5..e8e9fed7fcf5a46b3b39b04ca134a54e94d29f05 100644 (file)
@@ -1,12 +1,23 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
 
 {% block content %}
-<a href="condition">add</a>
-<ul>
+<h3>conditions</h3>
+
+<table>
+<tr>
+<th><a href="?sort_by={% if sort_by == "is_active" %}-{% endif %}is_active">active</a></th>
+<th><a href="?sort_by={% if sort_by == "title" %}-{% endif %}title">title</a></th>
+</tr>
 {% for condition in conditions %}
-<li><a href="condition?id={{condition.id_}}">{{condition.title.newest}}</a>
+<tr>
+<td>[{% if condition.is_active %}X{% else %} {% endif %}]</td>
+<td><a href="condition?id={{condition.id_}}">{{condition.title.newest}}</a></td>
+</tr>
 {% endfor %}
-</ul>
-{% endblock %}
+</table>
 
+<p>
+<a href="condition">add</a>
+</p>
 
+{% endblock %}
index efa1c9bb1e78fc5ea455c5a678b4727fb22efdd3..4c77705b514b6aec0821b336fea107fcaea3cd1f 100644 (file)
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
 
 
-{% macro show_node(node, indent) %}
-{% if node.is_todo %}
-{% for i in range(indent) %}&nbsp; {% endfor %} +
-{% if node.seen %}({% else %}{% endif %}<a href="todo?id={{node.item.id_}}">{{node.item.process.title.newest|e}}</a>{% if node.seen %}){% else %}{% endif %}
-{% else %}
-{% for i in range(indent) %}&nbsp;{% endfor %} +
-{% if node.seen %}({% else %}{% endif %}<a href="condition?id={{node.item.id_}}">{{node.item.title.newest|e}}</a>{% if node.seen %}){% else %}{% endif %}
-{% endif %}
-{% endmacro %}
 
+{% block css %}
+td, th, tr, table {
+  padding: 0;
+  margin: 0;
+}
+th {
+  border: 1px solid black;
+}
+td.min_width {
+  min-width: 1em;
+}
+td.cond_line_0 {
+  background-color: #ffbbbb;
+}
+td.cond_line_1 {
+  background-color: #bbffbb;
+}
+td.cond_line_2 {
+  background-color: #bbbbff;
+}
+td.todo_line {
+  border-bottom: 1px solid #bbbbbb;
+}
+{% endblock %}
 
-{% macro undone_with_children(node, indent) %}
-{% if not node.hide %}
+
+
+{% macro show_node_undone(node, indent) %}
+{% if not node.todo.is_done %}
 <tr>
-<td>
-{% if node.is_todo %}
-<input name="done" value="{{node.item.id_}}" type="checkbox" {% if node.seen or not node.item.is_doable %}disabled{% endif %} {% if node.item.is_done %} checked {% endif %} />
-{% endif %}
-</td>
-<td>
-{{ show_node(node, indent) }}
+<input type="hidden" name="todo_id" value="{{node.todo.id_}}" />
+
+{% for condition in conditions_present %}
+<td class="cond_line_{{loop.index0 % 3}} {% if not condition.is_active %}min_width{% endif %}">{% if condition in node.todo.conditions %}{% if not condition.is_active %}O{% endif %}{% endif %}</td>
+{% endfor %}
+
+<td class="todo_line">-&gt;</td>
+<td class="todo_line"><input name="done" type="checkbox" value="{{node.todo.id_}}" {% if node.todo.is_done %}checked disabled{% endif %} {% if not node.todo.is_doable %}disabled{% endif %}/></td>
+<td class="todo_line"><input name="effort" type="number" step=0.1 size=5 placeholder={{node.todo.process.effort.newest }} value={{node.todo.effort}} /></td>
+<td class="todo_line">
+{% for i in range(indent) %}&nbsp; {% endfor %} +
+{% if node.seen %}({% endif %}<a href="todo?id={{node.todo.id_}}">{{node.todo.process.title.newest|e}}</a>{% if node.seen %}){% endif %}
 </td>
+<td class="todo_line">-&gt;</td>
+
+{% for condition in conditions_present|reverse %}
+<td class="cond_line_{{(conditions_present|length - loop.index) % 3}} {% if condition in node.todo.enables or condition in node.todo.disables %}min_width{% endif %}">{% if condition in node.todo.enables %}+{% elif condition in node.todo.disables %}!{% endif %}</td>
+{% endfor %}
+
+<td><input name="comment" value="{{node.todo.comment|e}}" /></td>
+
 </tr>
 {% endif %}
+
+{% if not node.seen %}
 {% for child in node.children %}
-{{ undone_with_children(child, indent+1) }}
+{{ show_node_undone(child, indent+1) }}
 {% endfor %}
+{% endif %}
+
 {% endmacro %}
 
 
-{% macro done_with_children(node, indent) %}
-{% if not node.hide %}
+
+{% macro show_node_done(node, indent, path) %}
+{% if node.todo.is_done %}
+
 <tr>
+{% if path|length > 0 and not path[-1].todo.is_done %}
 <td>
-{{ show_node(node, indent) }}
+({% for path_node in path %}<a href="todo?id={{path_node.todo.id_}}">{{path_node.todo.process.title.newest|e}}</a>  &lt;- {% endfor %})
 </td>
 </tr>
+
+<tr>
+<td>
+&nbsp; +
+{% else %}
+<td>
+{% for i in range(indent) %}&nbsp; {% endfor %} +
+{% endif %}
+{% if node.seen %}({% endif %}<a href="todo?id={{node.todo.id_}}">{{node.todo.process.title.newest|e}}</a> {% if node.todo.comment|length > 0 %}[{{node.todo.comment|e}}]{% endif %}{% if node.seen %}){% endif %}
+</td>
+</tr>
+
 {% endif %}
+{% if not node.seen %}
 {% for child in node.children %}
-{{ done_with_children(child, indent+1) }}
+{{ show_node_done(child, indent+1, path + [node]) }}
 {% endfor %}
+{% endif %}
+
 {% endmacro %}
 
 
+
 {% block content %}
 <h3>{{day.date}} / {{day.weekday}}</h3>
 <p>
 <a href="day?date={{day.prev_date}}">prev</a> | <a href="day?date={{day.next_date}}">next</a>
 </p>
 <form action="day?date={{day.date}}" method="POST">
-comment: <input name="comment" value="{{day.comment|e}}" />
+comment: <input name="day_comment" value="{{day.comment|e}}" />
 <input type="submit" value="OK" /><br />
 add todo: <input name="new_todo" list="processes" autocomplete="off" />
-<datalist id="processes">
-{% for process in processes %}
-<option value="{{process.id_}}">{{process.title.newest|e}}</option>
+
+<h4>todo</h4>
+
+<table>
+
+<tr>
+<th colspan={{ conditions_present|length}}>c</th>
+<th colspan=5>states</th>
+<th colspan={{ conditions_present|length}}>t</th>
+<th>add enabler</th>
+</tr>
+
+{% for condition in conditions_present %}
+{% set outer_loop = loop %}
+<tr>
+
+{% for _ in conditions_present %}
+{% if outer_loop.index > loop.index %}
+<td class="cond_line_{{loop.index0 % 3}}">
+{% else %}
+<td class="cond_line_{{outer_loop.index0 % 3}}">
+{% endif %}
+{% if outer_loop.index == loop.index  %}
+{% endif %}
+</td>
+{% endfor %}
+
+<td class="cond_line_{{loop.index0 % 3}}">[{% if condition.is_active %}X{% else %}&nbsp;{% endif %}]</td>
+<td colspan=4 class="cond_line_{{loop.index0 % 3}}"><a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a></td>
+
+{% for _ in conditions_present %}
+{% if outer_loop.index0 + loop.index0 < conditions_present|length %}
+<td class="cond_line_{{outer_loop.index0 % 3}}">
+{% else %}
+<td class="cond_line_{{(conditions_present|length - loop.index) % 3}}">
+{% endif %}
 {% endfor %}
-</datalist>
-<h4>conditions</h4>
-<ul>
-{% for node in condition_listings %}
-<li>[{% if node.condition.is_active %}x{% else %} {% endif %}] <a href="condition?id={{node.condition.id_}}">{{node.condition.title.newest|e}}</a>
-({% for enabler in node.enablers %}
-&lt; {{enabler.process.title.newest|e}};
+{% set list_name = "todos_for_%s"|format(condition.id_) %}
+<td><input name="new_todo" list="{{list_name}}" autocomplete="off" /></td>
+{{ macros.datalist_of_titles(list_name, enablers_for[condition.id_]) }}
+</td>
+</tr>
 {% endfor %}
-{% for disabler in node.disablers %}
-! {{disabler.process.title.newest|e}};
-{% endfor %})
+
+<tr>
+{% for condition in conditions_present %}
+<td class="cond_line_{{loop.index0 % 3}}"></td>
 {% endfor %}
-</ul>
-<h4>to do</h4>
-<table>
-{% for node in todo_trees %}
-{{ undone_with_children(node, indent=0) }}
+<th colspan=5>doables</th>
+{% for condition in conditions_present %}
+<td class="cond_line_{{(conditions_present|length - loop.index) % 3}}"></td>
 {% endfor %}
+<th>comments</th>
+</tr>
+{% for node in top_nodes %}
+{{ show_node_undone(node, 0) }}
+{% endfor %}
+
 </table>
+
 <h4>done</h4>
+
 <table>
-{% for node in done_trees %}
-{{ done_with_children(node, indent=0) }}
+{% for node in top_nodes %}
+{{ show_node_done(node, 0, []) }}
 {% endfor %}
 </table>
+
 </form>
-{% endblock %}
 
+{{ macros.datalist_of_titles("processes", processes) }}
+{% endblock %}
index 90cd61c15a25c66944b0e6a4bd967768b28e4a0e..3672f21346194c75c702625e20a792e5c9fe1db3 100644 (file)
@@ -1,4 +1,6 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+
+
 
 {% block content %}
 <p>{{msg}}</p>
index 2a577152720c2101353767182f443d9d7fd11874..6dea4937527248d1edbe38911723895bc2399e94 100644 (file)
@@ -1,4 +1,5 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
 
 
 
@@ -21,7 +22,7 @@
 </td>
 <td>
 {% if step_node.is_explicit %}
-add step: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="off" />
+add: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="off" />
 {% endif %}
 </td>
 </tr>
@@ -38,72 +39,42 @@ add step: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="
 <h3>process</h3>
 <form action="process?id={{process.id_ or ''}}" method="POST">
 <table>
+
 <tr>
 <th>title</th>
-<td><input name="title" value="{{process.title.newest|e}}" /></td>
+<td><input name="title" value="{{process.title.newest|e}}" />{% if process.id_ %} [<a href="process_titles?id={{process.id_}}">history</a>]{% endif %}</td>
 </tr>
+
 <tr>
 <th>default effort</th>
-<td><input name="effort" type="number" step=0.1 value={{process.effort.newest}} /></td>
+<td><input name="effort" type="number" step=0.1 value={{process.effort.newest}} />{% if process.id_ %} [<a href="process_efforts?id={{process.id_}}">history</a>]{% endif %}</td>
 </tr>
+
 <tr>
 <th>description</th>
-<td><textarea name="description">{{process.description.newest|e}}</textarea></td>
+<td><textarea name="description">{{process.description.newest|e}}</textarea><br />{% if process.id_ %} [<a href="process_descriptions?id={{process.id_}}">history</a>]{% endif %}</td>
 </tr>
+
 <tr>
-<th>conditions</th>
-<td>
-<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>
+<th>calendarize</th>
+<td><input type="checkbox" name="calendarize" {% if process.calendarize %}checked {% endif %}</td>
 </tr>
-{% endfor %}
-</table>
-add condition: <input name="condition" list="condition_candidates" autocomplete="off" />
-</td>
+
+<tr>
+<th>conditions</th>
+<td>{{ macros.simple_checkbox_table("condition", process.conditions, "condition", "condition_candidates") }}</td>
 </tr>
+
 <tr>
 <th>enables</th>
-<td>
-<table>
-{% for condition in process.enables %}
-<tr>
-<td>
-<input type="checkbox" name="enables" value="{{condition.id_}}" checked />
-</td>
-<td>
-<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
-</td>
-</tr>
-{% endfor %}
-</table>
-add enables: <input name="enables" list="condition_candidates" autocomplete="off" />
-</td>
+<td>{{ macros.simple_checkbox_table("enables", process.enables, "condition", "condition_candidates") }}</td>
 </tr>
+
 <tr>
 <th>disables</th>
-<td>
-<table>
-{% for condition in process.disables %}
-<tr>
-<td>
-<input type="checkbox" name="disables" value="{{condition.id_}}" checked />
-</td>
-<td>
-<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
-</td>
-</tr>
-{% endfor %}
-</table>
-add disables: <input name="disables" list="condition_candidates" autocomplete="off" />
-</td>
+<td>{{ macros.simple_checkbox_table("disables", process.disables, "condition", "condition_candidates") }}</td>
 </tr>
+
 <tr>
 <th>steps</th>
 <td>
@@ -112,29 +83,23 @@ add disables: <input name="disables" list="condition_candidates" autocomplete="o
 {{ step_with_steps(step_id, step_node, 0) }}
 {% endfor %}
 </table>
-add step: <input name="new_top_step" list="step_candidates" autocomplete="off" />
+add: <input name="new_top_step" list="step_candidates" autocomplete="off" />
 </td>
 <tr>
-</table>
-<datalist id="condition_candidates">
-{% for condition_candidate in condition_candidates %}
-<option value="{{condition_candidate.id_}}">{{condition_candidate.title.newest|e}}</option>
-{% endfor %}
-</datalist>
-<datalist id="step_candidates">
-{% for candidate in step_candidates %}
-<option value="{{candidate.id_}}">{{candidate.title.newest|e}}</option>
-{% endfor %}
-</datalist>
-<input class="btn-harmless" type="submit" name="update" value="update" />
-<div class="btn-to-right">
-<input class="btn-dangerous" type="submit" name="delete" value="delete" />
-</div>
-</form>
-<h4>step of</h4>
-<ul>
+
+<tr>
+<th>step of</th>
+<td>
 {% for owner in owners %}
-<li><a href="process?id={{owner.id_}}">{{owner.title.newest|e}}</a>
+<a href="process?id={{owner.id_}}">{{owner.title.newest|e}}</a><br />
 {% endfor %}
-</ul>
+</td>
+<tr>
+
+</table>
+{{ macros.edit_buttons() }}
+</form>
+
+{{ macros.datalist_of_titles("condition_candidates", condition_candidates) }}
+{{ macros.datalist_of_titles("step_candidates", step_candidates) }}
 {% endblock %}
diff --git a/templates/process_descriptions.html b/templates/process_descriptions.html
new file mode 100644 (file)
index 0000000..15899b1
--- /dev/null
@@ -0,0 +1,9 @@
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+{{ macros.history_page("process", process, "description", process.description, as_pre=true) }}
+{% endblock %}
+
diff --git a/templates/process_efforts.html b/templates/process_efforts.html
new file mode 100644 (file)
index 0000000..579ded1
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+{{ macros.history_page("process", process, "effort", process.effort) }}
+{% endblock %}
diff --git a/templates/process_titles.html b/templates/process_titles.html
new file mode 100644 (file)
index 0000000..dd53a62
--- /dev/null
@@ -0,0 +1,8 @@
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+{{ macros.history_page("process", process, "title", process.title) }}
+{% endblock %}
index 6dc3e85c5690b38438dbe222c6c1dec6aeaff2f4..977ac405ac89c57fd66d65bf8dd71d7c78cbfb67 100644 (file)
@@ -1,11 +1,22 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
 
 {% block content %}
-<a href="process">add</a>
-<ul>
+<h3>processes</h3>
+
+<table>
+<tr>
+<th><a href="?sort_by={% if sort_by == "steps" %}-{% endif %}steps">steps</a></th>
+<th><a href="?sort_by={% if sort_by == "title" %}-{% endif %}title">title</a></th>
+</tr>
 {% for process in processes %}
-<li><a href="process?id={{process.id_}}">{{process.title.newest}}</a>
+<tr>
+<td>{{ process.explicit_steps|count }}</td>
+<td><a href="process?id={{process.id_}}">{{process.title.newest}}</a></td>
+</tr>
 {% endfor %}
-</ul>
-{% endblock %}
+</table>
 
+<p>
+<a href="process">add</a>
+</p>
+{% endblock %}
index 41a9eb1c876bafb1d2b4f485653b80a5445a947e..efaabdd2a4de4f6aba490e1fe77b618782434104 100644 (file)
@@ -1,85 +1,76 @@
-{% extends 'base.html' %}
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
 
 {% 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.date}}">{{todo.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>
+<th>day</th>
+<td><a href="day?date={{todo.date}}">{{todo.date}}</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>enables</h4>
-<table>
-{% for condition in todo.enables %}
+
 <tr>
-<td>
-<input type="checkbox" name="enables" value="{{condition.id_}}" checked />
-</td>
-<td>
-<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
-</td>
+<th>process</th>
+<td><a href="process?id={{todo.process.id_}}">{{todo.process.title.newest|e}}</a></td>
 </tr>
-{% endfor %}
-</table>
-add enables: <input name="enables" list="condition_candidates" autocomplete="off" />
-<h4>disables</h4>
-<table>
-{% for condition in todo.disables%}
+
 <tr>
-<td>
-<input type="checkbox" name="disables" value="{{condition.id_}}" checked />
-</td>
-<td>
-<a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a>
-</td>
+<th>done</th>
+<td><input type="checkbox" name="done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/><br /></td>
 </tr>
-{% endfor %}
-</table>
-add disables: <input name="disables" list="condition_candidates" autocomplete="off" />
-<h4>parents</h4>
-<ul>
+
+<tr>
+<th>effort</th>
+<td><input type="number" name="effort" step=0.1 size=5 placeholder={{todo.process.effort.newest }} value={{ todo.effort }} /><br /></td>
+</tr>
+
+<tr>
+<th>comment</th>
+<td><input name="comment" value="{{todo.comment|e}}"/></td>
+</tr>
+
+<tr>
+<th>calendarize</th>
+<td><input type="checkbox" name="calendarize" {% if todo.calendarize %}checked {% endif %}</td>
+</tr>
+
+<tr>
+<th>conditions</th>
+<td>{{ macros.simple_checkbox_table("condition", todo.conditions, "condition", "condition_candidates") }}</td>
+</tr>
+
+<tr>
+<th>enables</th>
+<td>{{ macros.simple_checkbox_table("enables", todo.enables, "condition", "condition_candidates") }}</td>
+</tr>
+
+<tr>
+<th>disables</th>
+<td>{{ macros.simple_checkbox_table("disables", todo.disables, "condition", "condition_candidates") }}</td>
+</tr>
+
+<tr>
+<th>parents</th>
+<td>
 {% 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><input type="checkbox" name="adopt" value="{{child.id_}}" checked />
-<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>
+<a href="todo?id={{parent.id_}}">{{parent.process.title.newest|e}}</a><br />
 {% endfor %}
-</datalist>
+</td>
+</tr>
+
+<tr>
+<th>children</th>
+<td>{{ macros.simple_checkbox_table("adopt", todo.children, "adopt", "todo_candidates", "adopt") }}</td>
+</tr>
 
-<input class="btn-harmless" type="submit" name="update" value="update" />
-<div class="btn-to-right">
-<input class="btn-dangerous" type="submit" name="delete" value="delete" />
-</div>
+</table>
+{{ macros.edit_buttons() }}
+</form>
 
-</form
+{{ macros.datalist_of_titles("condition_candidates", condition_candidates) }}
+{{ macros.datalist_of_titles("todo_candidates", todo_candidates) }}
 {% endblock %}
diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc
deleted file mode 100644 (file)
index 61bbc59..0000000
Binary files a/tests/__pycache__/__init__.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/conditions.cpython-311.pyc b/tests/__pycache__/conditions.cpython-311.pyc
deleted file mode 100644 (file)
index d671a08..0000000
Binary files a/tests/__pycache__/conditions.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/days.cpython-311.pyc b/tests/__pycache__/days.cpython-311.pyc
deleted file mode 100644 (file)
index fcbd24a..0000000
Binary files a/tests/__pycache__/days.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/misc.cpython-311.pyc b/tests/__pycache__/misc.cpython-311.pyc
deleted file mode 100644 (file)
index edb0671..0000000
Binary files a/tests/__pycache__/misc.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/processes.cpython-311.pyc b/tests/__pycache__/processes.cpython-311.pyc
deleted file mode 100644 (file)
index 4f4c7f1..0000000
Binary files a/tests/__pycache__/processes.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/test_days.cpython-311.pyc b/tests/__pycache__/test_days.cpython-311.pyc
deleted file mode 100644 (file)
index 61f606c..0000000
Binary files a/tests/__pycache__/test_days.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/todos.cpython-311.pyc b/tests/__pycache__/todos.cpython-311.pyc
deleted file mode 100644 (file)
index 6aa0815..0000000
Binary files a/tests/__pycache__/todos.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/utils.cpython-311.pyc b/tests/__pycache__/utils.cpython-311.pyc
deleted file mode 100644 (file)
index 5c85fd8..0000000
Binary files a/tests/__pycache__/utils.cpython-311.pyc and /dev/null differ
diff --git a/tests/__pycache__/versioned_attributes.cpython-311.pyc b/tests/__pycache__/versioned_attributes.cpython-311.pyc
deleted file mode 100644 (file)
index 7a33722..0000000
Binary files a/tests/__pycache__/versioned_attributes.cpython-311.pyc and /dev/null differ
index 45c3df7cfff43fe0f365216e7b6555d9299fefb0..c9b516418f73bf3700f213bc36d0c00235bab5dc 100644 (file)
@@ -9,32 +9,15 @@ from plomtask.exceptions import HandledException
 class TestsSansDB(TestCaseSansDB):
     """Tests requiring no DB setup."""
     checked_class = Condition
-
-    def test_Condition_id_setting(self) -> None:
-        """Test .id_ being set and its legal range being enforced."""
-        self.check_id_setting()
-
-    def test_Condition_versioned_defaults(self) -> None:
-        """Test defaults of VersionedAttributes."""
-        self.check_versioned_defaults({
-            'title': 'UNNAMED',
-            'description': ''})
+    do_id_test = True
+    versioned_defaults_to_test = {'title': 'UNNAMED', 'description': ''}
 
 
 class TestsWithDB(TestCaseWithDB):
     """Tests requiring DB, but not server setup."""
     checked_class = Condition
-
-    def test_Condition_saving_and_caching(self) -> None:
-        """Test .save/.save_core."""
-        kwargs = {'id_': 1, 'is_active': False}
-        self.check_saving_and_caching(**kwargs)
-        # check .id_ set if None, and versioned attributes too
-        c = Condition(None)
-        c.save(self.db_conn)
-        self.assertEqual(c.id_, 2)
-        self.check_saving_of_versioned('title', str)
-        self.check_saving_of_versioned('description', str)
+    default_init_kwargs = {'is_active': False}
+    test_versioneds = {'title': str, 'description': str}
 
     def test_Condition_from_table_row(self) -> None:
         """Test .from_table_row() properly reads in class from DB"""
index 1f0e55d871b282881dc0fee2f7e2fc91a591d243..9e12d3ff665550f3fb23bb603e639ce8efbf60ff 100644 (file)
@@ -42,8 +42,12 @@ class TestsWithDB(TestCaseWithDB):
     checked_class = Day
     default_ids = ('2024-01-01', '2024-01-02', '2024-01-03')
 
-    def test_Day_saving_and_caching(self) -> None:
-        """Test .save/.save_core."""
+    def test_saving_and_caching(self) -> None:
+        """Test storage of instances.
+
+        We don't use the parent class's method here because the checked class
+        has too different a handling of IDs.
+        """
         kwargs = {'date': self.default_ids[0], 'comment': 'foo'}
         self.check_saving_and_caching(**kwargs)
 
@@ -96,7 +100,7 @@ class TestsWithDB(TestCaseWithDB):
 
     def test_Day_singularity(self) -> None:
         """Test pointers made for single object keep pointing to it."""
-        self.check_singularity('comment', 'boo')
+        self.check_singularity('day_comment', 'boo')
 
 
 class TestsWithServer(TestCaseWithServer):
@@ -115,7 +119,7 @@ class TestsWithServer(TestCaseWithServer):
 
     def test_do_POST_day(self) -> None:
         """Test POST /day."""
-        form_data = {'comment': ''}
+        form_data = {'day_comment': ''}
         self.check_post(form_data, '/day', 400)
         self.check_post(form_data, '/day?date=foo', 400)
         self.check_post(form_data, '/day?date=2024-01-01', 302)
index 9e769c1e3da6acafa81b3d529d040aba4a5c5251..578d545a9d1004c53121a2f2b785b434e791856d 100644 (file)
@@ -9,31 +9,22 @@ from plomtask.todos import Todo
 class TestsSansDB(TestCaseSansDB):
     """Module tests not requiring DB setup."""
     checked_class = Process
-
-    def test_Process_id_setting(self) -> None:
-        """Test .id_ being set and its legal range being enforced."""
-        self.check_id_setting()
-
-    def test_Process_versioned_defaults(self) -> None:
-        """Test defaults of VersionedAttributes."""
-        self.check_versioned_defaults({
-            'title': 'UNNAMED',
-            'description': '',
-            'effort': 1.0})
+    do_id_test = True
+    versioned_defaults_to_test = {'title': 'UNNAMED', 'description': '',
+                                  'effort': 1.0}
 
 
 class TestsSansDBProcessStep(TestCaseSansDB):
     """Module tests not requiring DB setup."""
     checked_class = ProcessStep
-
-    def test_ProcessStep_id_setting(self) -> None:
-        """Test .id_ being set and its legal range being enforced."""
-        self.check_id_setting(2, 3, 4)
+    do_id_test = True
+    default_init_args = [2, 3, 4]
 
 
 class TestsWithDB(TestCaseWithDB):
     """Module tests requiring DB setup."""
     checked_class = Process
+    test_versioneds = {'title': str, 'description': str, 'effort': float}
 
     def three_processes(self) -> tuple[Process, Process, Process]:
         """Return three saved processes."""
@@ -64,13 +55,8 @@ class TestsWithDB(TestCaseWithDB):
         p.save(self.db_conn)
         return p, set_1, set_2, set_3
 
-    def test_Process_saving_and_caching(self) -> None:
+    def test_Process_conditions_saving(self) -> None:
         """Test .save/.save_core."""
-        kwargs = {'id_': 1}
-        self.check_saving_and_caching(**kwargs)
-        self.check_saving_of_versioned('title', str)
-        self.check_saving_of_versioned('description', str)
-        self.check_saving_of_versioned('effort', float)
         p, set1, set2, set3 = self.p_of_conditions()
         p.uncache()
         r = Process.by_id(self.db_conn, p.id_)
@@ -203,14 +189,8 @@ class TestsWithDB(TestCaseWithDB):
 class TestsWithDBForProcessStep(TestCaseWithDB):
     """Module tests requiring DB setup."""
     checked_class = ProcessStep
-
-    def test_ProcessStep_saving_and_caching(self) -> None:
-        """Test .save/.save_core."""
-        kwargs = {'id_': 1,
-                  'owner_id': 2,
-                  'step_process_id': 3,
-                  'parent_step_id': 4}
-        self.check_saving_and_caching(**kwargs)
+    default_init_kwargs = {'owner_id': 2, 'step_process_id': 3,
+                           'parent_step_id': 4}
 
     def test_ProcessStep_from_table_row(self) -> None:
         """Test .from_table_row() properly reads in class from DB"""
index 7fabfdb4bcb6c0761b40f7159583141e5f38ba09..059bd9f4fb47fac79a710b8421995f3f4b869da1 100644 (file)
@@ -1,6 +1,6 @@
 """Test Todos module."""
 from tests.utils import TestCaseWithDB, TestCaseWithServer
-from plomtask.todos import Todo, TodoStepsNode
+from plomtask.todos import Todo, TodoNode
 from plomtask.processes import Process
 from plomtask.conditions import Condition
 from plomtask.exceptions import (NotFoundException, BadFormatException,
@@ -9,6 +9,9 @@ from plomtask.exceptions import (NotFoundException, BadFormatException,
 
 class TestsWithDB(TestCaseWithDB):
     """Tests requiring DB, but not server setup."""
+    checked_class = Todo
+    default_init_kwargs = {'process': None, 'is_done': False,
+                           'date': '2024-01-01'}
 
     def setUp(self) -> None:
         super().setUp()
@@ -20,14 +23,31 @@ class TestsWithDB(TestCaseWithDB):
         self.cond1.save(self.db_conn)
         self.cond2 = Condition(None)
         self.cond2.save(self.db_conn)
+        self.default_init_kwargs['process'] = self.proc
 
-    def test_Todo_by_id(self) -> None:
-        """Test creation and findability of Todos."""
-        process_unsaved = Process(None)
+    def test_Todo_init(self) -> None:
+        """Test creation of Todo and what they default to."""
+        process = Process(None)
         with self.assertRaises(NotFoundException):
-            todo = Todo(None, process_unsaved, False, self.date1)
-        process_unsaved.save(self.db_conn)
-        todo = Todo(None, process_unsaved, False, self.date1)
+            Todo(None, process, False, self.date1)
+        process.save(self.db_conn)
+        assert isinstance(self.cond1.id_, int)
+        assert isinstance(self.cond2.id_, int)
+        process.set_conditions(self.db_conn, [self.cond1.id_, self.cond2.id_])
+        process.set_enables(self.db_conn, [self.cond1.id_])
+        process.set_disables(self.db_conn, [self.cond2.id_])
+        todo_no_id = Todo(None, process, False, self.date1)
+        self.assertEqual(todo_no_id.conditions, [self.cond1, self.cond2])
+        self.assertEqual(todo_no_id.enables, [self.cond1])
+        self.assertEqual(todo_no_id.disables, [self.cond2])
+        todo_yes_id = Todo(5, process, False, self.date1)
+        self.assertEqual(todo_yes_id.conditions, [])
+        self.assertEqual(todo_yes_id.enables, [])
+        self.assertEqual(todo_yes_id.disables, [])
+
+    def test_Todo_by_id(self) -> None:
+        """Test findability of Todos."""
+        todo = Todo(1, self.proc, False, self.date1)
         todo.save(self.db_conn)
         self.assertEqual(Todo.by_id(self.db_conn, 1), todo)
         with self.assertRaises(NotFoundException):
@@ -45,29 +65,6 @@ class TestsWithDB(TestCaseWithDB):
         self.assertEqual(Todo.by_date(self.db_conn, self.date2), [])
         self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
 
-    def test_Todo_from_process(self) -> None:
-        """Test spawning of Todo attributes from Process."""
-        assert isinstance(self.cond1.id_, int)
-        assert isinstance(self.cond2.id_, int)
-        self.proc.set_conditions(self.db_conn, [self.cond1.id_])
-        todo = Todo(None, self.proc, False, self.date1)
-        self.assertEqual(todo.conditions, [self.cond1])
-        todo.set_conditions(self.db_conn, [self.cond2.id_])
-        self.assertEqual(todo.conditions, [self.cond2])
-        self.assertEqual(self.proc.conditions, [self.cond1])
-        self.proc.set_enables(self.db_conn, [self.cond1.id_])
-        todo = Todo(None, self.proc, False, self.date1)
-        self.assertEqual(todo.enables, [self.cond1])
-        todo.set_enables(self.db_conn, [self.cond2.id_])
-        self.assertEqual(todo.enables, [self.cond2])
-        self.assertEqual(self.proc.enables, [self.cond1])
-        self.proc.set_disables(self.db_conn, [self.cond1.id_])
-        todo = Todo(None, self.proc, False, self.date1)
-        self.assertEqual(todo.disables, [self.cond1])
-        todo.set_disables(self.db_conn, [self.cond2.id_])
-        self.assertEqual(todo.disables, [self.cond2])
-        self.assertEqual(self.proc.disables, [self.cond1])
-
     def test_Todo_on_conditions(self) -> None:
         """Test effect of Todos on Conditions."""
         assert isinstance(self.cond1.id_, int)
@@ -83,40 +80,6 @@ class TestsWithDB(TestCaseWithDB):
         self.assertEqual(self.cond1.is_active, True)
         self.assertEqual(self.cond2.is_active, False)
 
-    def test_Todo_enablers_disablers(self) -> None:
-        """Test Todo.enablers_for_at/disablers_for_at."""
-        assert isinstance(self.cond1.id_, int)
-        assert isinstance(self.cond2.id_, int)
-        todo1 = Todo(None, self.proc, False, self.date1)
-        todo1.save(self.db_conn)
-        todo1.set_enables(self.db_conn, [self.cond1.id_])
-        todo1.set_disables(self.db_conn, [self.cond2.id_])
-        todo1.save(self.db_conn)
-        todo2 = Todo(None, self.proc, False, self.date1)
-        todo2.save(self.db_conn)
-        todo2.set_enables(self.db_conn, [self.cond2.id_])
-        todo2.save(self.db_conn)
-        todo3 = Todo(None, self.proc, False, self.date2)
-        todo3.save(self.db_conn)
-        todo3.set_enables(self.db_conn, [self.cond2.id_])
-        todo3.save(self.db_conn)
-        enablers = Todo.enablers_for_at(self.db_conn, self.cond1, self.date1)
-        self.assertEqual(enablers, [todo1])
-        enablers = Todo.enablers_for_at(self.db_conn, self.cond1, self.date2)
-        self.assertEqual(enablers, [])
-        disablers = Todo.disablers_for_at(self.db_conn, self.cond1, self.date1)
-        self.assertEqual(disablers, [])
-        disablers = Todo.disablers_for_at(self.db_conn, self.cond1, self.date2)
-        self.assertEqual(disablers, [])
-        enablers = Todo.enablers_for_at(self.db_conn, self.cond2, self.date1)
-        self.assertEqual(enablers, [todo2])
-        enablers = Todo.enablers_for_at(self.db_conn, self.cond2, self.date2)
-        self.assertEqual(enablers, [todo3])
-        disablers = Todo.disablers_for_at(self.db_conn, self.cond2, self.date1)
-        self.assertEqual(disablers, [todo1])
-        disablers = Todo.disablers_for_at(self.db_conn, self.cond2, self.date2)
-        self.assertEqual(disablers, [])
-
     def test_Todo_children(self) -> None:
         """Test Todo.children relations."""
         todo_1 = Todo(None, self.proc, False, self.date1)
@@ -158,56 +121,37 @@ class TestsWithDB(TestCaseWithDB):
 
     def test_Todo_step_tree(self) -> None:
         """Test self-configuration of TodoStepsNode tree for Day view."""
-        assert isinstance(self.cond1.id_, int)
-        assert isinstance(self.cond2.id_, int)
         todo_1 = Todo(None, self.proc, False, self.date1)
         todo_1.save(self.db_conn)
         assert isinstance(todo_1.id_, int)
         # test minimum
-        node_0 = TodoStepsNode(todo_1, True, [], False, False)
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
+        node_0 = TodoNode(todo_1, False, [])
+        self.assertEqual(todo_1.get_step_tree(set()), node_0)
         # test non_emtpy seen_todo does something
         node_0.seen = True
-        self.assertEqual(todo_1.get_step_tree({todo_1.id_}, set()), node_0)
+        self.assertEqual(todo_1.get_step_tree({todo_1.id_}), node_0)
         # test child shows up
         todo_2 = Todo(None, self.proc, False, self.date1)
         todo_2.save(self.db_conn)
         assert isinstance(todo_2.id_, int)
         todo_1.add_child(todo_2)
-        node_2 = TodoStepsNode(todo_2, True, [], False, False)
+        node_2 = TodoNode(todo_2, False, [])
         node_0.children = [node_2]
         node_0.seen = False
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
+        self.assertEqual(todo_1.get_step_tree(set()), node_0)
         # test child shows up with child
         todo_3 = Todo(None, self.proc, False, self.date1)
         todo_3.save(self.db_conn)
         assert isinstance(todo_3.id_, int)
         todo_2.add_child(todo_3)
-        node_3 = TodoStepsNode(todo_3, True, [], False, False)
+        node_3 = TodoNode(todo_3, False, [])
         node_2.children = [node_3]
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
+        self.assertEqual(todo_1.get_step_tree(set()), node_0)
         # test same todo can be child-ed multiple times at different locations
         todo_1.add_child(todo_3)
-        node_4 = TodoStepsNode(todo_3, True, [], True, False)
+        node_4 = TodoNode(todo_3, True, [])
         node_0.children += [node_4]
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
-        # test condition shows up
-        todo_1.set_conditions(self.db_conn, [self.cond1.id_])
-        node_5 = TodoStepsNode(self.cond1, False, [], False, False)
-        node_0.children += [node_5]
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
-        # test second condition shows up
-        todo_2.set_conditions(self.db_conn, [self.cond2.id_])
-        node_6 = TodoStepsNode(self.cond2, False, [], False, False)
-        node_2.children += [node_6]
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
-        # test second condition is not hidden if fulfilled by non-sibling
-        todo_1.set_enables(self.db_conn, [self.cond2.id_])
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
-        # test second condition is hidden if fulfilled by sibling
-        todo_3.set_enables(self.db_conn, [self.cond2.id_])
-        node_2.children.remove(node_6)
-        self.assertEqual(todo_1.get_step_tree(set(), set()), node_0)
+        self.assertEqual(todo_1.get_step_tree(set()), node_0)
 
     def test_Todo_unsatisfied_steps(self) -> None:
         """Test options of satisfying unfulfilled Process.explicit_steps."""
@@ -257,14 +201,7 @@ class TestsWithDB(TestCaseWithDB):
 
     def test_Todo_singularity(self) -> None:
         """Test pointers made for single object keep pointing to it."""
-        todo = Todo(None, self.proc, False, self.date1)
-        todo.save(self.db_conn)
-        retrieved_todo = Todo.by_id(self.db_conn, 1)
-        todo.is_done = True
-        self.assertEqual(retrieved_todo.is_done, True)
-        retrieved_todo = Todo.by_date(self.db_conn, self.date1)[0]
-        retrieved_todo.is_done = False
-        self.assertEqual(todo.is_done, False)
+        self.check_singularity('is_done', True, self.proc, False, self.date1)
 
     def test_Todo_remove(self) -> None:
         """Test removal."""
@@ -292,7 +229,7 @@ class TestsWithServer(TestCaseWithServer):
         self.post_process(2)
         proc = Process.by_id(self.db_conn, 1)
         proc2 = Process.by_id(self.db_conn, 2)
-        form_data = {'comment': ''}
+        form_data = {'day_comment': ''}
         self.check_post(form_data, '/day?date=2024-01-01', 302)
         self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
         form_data['new_todo'] = str(proc.id_)
@@ -319,7 +256,7 @@ class TestsWithServer(TestCaseWithServer):
             return Todo.by_date(self.db_conn, '2024-01-01')[0]
         # test minimum
         self.post_process()
-        self.check_post({'comment': '', 'new_todo': 1},
+        self.check_post({'day_comment': '', 'new_todo': 1},
                         '/day?date=2024-01-01', 302)
         # test posting to bad URLs
         self.check_post({}, '/todo=', 404)
@@ -342,7 +279,7 @@ class TestsWithServer(TestCaseWithServer):
         self.check_post({'adopt': 1}, '/todo?id=1', 400)
         self.check_post({'adopt': 2}, '/todo?id=1', 404)
         # test posting second todo of same process
-        self.check_post({'comment': '', 'new_todo': 1},
+        self.check_post({'day_comment': '', 'new_todo': 1},
                         '/day?date=2024-01-01', 302)
         # test todo 1 adopting todo 2
         todo1 = post_and_reload({'adopt': 2})
@@ -368,7 +305,7 @@ class TestsWithServer(TestCaseWithServer):
         """Test Todos posted to Day view may adopt existing Todos."""
         form_data = self.post_process()
         form_data = self.post_process(2, form_data | {'new_top_step': 1})
-        form_data = {'comment': '', 'new_todo': 1}
+        form_data = {'day_comment': '', 'new_todo': 1}
         self.check_post(form_data, '/day?date=2024-01-01', 302)
         form_data['new_todo'] = 2
         self.check_post(form_data, '/day?date=2024-01-01', 302)
@@ -379,10 +316,56 @@ class TestsWithServer(TestCaseWithServer):
         self.assertEqual(todo2.children, [todo1])
         self.assertEqual(todo2.parents, [])
 
+    def test_do_POST_day_todo_multiple(self) -> None:
+        """Test multiple Todos can be posted to Day view."""
+        form_data = self.post_process()
+        form_data = self.post_process(2)
+        form_data = {'day_comment': '', 'new_todo': [1, 2]}
+        self.check_post(form_data, '/day?date=2024-01-01', 302)
+        todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
+        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
+        self.assertEqual(todo1.process.id_, 1)
+        self.assertEqual(todo2.process.id_, 2)
+
+    def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
+        """Test multiple Todos can be posted to Day view w. inner adoption."""
+        form_data = self.post_process()
+        form_data = self.post_process(2, form_data | {'new_top_step': 1})
+        form_data = {'day_comment': '', 'new_todo': [1, 2]}
+        self.check_post(form_data, '/day?date=2024-01-01', 302)
+        todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
+        todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
+        self.assertEqual(todo1.children, [])
+        self.assertEqual(todo1.parents, [todo2])
+        self.assertEqual(todo2.children, [todo1])
+        self.assertEqual(todo2.parents, [])
+        # check process ID order does not affect end result
+        form_data = {'day_comment': '', 'new_todo': [2, 1]}
+        self.check_post(form_data, '/day?date=2024-01-02', 302)
+        todo1 = Todo.by_date(self.db_conn, '2024-01-02')[1]
+        todo2 = Todo.by_date(self.db_conn, '2024-01-02')[0]
+        self.assertEqual(todo1.children, [])
+        self.assertEqual(todo1.parents, [todo2])
+        self.assertEqual(todo2.children, [todo1])
+        self.assertEqual(todo2.parents, [])
+
+    def test_do_POST_day_todo_doneness(self) -> None:
+        """Test Todo doneness can be posted to Day view."""
+        form_data = self.post_process()
+        form_data = {'day_comment': '', 'new_todo': [1]}
+        self.check_post(form_data, '/day?date=2024-01-01', 302)
+        todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
+        form_data = {'day_comment': '', 'todo_id': [1]}
+        self.check_post(form_data, '/day?date=2024-01-01', 302)
+        self.assertEqual(todo.is_done, False)
+        form_data = {'day_comment': '', 'todo_id': [1], 'done': [1]}
+        self.check_post(form_data, '/day?date=2024-01-01', 302)
+        self.assertEqual(todo.is_done, True)
+
     def test_do_GET_todo(self) -> None:
         """Test GET /todo response codes."""
         self.post_process()
-        form_data = {'comment': '', 'new_todo': 1}
+        form_data = {'day_comment': '', 'new_todo': 1}
         self.check_post(form_data, '/day?date=2024-01-01', 302)
         self.check_get('/todo', 400)
         self.check_get('/todo?id=', 400)
index bb37270ca68afde8e00df09a19892011fd371a29..a42b3f3ba61b1e28031bf35dca1d263048495830 100644 (file)
@@ -18,18 +18,25 @@ from plomtask.exceptions import NotFoundException, HandledException
 class TestCaseSansDB(TestCase):
     """Tests requiring no DB setup."""
     checked_class: Any
+    do_id_test: bool = False
+    default_init_args: list[Any] = []
+    versioned_defaults_to_test: dict[str, str | float] = {}
 
-    def check_id_setting(self, *args: Any) -> None:
+    def test_id_setting(self) -> None:
         """Test .id_ being set and its legal range being enforced."""
+        if not self.do_id_test:
+            return
         with self.assertRaises(HandledException):
-            self.checked_class(0, *args)
-        obj = self.checked_class(5, *args)
+            self.checked_class(0, *self.default_init_args)
+        obj = self.checked_class(5, *self.default_init_args)
         self.assertEqual(obj.id_, 5)
 
-    def check_versioned_defaults(self, attrs: dict[str, Any]) -> None:
+    def test_versioned_defaults(self) -> None:
         """Test defaults of VersionedAttributes."""
-        obj = self.checked_class(None)
-        for k, v in attrs.items():
+        if len(self.versioned_defaults_to_test) == 0:
+            return
+        obj = self.checked_class(1, *self.default_init_args)
+        for k, v in self.versioned_defaults_to_test.items():
             self.assertEqual(getattr(obj, k).newest, v)
 
 
@@ -37,6 +44,8 @@ class TestCaseWithDB(TestCase):
     """Module tests not requiring DB setup."""
     checked_class: Any
     default_ids: tuple[int | str, int | str, int | str] = (1, 2, 3)
+    default_init_kwargs: dict[str, Any] = {}
+    test_versioneds: dict[str, type] = {}
 
     def setUp(self) -> None:
         Condition.empty_cache()
@@ -45,14 +54,24 @@ class TestCaseWithDB(TestCase):
         ProcessStep.empty_cache()
         Todo.empty_cache()
         timestamp = datetime.now().timestamp()
-        self.db_file = DatabaseFile(f'test_db:{timestamp}')
-        self.db_file.remake()
+        self.db_file = DatabaseFile.create_at(f'test_db:{timestamp}')
         self.db_conn = DatabaseConnection(self.db_file)
 
     def tearDown(self) -> None:
         self.db_conn.close()
         remove_file(self.db_file.path)
 
+    def test_saving_and_caching(self) -> None:
+        """Test storage and initialization of instances and attributes."""
+        if not hasattr(self, 'checked_class'):
+            return
+        self.check_saving_and_caching(id_=1, **self.default_init_kwargs)
+        obj = self.checked_class(None, **self.default_init_kwargs)
+        obj.save(self.db_conn)
+        self.assertEqual(obj.id_, 2)
+        for k, v in self.test_versioneds.items():
+            self.check_saving_of_versioned(k, v)
+
     def check_storage(self, content: list[Any]) -> None:
         """Test cache and DB equal content."""
         expected_cache = {}
@@ -68,7 +87,7 @@ class TestCaseWithDB(TestCase):
                                                                row)]
         self.assertEqual(sorted(content), sorted(db_found))
 
-    def check_saving_and_caching(self, **kwargs: Any) -> Any:
+    def check_saving_and_caching(self, **kwargs: Any) -> None:
         """Test instance.save in its core without relations."""
         obj = self.checked_class(**kwargs)  # pylint: disable=not-callable
         # check object init itself doesn't store anything yet