[BASIC]
init-hook='import sys; sys.path.append(".")'
-good-names-rgxs=(test_)?do_(GET|POST)(_[a-z]+)?,test_[A-Z]+
+good-names-rgxs=.*_?do_(GET|POST)(_[a-z]+)?,test_[A-Z]+
--- /dev/null
+CREATE TABLE process_blockers (
+ 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 todo_blockers (
+ todo INTEGER NOT NULL,
+ condition INTEGER NOT NULL,
+ PRIMARY KEY (todo, condition),
+ FOREIGN KEY (todo) REFERENCES todos(id),
+ FOREIGN KEY (condition) REFERENCES conditions(id)
+);
--- /dev/null
+CREATE TABLE process_step_suppressions (
+ process INTEGER NOT NULL,
+ process_step INTEGER NOT NULL,
+ PRIMARY KEY (process, process_step),
+ FOREIGN KEY (process) REFERENCES processes(id),
+ FOREIGN KEY (process_step) REFERENCES process_steps(id)
+);
+
+++ /dev/null
-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)
-);
--- /dev/null
+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_blockers (
+ 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_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_step_suppressions (
+ process INTEGER NOT NULL,
+ process_step INTEGER NOT NULL,
+ PRIMARY KEY (process, process_step),
+ FOREIGN KEY (process) REFERENCES processes(id),
+ FOREIGN KEY (process_step) REFERENCES process_steps(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_blockers (
+ 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_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)
+);
table_name = 'conditions'
to_save = ['is_active']
to_save_versioned = ['title', 'description']
+ to_search = ['title.newest', 'description.newest']
def __init__(self, id_: int | None, is_active: bool = False) -> None:
super().__init__(id_)
if self.id_ is None:
raise HandledException('cannot remove unsaved item')
for item in ('process', 'todo'):
- for attr in ('conditions', 'enables', 'disables'):
+ for attr in ('conditions', 'blockers', 'enables', 'disables'):
table_name = f'{item}_{attr}'
for _ in db_conn.row_where(table_name, 'condition', self.id_):
raise HandledException('cannot remove Condition in use')
class ConditionsRelations:
"""Methods for handling relations to Conditions, for Todo and Process."""
+ def __init__(self) -> None:
+ self.conditions: list[Condition] = []
+ self.blockers: list[Condition] = []
+ self.enables: list[Condition] = []
+ self.disables: list[Condition] = []
+
def set_conditions(self, db_conn: DatabaseConnection, ids: list[int],
target: str = 'conditions') -> None:
"""Set self.[target] to Conditions identified by ids."""
for id_ in ids:
target_list += [Condition.by_id(db_conn, id_)]
+ def set_blockers(self, db_conn: DatabaseConnection,
+ ids: list[int]) -> None:
+ """Set self.enables to Conditions identified by ids."""
+ self.set_conditions(db_conn, ids, 'blockers')
+
def set_enables(self, db_conn: DatabaseConnection,
ids: list[int]) -> None:
"""Set self.enables to Conditions identified by ids."""
--- /dev/null
+"""Various utilities for handling dates."""
+from datetime import datetime, timedelta
+from plomtask.exceptions import BadFormatException
+
+DATE_FORMAT = '%Y-%m-%d'
+
+
+def valid_date(date_str: str) -> str:
+ """Validate date against DATE_FORMAT or 'today'/'yesterday'/'tomorrow.
+
+ In any case, returns in DATE_FORMAT.
+ """
+ if date_str == 'today':
+ date_str = date_in_n_days(0)
+ elif date_str == 'yesterday':
+ date_str = date_in_n_days(-1)
+ elif date_str == 'tomorrow':
+ date_str = date_in_n_days(1)
+ try:
+ dt = datetime.strptime(date_str, DATE_FORMAT)
+ except (ValueError, TypeError) as e:
+ msg = f'Given date of wrong format: {date_str}'
+ raise BadFormatException(msg) from e
+ return dt.strftime(DATE_FORMAT)
+
+
+def date_in_n_days(n: int) -> str:
+ """Return in DATE_FORMAT date from today + n days."""
+ date = datetime.now() + timedelta(days=n)
+ return date.strftime(DATE_FORMAT)
"""Collecting Day and date-related items."""
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'
-MAX_RANGE_DATE = '2030-12-31'
-
-
-def valid_date(date_str: str) -> str:
- """Validate date against DATE_FORMAT or 'today', return in DATE_FORMAT."""
- if date_str == 'today':
- date_str = todays_date()
- try:
- dt = datetime.strptime(date_str, DATE_FORMAT)
- except (ValueError, TypeError) as e:
- msg = f'Given date of wrong format: {date_str}'
- raise BadFormatException(msg) from e
- return dt.strftime(DATE_FORMAT)
-
-
-def todays_date() -> str:
- """Return current date in DATE_FORMAT."""
- return datetime.now().strftime(DATE_FORMAT)
+from plomtask.dating import (DATE_FORMAT, valid_date)
class Day(BaseModel[str]):
return self.date < other.date
@classmethod
- def all(cls, db_conn: DatabaseConnection,
- date_range: tuple[str, str] = ('', ''),
- fill_gaps: bool = False) -> list[Day]:
- """Return list of Days in database within (open) date_range interval.
-
- If no range values provided, defaults them to MIN_RANGE_DATE and
- MAX_RANGE_DATE. Also knows to properly interpret 'today' as value.
+ def by_date_range_filled(cls, db_conn: DatabaseConnection,
+ start: str, end: str) -> list[Day]:
+ """Return days existing and non-existing between dates start/end."""
+ ret = cls.by_date_range_with_limits(db_conn, (start, end), 'id')
+ days, start_date, end_date = ret
+ return cls.with_filled_gaps(days, start_date, end_date)
- On fill_gaps=True, will instantiate (without saving) Days of all dates
- within the date range that don't exist yet.
- """
- min_date = '2024-01-01'
- max_date = '2030-12-31'
- start_date = valid_date(date_range[0] if date_range[0] else min_date)
- end_date = valid_date(date_range[1] if date_range[1] else max_date)
- days = []
- sql = 'SELECT id FROM days WHERE id >= ? AND id <= ?'
- for row in db_conn.exec(sql, (start_date, end_date)):
- days += [cls.by_id(db_conn, row[0])]
+ @classmethod
+ def with_filled_gaps(cls, days: list[Day], start_date: str, end_date: str
+ ) -> list[Day]:
+ """In days, fill with (un-saved) Days gaps between start/end_date."""
+ if start_date > end_date:
+ return days
days.sort()
- if fill_gaps and len(days) > 1:
+ if start_date not in [d.date for d in days]:
+ days[:] = [Day(start_date)] + days
+ if end_date not in [d.date for d in days]:
+ days += [Day(end_date)]
+ if len(days) > 1:
gapless_days = []
for i, day in enumerate(days):
gapless_days += [day]
while day.next_date != days[i+1].date:
day = Day(day.next_date)
gapless_days += [day]
- days = gapless_days
+ days[:] = gapless_days
return days
@property
from sqlite3 import connect as sql_connect, Cursor, Row
from typing import Any, Self, TypeVar, Generic
from plomtask.exceptions import HandledException, NotFoundException
+from plomtask.dating import valid_date
-EXPECTED_DB_VERSION = 3
+EXPECTED_DB_VERSION = 5
MIGRATIONS_DIR = 'migrations'
FILENAME_DB_SCHEMA = f'init_{EXPECTED_DB_VERSION}.sql'
PATH_DB_SCHEMA = f'{MIGRATIONS_DIR}/{FILENAME_DB_SCHEMA}'
self.conn.close()
def rewrite_relations(self, table_name: str, key: str, target: int | str,
- rows: list[list[Any]]) -> None:
- """Rewrite relations in table_name to target, with rows values."""
+ rows: list[list[Any]], key_index: int = 0) -> None:
+ # pylint: disable=too-many-arguments
+ """Rewrite relations in table_name to target, with rows values.
+
+ Note that single rows are expected without the column and value
+ identified by key and target, which are inserted inside the function
+ at key_index.
+ """
self.delete_where(table_name, key, target)
for row in rows:
- values = tuple([target] + row)
+ values = tuple(row[:key_index] + [target] + row[key_index:])
q_marks = self.__class__.q_marks_from_values(values)
self.exec(f'INSERT INTO {table_name} VALUES {q_marks}', values)
return list(self.exec(f'SELECT * FROM {table_name} WHERE {key} = ?',
(target,)))
+ # def column_where_pattern(self,
+ # table_name: str,
+ # column: str,
+ # pattern: str,
+ # keys: list[str]) -> list[Any]:
+ # """Return column of rows where one of keys matches pattern."""
+ # targets = tuple([f'%{pattern}%'] * len(keys))
+ # haystack = ' OR '.join([f'{k} LIKE ?' for k in keys])
+ # sql = f'SELECT {column} FROM {table_name} WHERE {haystack}'
+ # return [row[0] for row in self.exec(sql, targets)]
+
def column_where(self, table_name: str, column: str, key: str,
target: int | str) -> list[Any]:
"""Return column of table where key == target."""
table_name = ''
to_save: list[str] = []
to_save_versioned: list[str] = []
- to_save_relations: list[tuple[str, str, str]] = []
+ to_save_relations: list[tuple[str, str, str, int]] = []
id_: None | BaseModelId
cache_: dict[BaseModelId, Self]
+ to_search: list[str] = []
def __init__(self, id_: BaseModelId | None) -> None:
if isinstance(id_, int) and id_ < 1:
items[item.id_] = item
return list(items.values())
+ @classmethod
+ def by_date_range_with_limits(cls: type[BaseModelInstance],
+ db_conn: DatabaseConnection,
+ date_range: tuple[str, str],
+ date_col: str = 'day'
+ ) -> tuple[list[BaseModelInstance], str,
+ str]:
+ """Return list of Days in database within (open) date_range interval.
+
+ If no range values provided, defaults them to 'yesterday' and
+ 'tomorrow'. Knows to properly interpret these and 'today' as value.
+ """
+ start_str = date_range[0] if date_range[0] else 'yesterday'
+ end_str = date_range[1] if date_range[1] else 'tomorrow'
+ start_date = valid_date(start_str)
+ end_date = valid_date(end_str)
+ items = []
+ sql = f'SELECT id FROM {cls.table_name} '
+ sql += f'WHERE {date_col} >= ? AND {date_col} <= ?'
+ for row in db_conn.exec(sql, (start_date, end_date)):
+ items += [cls.by_id(db_conn, row[0])]
+ return items, start_date, end_date
+
+ @classmethod
+ def matching(cls: type[BaseModelInstance], db_conn: DatabaseConnection,
+ pattern: str) -> list[BaseModelInstance]:
+ """Return all objects whose .to_search match pattern."""
+ items = cls.all(db_conn)
+ if pattern:
+ filtered = []
+ for item in items:
+ for attr_name in cls.to_search:
+ toks = attr_name.split('.')
+ parent = item
+ for tok in toks:
+ attr = getattr(parent, tok)
+ parent = attr
+ if pattern in attr:
+ filtered += [item]
+ break
+ return filtered
+ return items
+
def save(self, db_conn: DatabaseConnection) -> None:
"""Write self to DB and cache and ensure .id_.
self.cache()
for attr_name in self.to_save_versioned:
getattr(self, attr_name).save(db_conn)
- for table, column, attr_name in self.to_save_relations:
+ for table, column, attr_name, key_index in self.to_save_relations:
assert isinstance(self.id_, (int, str))
db_conn.rewrite_relations(table, column, self.id_,
[[i.id_] for i
- in getattr(self, attr_name)])
+ in getattr(self, attr_name)], key_index)
def remove(self, db_conn: DatabaseConnection) -> None:
"""Remove from DB and cache, including dependencies."""
raise HandledException('cannot remove unsaved item')
for attr_name in self.to_save_versioned:
getattr(self, attr_name).remove(db_conn)
- for table, column, attr_name in self.to_save_relations:
+ for table, column, attr_name, _ in self.to_save_relations:
db_conn.delete_where(table, column, self.id_)
self.uncache()
db_conn.delete_where(self.table_name, 'id', self.id_)
"""Web server stuff."""
+from __future__ import annotations
+from dataclasses import dataclass
from typing import Any
+from base64 import b64encode, b64decode
from http.server import BaseHTTPRequestHandler
from http.server import HTTPServer
from urllib.parse import urlparse, parse_qs
from os.path import split as path_split
from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader
-from plomtask.days import Day, todays_date
+from plomtask.dating import date_in_n_days
+from plomtask.days import Day
from plomtask.exceptions import HandledException, BadFormatException, \
NotFoundException
from plomtask.db import DatabaseConnection, DatabaseFile
-from plomtask.processes import Process
+from plomtask.processes import Process, ProcessStep, ProcessStepsNode
from plomtask.conditions import Condition
from plomtask.todos import Todo
TEMPLATES_DIR = 'templates'
+@dataclass
+class TodoStepsNode:
+ """Collect what's useful for Todo steps tree display."""
+ id_: int
+ todo: Todo | None
+ process: Process | None
+ children: list[TodoStepsNode]
+ fillable: bool = False
+
+
class TaskServer(HTTPServer):
"""Variant of HTTPServer that knows .jinja as Jinja Environment."""
return default
return self.inputs[key][0]
+ def get_first_strings_starting(self, prefix: str) -> dict[str, str]:
+ """Retrieve dict of (first) strings at key starting with prefix."""
+ ret = {}
+ for key in [k for k in self.inputs.keys() if k.startswith(prefix)]:
+ ret[key] = self.inputs[key][0]
+ return ret
+
def get_int(self, key: str) -> int:
"""Retrieve single/first value of key as int, error if empty."""
val = self.get_int_or_none(key)
class TaskHandler(BaseHTTPRequestHandler):
"""Handles single HTTP request."""
+ # pylint: disable=too-many-public-methods
server: TaskServer
+ conn: DatabaseConnection
+ _site: str
+ _form_data: InputsParser
+ _params: InputsParser
def do_GET(self) -> None:
"""Handle any GET request."""
try:
self._init_handling()
- if hasattr(self, f'do_GET_{self.site}'):
- template = f'{self.site}.html'
- ctx = getattr(self, f'do_GET_{self.site}')()
+ 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)
self._send_html(html)
- elif '' == self.site:
+ elif '' == self._site:
self._redirect('/day')
else:
- raise NotFoundException(f'Unknown page: /{self.site}')
+ raise NotFoundException(f'Unknown page: /{self._site}')
except HandledException as error:
self._send_msg(error, code=error.http_code)
finally:
self.conn.close()
- def do_GET_calendar(self) -> dict[str, object]:
+ def _do_GET_calendar(self) -> dict[str, object]:
"""Show Days from ?start= to ?end=."""
- start = self.params.get_str('start')
- end = self.params.get_str('end')
- days = Day.all(self.conn, date_range=(start, end), fill_gaps=True)
+ start = self._params.get_str('start')
+ end = self._params.get_str('end')
+ if not end:
+ end = date_in_n_days(366)
+ ret = Day.by_date_range_with_limits(self.conn, (start, end), 'id')
+ days, start, end = ret
+ days = Day.with_filled_gaps(days, start, end)
for day in days:
day.collect_calendarized_todos(self.conn)
- return {'start': start, 'end': end, 'days': days}
+ today = date_in_n_days(0)
+ return {'start': start, 'end': end, 'days': days, 'today': today}
+
+ def do_GET_calendar(self) -> dict[str, object]:
+ """Show Days from ?start= to ?end= – normal view."""
+ return self._do_GET_calendar()
+
+ def do_GET_calendar_txt(self) -> dict[str, object]:
+ """Show Days from ?start= to ?end= – minimalist view."""
+ return self._do_GET_calendar()
def do_GET_day(self) -> dict[str, object]:
"""Show single Day of ?date=."""
- date = self.params.get_str('date', todays_date())
+ date = self._params.get_str('date', date_in_n_days(0))
+ make_type = self._params.get_str('make_type')
todays_todos = Todo.by_date(self.conn, date)
+ total_effort = 0.0
+ for todo in todays_todos:
+ total_effort += todo.performed_effort
conditions_present = []
enablers_for = {}
+ disablers_for = {}
for todo in todays_todos:
- for condition in todo.conditions:
+ for condition in todo.conditions + todo.blockers:
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]
+ disablers_for[condition.id_] = [p for p in
+ Process.all(self.conn)
+ if condition in p.disables]
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),
+ 'total_effort': total_effort,
'top_nodes': top_nodes,
+ 'make_type': make_type,
'enablers_for': enablers_for,
+ 'disablers_for': disablers_for,
'conditions_present': conditions_present,
'processes': Process.all(self.conn)}
def do_GET_todo(self) -> dict[str, object]:
"""Show single Todo of ?id=."""
- id_ = self.params.get_int('id')
+
+ def walk_process_steps(id_: int,
+ process_step_nodes: list[ProcessStepsNode],
+ steps_nodes: list[TodoStepsNode]) -> None:
+ for process_step_node in process_step_nodes:
+ id_ += 1
+ node = TodoStepsNode(id_, None, process_step_node.process, [])
+ steps_nodes += [node]
+ walk_process_steps(id_, list(process_step_node.steps.values()),
+ node.children)
+
+ def walk_todo_steps(id_: int, todos: list[Todo],
+ steps_nodes: list[TodoStepsNode]) -> None:
+ for todo in todos:
+ matched = False
+ for match in [item for item in steps_nodes
+ if item.process
+ and item.process == todo.process]:
+ match.todo = todo
+ matched = True
+ for child in match.children:
+ child.fillable = True
+ walk_todo_steps(id_, todo.children, match.children)
+ if not matched:
+ id_ += 1
+ node = TodoStepsNode(id_, todo, None, [])
+ steps_nodes += [node]
+ walk_todo_steps(id_, todo.children, node.children)
+
+ def collect_adoptables_keys(steps_nodes: list[TodoStepsNode]
+ ) -> set[int]:
+ ids = set()
+ for node in steps_nodes:
+ if not node.todo:
+ assert isinstance(node.process, Process)
+ assert isinstance(node.process.id_, int)
+ ids.add(node.process.id_)
+ ids = ids | collect_adoptables_keys(node.children)
+ return ids
+
+ id_ = self._params.get_int('id')
todo = Todo.by_id(self.conn, id_)
- return {'todo': todo,
- 'todo_candidates': Todo.by_date(self.conn, todo.date),
+ todo_steps = [step.todo for step in todo.get_step_tree(set()).children]
+ process_tree = todo.process.get_steps(self.conn, None)
+ steps_todo_to_process: list[TodoStepsNode] = []
+ walk_process_steps(0, list(process_tree.values()),
+ steps_todo_to_process)
+ for steps_node in steps_todo_to_process:
+ steps_node.fillable = True
+ walk_todo_steps(len(steps_todo_to_process), todo_steps,
+ steps_todo_to_process)
+ adoptables: dict[int, list[Todo]] = {}
+ any_adoptables = [Todo.by_id(self.conn, t.id_)
+ for t in Todo.by_date(self.conn, todo.date)
+ if t != todo]
+ for id_ in collect_adoptables_keys(steps_todo_to_process):
+ adoptables[id_] = [t for t in any_adoptables
+ if t.process.id_ == id_]
+ return {'todo': todo, 'steps_todo_to_process': steps_todo_to_process,
+ 'adoption_candidates_for': adoptables,
+ 'process_candidates': Process.all(self.conn),
+ 'todo_candidates': any_adoptables,
'condition_candidates': Condition.all(self.conn)}
+ def do_GET_todos(self) -> dict[str, object]:
+ """Show Todos from ?start= to ?end=, of ?process=, ?comment= pattern"""
+ sort_by = self._params.get_str('sort_by')
+ start = self._params.get_str('start')
+ end = self._params.get_str('end')
+ process_id = self._params.get_int_or_none('process_id')
+ comment_pattern = self._params.get_str('comment_pattern')
+ todos = []
+ ret = Todo.by_date_range_with_limits(self.conn, (start, end))
+ todos_by_date_range, start, end = ret
+ todos = [t for t in todos_by_date_range
+ if comment_pattern in t.comment
+ and ((not process_id) or t.process.id_ == process_id)]
+ if sort_by == 'doneness':
+ todos.sort(key=lambda t: t.is_done)
+ elif sort_by == '-doneness':
+ todos.sort(key=lambda t: t.is_done, reverse=True)
+ elif sort_by == 'title':
+ todos.sort(key=lambda t: t.title_then)
+ elif sort_by == '-title':
+ todos.sort(key=lambda t: t.title_then, reverse=True)
+ elif sort_by == 'comment':
+ todos.sort(key=lambda t: t.comment)
+ elif sort_by == '-comment':
+ todos.sort(key=lambda t: t.comment, reverse=True)
+ elif sort_by == '-date':
+ todos.sort(key=lambda t: t.date, reverse=True)
+ else:
+ todos.sort(key=lambda t: t.date)
+ return {'start': start, 'end': end, 'process_id': process_id,
+ 'comment_pattern': comment_pattern, 'todos': todos,
+ 'all_processes': Process.all(self.conn), 'sort_by': sort_by}
+
def do_GET_conditions(self) -> dict[str, object]:
"""Show all Conditions."""
- conditions = Condition.all(self.conn)
- sort_by = self.params.get_str('sort_by')
+ pattern = self._params.get_str('pattern')
+ conditions = Condition.matching(self.conn, pattern)
+ 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.title.newest, reverse=True)
else:
conditions.sort(key=lambda c: c.title.newest)
- return {'conditions': conditions, 'sort_by': sort_by}
+ return {'conditions': conditions,
+ 'sort_by': sort_by,
+ 'pattern': pattern}
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)}
+ id_ = self._params.get_int_or_none('id')
+ c = Condition.by_id(self.conn, id_, create=True)
+ ps = Process.all(self.conn)
+ return {'condition': c, 'is_new': c.id_ is None,
+ 'enabled_processes': [p for p in ps if c in p.conditions],
+ 'disabled_processes': [p for p in ps if c in p.blockers],
+ 'enabling_processes': [p for p in ps if c in p.enables],
+ 'disabling_processes': [p for p in ps if c in p.disables]}
def do_GET_condition_titles(self) -> dict[str, object]:
"""Show title history of Condition of ?id=."""
- id_ = self.params.get_int_or_none('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')
+ 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=."""
- id_ = self.params.get_int_or_none('id')
+ id_ = self._params.get_int_or_none('id')
process = Process.by_id(self.conn, id_, create=True)
- return {'process': process,
- 'steps': process.get_steps(self.conn),
- 'owners': process.used_as_step_by(self.conn),
- 'step_candidates': Process.all(self.conn),
+ title_64 = self._params.get_str('title_b64')
+ if title_64:
+ title = b64decode(title_64.encode()).decode()
+ process.title.set(title)
+ owners = process.used_as_step_by(self.conn)
+ for step_id in self._params.get_all_int('step_to'):
+ owners += [Process.by_id(self.conn, step_id)]
+ preset_top_step = None
+ for process_id in self._params.get_all_int('has_step'):
+ preset_top_step = process_id
+ return {'process': process, 'is_new': process.id_ is None,
+ 'preset_top_step': preset_top_step,
+ 'steps': process.get_steps(self.conn), 'owners': owners,
+ 'n_todos': len(Todo.by_process_id(self.conn, process.id_)),
+ 'process_candidates': Process.all(self.conn),
'condition_candidates': Condition.all(self.conn)}
def do_GET_process_titles(self) -> dict[str, object]:
"""Show title history of Process of ?id=."""
- id_ = self.params.get_int_or_none('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')
+ 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')
+ 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."""
- processes = Process.all(self.conn)
- sort_by = self.params.get_str('sort_by')
+ pattern = self._params.get_str('pattern')
+ processes = Process.matching(self.conn, pattern)
+ sort_by = self._params.get_str('sort_by')
if sort_by == 'steps':
- processes.sort(key=lambda c: len(c.explicit_steps))
+ processes.sort(key=lambda p: len(p.explicit_steps))
elif sort_by == '-steps':
- processes.sort(key=lambda c: len(c.explicit_steps), reverse=True)
+ processes.sort(key=lambda p: len(p.explicit_steps), reverse=True)
+ elif sort_by == 'owners':
+ processes.sort(key=lambda p: p.n_owners or 0)
+ elif sort_by == '-owners':
+ processes.sort(key=lambda p: p.n_owners or 0, reverse=True)
+ elif sort_by == 'effort':
+ processes.sort(key=lambda p: p.effort.newest)
+ elif sort_by == '-effort':
+ processes.sort(key=lambda p: p.effort.newest, reverse=True)
elif sort_by == '-title':
- processes.sort(key=lambda c: c.title.newest, reverse=True)
+ processes.sort(key=lambda p: p.title.newest, reverse=True)
else:
- processes.sort(key=lambda c: c.title.newest)
- return {'processes': processes, 'sort_by': sort_by}
+ processes.sort(key=lambda p: p.title.newest)
+ return {'processes': processes, 'sort_by': sort_by, 'pattern': pattern}
def do_POST(self) -> None:
"""Handle any POST request."""
- # pylint: disable=attribute-defined-outside-init
try:
self._init_handling()
length = int(self.headers['content-length'])
postvars = parse_qs(self.rfile.read(length).decode(),
keep_blank_values=True, strict_parsing=True)
- self.form_data = InputsParser(postvars)
- if hasattr(self, f'do_POST_{self.site}'):
- redir_target = getattr(self, f'do_POST_{self.site}')()
+ self._form_data = InputsParser(postvars)
+ if hasattr(self, f'do_POST_{self._site}'):
+ redir_target = getattr(self, f'do_POST_{self._site}')()
self.conn.commit()
else:
- msg = f'Page not known as POST target: /{self.site}'
+ msg = f'Page not known as POST target: /{self._site}'
raise NotFoundException(msg)
self._redirect(redir_target)
except HandledException as error:
def do_POST_day(self) -> str:
"""Update or insert Day of date and Todos mapped to it."""
- date = self.params.get_str('date')
+ date = self._params.get_str('date')
day = Day.by_id(self.conn, date, create=True)
- day.comment = self.form_data.get_str('day_comment')
+ day.comment = self._form_data.get_str('day_comment')
day.save(self.conn)
- 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)
- 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)
+ make_type = self._form_data.get_str('make_type')
+ for process_id in sorted(self._form_data.get_all_int('new_todo')):
+ if 'empty' == make_type:
+ process = Process.by_id(self.conn, process_id)
+ todo = Todo(None, process, False, date)
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')):
+ else:
+ Todo.create_with_children(self.conn, process_id, date)
+ 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 = todo_id in done_ids
if len(comments) > 0:
condition.save(self.conn)
for condition in todo.disables:
condition.save(self.conn)
- return f'/day?date={date}'
+ return f'/day?date={date}&make_type={make_type}'
def do_POST_todo(self) -> str:
"""Update Todo and its children."""
- id_ = self.params.get_int('id')
- for _ in self.form_data.get_all_str('delete'):
+ # pylint: disable=too-many-locals
+ # pylint: disable=too-many-branches
+ id_ = self._params.get_int('id')
+ for _ in self._form_data.get_all_str('delete'):
todo = Todo .by_id(self.conn, id_)
todo.remove(self.conn)
return '/'
todo = Todo.by_id(self.conn, id_)
- adopted_child_ids = self.form_data.get_all_int('adopt')
+ adopted_child_ids = self._form_data.get_all_int('adopt')
+ processes_to_make_full = self._form_data.get_all_int('make_full')
+ processes_to_make_empty = self._form_data.get_all_int('make_empty')
+ fill_fors = self._form_data.get_first_strings_starting('fill_for_')
+ for v in fill_fors.values():
+ if v.startswith('make_empty_'):
+ processes_to_make_empty += [int(v[11:])]
+ elif v.startswith('make_full_'):
+ processes_to_make_full += [int(v[10:])]
+ elif v != 'ignore':
+ adopted_child_ids += [int(v)]
+ to_remove = []
for child in todo.children:
+ assert isinstance(child.id_, int)
if child.id_ not in adopted_child_ids:
- assert isinstance(child.id_, int)
- child = Todo.by_id(self.conn, child.id_)
- todo.remove_child(child)
+ to_remove += [child.id_]
+ for id_ in to_remove:
+ child = Todo.by_id(self.conn, id_)
+ todo.remove_child(child)
for child_id in adopted_child_ids:
if child_id in [c.id_ for c in todo.children]:
continue
child = Todo.by_id(self.conn, child_id)
todo.add_child(child)
- effort = self.form_data.get_str('effort', ignore_strict=True)
+ for process_id in processes_to_make_empty:
+ process = Process.by_id(self.conn, process_id)
+ made = Todo(None, process, False, todo.date)
+ made.save(self.conn)
+ todo.add_child(made)
+ for process_id in processes_to_make_full:
+ made = Todo.create_with_children(self.conn, process_id, todo.date)
+ todo.add_child(made)
+ 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.set_conditions(self.conn,
+ self._form_data.get_all_int('condition'))
+ todo.set_blockers(self.conn, self._form_data.get_all_int('blocker'))
+ 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)
condition.save(self.conn)
return f'/todo?id={todo.id_}'
+ def _do_POST_versioned_timestamps(self, cls: Any, attr_name: str) -> str:
+ """Update history timestamps for VersionedAttribute."""
+ id_ = self._params.get_int_or_none('id')
+ item = cls.by_id(self.conn, id_)
+ attr = getattr(item, attr_name)
+ for k, v in self._form_data.get_first_strings_starting('at:').items():
+ old = k[3:]
+ if old[19:] != v:
+ attr.reset_timestamp(old, f'{v}.0')
+ attr.save(self.conn)
+ cls_name = cls.__name__.lower()
+ return f'/{cls_name}_{attr_name}s?id={item.id_}'
+
+ def do_POST_process_descriptions(self) -> str:
+ """Update history timestamps for Process.description."""
+ return self._do_POST_versioned_timestamps(Process, 'description')
+
+ def do_POST_process_efforts(self) -> str:
+ """Update history timestamps for Process.effort."""
+ return self._do_POST_versioned_timestamps(Process, 'effort')
+
+ def do_POST_process_titles(self) -> str:
+ """Update history timestamps for Process.title."""
+ return self._do_POST_versioned_timestamps(Process, 'title')
+
def do_POST_process(self) -> str:
"""Update or insert Process of ?id= and fields defined in postvars."""
- id_ = self.params.get_int_or_none('id')
- for _ in self.form_data.get_all_str('delete'):
+ # pylint: disable=too-many-branches
+ id_ = self._params.get_int_or_none('id')
+ for _ in self._form_data.get_all_str('delete'):
process = Process.by_id(self.conn, id_)
process.remove(self.conn)
return '/processes'
process = Process.by_id(self.conn, id_, create=True)
- process.title.set(self.form_data.get_str('title'))
- process.description.set(self.form_data.get_str('description'))
- process.effort.set(self.form_data.get_float('effort'))
+ process.title.set(self._form_data.get_str('title'))
+ process.description.set(self._form_data.get_str('description'))
+ process.effort.set(self._form_data.get_float('effort'))
process.set_conditions(self.conn,
- 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') != []
+ self._form_data.get_all_int('condition'))
+ process.set_blockers(self.conn, self._form_data.get_all_int('blocker'))
+ 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]] = []
- for step_id in self.form_data.get_all_int('steps'):
- for step_process_id in self.form_data.get_all_int(
- f'new_step_to_{step_id}'):
- steps += [(None, step_process_id, step_id)]
- if step_id not in self.form_data.get_all_int('keep_step'):
+ assert isinstance(process.id_, int)
+ steps: list[ProcessStep] = []
+ for step_id in self._form_data.get_all_int('keep_step'):
+ if step_id not in self._form_data.get_all_int('steps'):
+ raise BadFormatException('trying to keep unknown step')
+ for step_id in self._form_data.get_all_int('steps'):
+ if step_id not in self._form_data.get_all_int('keep_step'):
continue
- step_process_id = self.form_data.get_int(
+ step_process_id = self._form_data.get_int(
f'step_{step_id}_process_id')
- parent_id = self.form_data.get_int_or_none(
+ parent_id = self._form_data.get_int_or_none(
f'step_{step_id}_parent_id')
- steps += [(step_id, step_process_id, parent_id)]
- for step_process_id in self.form_data.get_all_int('new_top_step'):
- steps += [(None, step_process_id, None)]
+ steps += [ProcessStep(step_id, process.id_, step_process_id,
+ parent_id)]
+ for step_id in self._form_data.get_all_int('steps'):
+ for step_process_id in self._form_data.get_all_int(
+ f'new_step_to_{step_id}'):
+ steps += [ProcessStep(None, process.id_, step_process_id,
+ step_id)]
+ new_step_title = None
+ for step_identifier in self._form_data.get_all_str('new_top_step'):
+ try:
+ step_process_id = int(step_identifier)
+ steps += [ProcessStep(None, process.id_, step_process_id,
+ None)]
+ except ValueError:
+ new_step_title = step_identifier
+ process.uncache()
process.set_steps(self.conn, steps)
+ process.set_step_suppressions(self.conn,
+ self._form_data.
+ get_all_int('suppresses'))
process.save(self.conn)
- return f'/process?id={process.id_}'
+ owners_to_set = []
+ new_owner_title = None
+ for owner_identifier in self._form_data.get_all_str('step_of'):
+ try:
+ owners_to_set += [int(owner_identifier)]
+ except ValueError:
+ new_owner_title = owner_identifier
+ process.set_owners(self.conn, owners_to_set)
+ params = f'id={process.id_}'
+ if new_step_title:
+ title_b64_encoded = b64encode(new_step_title.encode()).decode()
+ params = f'step_to={process.id_}&title_b64={title_b64_encoded}'
+ elif new_owner_title:
+ title_b64_encoded = b64encode(new_owner_title.encode()).decode()
+ params = f'has_step={process.id_}&title_b64={title_b64_encoded}'
+ return f'/process?{params}'
+
+ def do_POST_condition_descriptions(self) -> str:
+ """Update history timestamps for Condition.description."""
+ return self._do_POST_versioned_timestamps(Condition, 'description')
+
+ def do_POST_condition_titles(self) -> str:
+ """Update history timestamps for Condition.title."""
+ return self._do_POST_versioned_timestamps(Condition, 'title')
def do_POST_condition(self) -> str:
"""Update/insert Condition of ?id= and fields defined in postvars."""
- id_ = self.params.get_int_or_none('id')
- for _ in self.form_data.get_all_str('delete'):
+ id_ = self._params.get_int_or_none('id')
+ for _ in self._form_data.get_all_str('delete'):
condition = Condition.by_id(self.conn, id_)
condition.remove(self.conn)
return '/conditions'
condition = Condition.by_id(self.conn, id_, create=True)
- condition.is_active = self.form_data.get_all_str('is_active') != []
- condition.title.set(self.form_data.get_str('title'))
- condition.description.set(self.form_data.get_str('description'))
+ condition.is_active = self._form_data.get_all_str('is_active') != []
+ condition.title.set(self._form_data.get_str('title'))
+ condition.description.set(self._form_data.get_str('description'))
condition.save(self.conn)
return f'/condition?id={condition.id_}'
def _init_handling(self) -> None:
- # pylint: disable=attribute-defined-outside-init
+ """Our own __init__, as we're not supposed to use the original."""
self.conn = DatabaseConnection(self.server.db)
parsed_url = urlparse(self.path)
- self.site = path_split(parsed_url.path)[1]
+ self._site = path_split(parsed_url.path)[1]
params = parse_qs(parsed_url.query, strict_parsing=True)
- self.params = InputsParser(params, False)
+ self._params = InputsParser(params, False)
def _redirect(self, target: str) -> None:
+ """Redirect to target."""
self.send_response(302)
self.send_header('Location', target)
self.end_headers()
parent_id: int | None
is_explicit: bool
steps: dict[int, ProcessStepsNode]
- seen: bool
+ seen: bool = False
+ is_suppressed: bool = False
class Process(BaseModel[int], ConditionsRelations):
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')]
+ to_save_relations = [('process_conditions', 'process', 'conditions', 0),
+ ('process_blockers', 'process', 'blockers', 0),
+ ('process_enables', 'process', 'enables', 0),
+ ('process_disables', 'process', 'disables', 0),
+ ('process_step_suppressions', 'process',
+ 'suppressed_steps', 0)]
+ to_search = ['title.newest', 'description.newest']
def __init__(self, id_: int | None, calendarize: bool = False) -> None:
- super().__init__(id_)
+ BaseModel.__init__(self, id_)
+ ConditionsRelations.__init__(self)
self.title = VersionedAttribute(self, 'process_titles', 'UNNAMED')
self.description = VersionedAttribute(self, 'process_descriptions', '')
self.effort = VersionedAttribute(self, 'process_efforts', 1.0)
self.explicit_steps: list[ProcessStep] = []
+ self.suppressed_steps: list[ProcessStep] = []
self.calendarize = calendarize
- self.conditions: list[Condition] = []
- self.enables: list[Condition] = []
- self.disables: list[Condition] = []
+ self.n_owners: int | None = None # only set by from_table_row
@classmethod
def from_table_row(cls, db_conn: DatabaseConnection,
row: Row | list[Any]) -> Process:
"""Make from DB row, with dependencies."""
+ # pylint: disable=no-member
process = super().from_table_row(db_conn, row)
assert isinstance(process.id_, int)
for name in ('title', 'description', 'effort'):
for row_ in db_conn.row_where('process_steps', 'owner',
process.id_):
step = ProcessStep.from_table_row(db_conn, row_)
- process.explicit_steps += [step] # pylint: disable=no-member
- for name in ('conditions', 'enables', 'disables'):
+ process.explicit_steps += [step]
+ for row_ in db_conn.row_where('process_step_suppressions', 'process',
+ process.id_):
+ step = ProcessStep.by_id(db_conn, row_[1])
+ process.suppressed_steps += [step]
+ for name in ('conditions', 'blockers', 'enables', 'disables'):
table = f'process_{name}'
assert isinstance(process.id_, int)
for c_id in db_conn.column_where(table, 'condition',
'process', process.id_):
target = getattr(process, name)
target += [Condition.by_id(db_conn, c_id)]
+ process.n_owners = len(process.used_as_step_by(db_conn))
return process
def used_as_step_by(self, db_conn: DatabaseConnection) -> list[Process]:
Process | None = None) -> dict[int, ProcessStepsNode]:
"""Return tree of depended-on explicit and implicit ProcessSteps."""
- def make_node(step: ProcessStep) -> ProcessStepsNode:
+ def make_node(step: ProcessStep, suppressed: bool) -> ProcessStepsNode:
is_explicit = False
if external_owner is not None:
is_explicit = step.owner_id == external_owner.id_
process = self.__class__.by_id(db_conn, step.step_process_id)
- step_steps = process.get_steps(db_conn, external_owner)
+ step_steps = {}
+ if not suppressed:
+ step_steps = process.get_steps(db_conn, external_owner)
return ProcessStepsNode(process, step.parent_step_id,
- is_explicit, step_steps, False)
+ is_explicit, step_steps, False, suppressed)
def walk_steps(node_id: int, node: ProcessStepsNode) -> None:
+ node.seen = node_id in seen_step_ids
+ seen_step_ids.add(node_id)
+ if node.is_suppressed:
+ return
explicit_children = [s for s in self.explicit_steps
if s.parent_step_id == node_id]
for child in explicit_children:
assert isinstance(child.id_, int)
- node.steps[child.id_] = make_node(child)
- node.seen = node_id in seen_step_ids
- seen_step_ids.add(node_id)
+ node.steps[child.id_] = make_node(child, False)
for id_, step in node.steps.items():
walk_steps(id_, step)
for step in [s for s in self.explicit_steps
if s.parent_step_id is None]:
assert isinstance(step.id_, int)
- steps[step.id_] = make_node(step)
+ new_node = make_node(step, step in external_owner.suppressed_steps)
+ steps[step.id_] = new_node
for step_id, step_node in steps.items():
walk_steps(step_id, step_node)
return steps
- def _add_step(self,
- db_conn: DatabaseConnection,
- id_: int | None,
- step_process_id: int,
- parent_step_id: int | None) -> ProcessStep:
- """Create new ProcessStep, save and add it to self.explicit_steps.
+ def set_step_suppressions(self, db_conn: DatabaseConnection,
+ step_ids: list[int]) -> None:
+ """Set self.suppressed_steps from step_ids."""
+ assert isinstance(self.id_, int)
+ db_conn.delete_where('process_step_suppressions', 'process', self.id_)
+ self.suppressed_steps = [ProcessStep.by_id(db_conn, s)
+ for s in step_ids]
- Also checks against step recursion.
+ def set_steps(self, db_conn: DatabaseConnection,
+ steps: list[ProcessStep]) -> None:
+ """Set self.explicit_steps in bulk.
- The new step's parent_step_id will fall back to None either if no
- matching ProcessStep is found (which can be assumed in case it was
- just deleted under its feet), or if the parent step would not be
- owned by the current Process.
+ Checks against recursion, and turns into top-level steps any of
+ unknown or non-owned parent.
"""
-
def walk_steps(node: ProcessStep) -> None:
if node.step_process_id == self.id_:
raise BadFormatException('bad step selection causes recursion')
for step in step_process.explicit_steps:
walk_steps(step)
- if parent_step_id is not None:
- try:
- parent_step = ProcessStep.by_id(db_conn, parent_step_id)
- if parent_step.owner_id != self.id_:
- parent_step_id = None
- except NotFoundException:
- parent_step_id = None
- assert isinstance(self.id_, int)
- step = ProcessStep(id_, self.id_, step_process_id, parent_step_id)
- walk_steps(step)
- self.explicit_steps += [step]
- step.save(db_conn) # NB: This ensures a non-None step.id_.
- return step
-
- def set_steps(self, db_conn: DatabaseConnection,
- steps: list[tuple[int | None, int, int | None]]) -> None:
- """Set self.explicit_steps in bulk."""
assert isinstance(self.id_, int)
for step in self.explicit_steps:
step.uncache()
self.explicit_steps = []
db_conn.delete_where('process_steps', 'owner', self.id_)
- for step_tuple in steps:
- self._add_step(db_conn, step_tuple[0],
- step_tuple[1], step_tuple[2])
+ for step in steps:
+ step.save(db_conn)
+ if step.parent_step_id is not None:
+ try:
+ parent_step = ProcessStep.by_id(db_conn,
+ step.parent_step_id)
+ if parent_step.owner_id != self.id_:
+ step.parent_step_id = None
+ except NotFoundException:
+ step.parent_step_id = None
+ walk_steps(step)
+ self.explicit_steps += [step]
+
+ def set_owners(self, db_conn: DatabaseConnection,
+ owner_ids: list[int]) -> None:
+ """Re-set owners to those identified in owner_ids."""
+ owners_old = self.used_as_step_by(db_conn)
+ losers = [o for o in owners_old if o.id_ not in owner_ids]
+ owners_old_ids = [o.id_ for o in owners_old]
+ winners = [Process.by_id(db_conn, id_) for id_ in owner_ids
+ if id_ not in owners_old_ids]
+ steps_to_remove = []
+ for loser in losers:
+ steps_to_remove += [s for s in loser.explicit_steps
+ if s.step_process_id == self.id_]
+ for step in steps_to_remove:
+ step.remove(db_conn)
+ for winner in winners:
+ assert isinstance(winner.id_, int)
+ assert isinstance(self.id_, int)
+ new_step = ProcessStep(None, winner.id_, self.id_, None)
+ new_explicit_steps = winner.explicit_steps + [new_step]
+ winner.set_steps(db_conn, new_explicit_steps)
def save(self, db_conn: DatabaseConnection) -> None:
"""Add (or re-write) self and connected items to DB."""
from typing import Any
from sqlite3 import Row
from plomtask.db import DatabaseConnection, BaseModel
-from plomtask.processes import Process
+from plomtask.processes import Process, ProcessStepsNode
from plomtask.versioned_attributes import VersionedAttribute
from plomtask.conditions import Condition, ConditionsRelations
from plomtask.exceptions import (NotFoundException, BadFormatException,
HandledException)
+from plomtask.dating import valid_date
@dataclass
table_name = 'todos'
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')]
+ to_save_relations = [('todo_conditions', 'todo', 'conditions', 0),
+ ('todo_blockers', 'todo', 'blockers', 0),
+ ('todo_enables', 'todo', 'enables', 0),
+ ('todo_disables', 'todo', 'disables', 0),
+ ('todo_children', 'parent', 'children', 0),
+ ('todo_children', 'child', 'parents', 1)]
+ to_search = ['comment']
# pylint: disable=too-many-arguments
def __init__(self, id_: int | None,
date: str, comment: str = '',
effort: None | float = None,
calendarize: bool = False) -> None:
- super().__init__(id_)
+ BaseModel.__init__(self, id_)
+ ConditionsRelations.__init__(self)
if process.id_ is None:
raise NotFoundException('Process of Todo without ID (not saved?)')
self.process = process
self._is_done = is_done
- self.date = date
+ self.date = valid_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.blockers = self.process.blockers[:]
self.enables = self.process.enables[:]
self.disables = self.process.disables[:]
+ @classmethod
+ def by_date_range(cls, db_conn: DatabaseConnection,
+ date_range: tuple[str, str] = ('', '')) -> list[Todo]:
+ """Collect Todos of Days within date_range."""
+ todos, _, _ = cls.by_date_range_with_limits(db_conn, date_range)
+ return todos
+
+ @classmethod
+ def create_with_children(cls, db_conn: DatabaseConnection,
+ process_id: int, date: str) -> Todo:
+ """Create Todo of process for date, ensure children."""
+
+ def key_order_func(n: ProcessStepsNode) -> int:
+ assert isinstance(n.process.id_, int)
+ return n.process.id_
+
+ def walk_steps(parent: Todo, step_node: ProcessStepsNode) -> Todo:
+ adoptables = [t for t in cls.by_date(db_conn, date)
+ if (t not in parent.children)
+ and (t != parent)
+ and step_node.process == t.process]
+ satisfier = None
+ for adoptable in adoptables:
+ satisfier = adoptable
+ break
+ if not satisfier:
+ satisfier = cls(None, step_node.process, False, date)
+ satisfier.save(db_conn)
+ sub_step_nodes = list(step_node.steps.values())
+ sub_step_nodes.sort(key=key_order_func)
+ for sub_node in sub_step_nodes:
+ if sub_node.is_suppressed:
+ continue
+ n_slots = len([n for n in sub_step_nodes
+ if n.process == sub_node.process])
+ filled_slots = len([t for t in satisfier.children
+ if t.process == sub_node.process])
+ # if we did not newly create satisfier, it may already fill
+ # some step dependencies, so only fill what remains open
+ if n_slots - filled_slots > 0:
+ satisfier.add_child(walk_steps(satisfier, sub_node))
+ satisfier.save(db_conn)
+ return satisfier
+
+ process = Process.by_id(db_conn, process_id)
+ todo = cls(None, process, False, date)
+ todo.save(db_conn)
+ steps_tree = process.get_steps(db_conn)
+ for step_node in steps_tree.values():
+ if step_node.is_suppressed:
+ continue
+ todo.add_child(walk_steps(todo, step_node))
+ todo.save(db_conn)
+ return todo
+
@classmethod
def from_table_row(cls, db_conn: DatabaseConnection,
row: Row | list[Any]) -> Todo:
'child', todo.id_):
# pylint: disable=no-member
todo.parents += [cls.by_id(db_conn, t_id)]
- for name in ('conditions', 'enables', 'disables'):
+ for name in ('conditions', 'blockers', 'enables', 'disables'):
table = f'todo_{name}'
assert isinstance(todo.id_, int)
for cond_id in db_conn.column_where(table, 'condition',
target += [Condition.by_id(db_conn, cond_id)]
return todo
+ @classmethod
+ def by_process_id(cls, db_conn: DatabaseConnection,
+ process_id: int | None) -> list[Todo]:
+ """Collect all Todos of Process of process_id."""
+ return [t for t in cls.all(db_conn) if t.process.id_ == process_id]
+
@classmethod
def by_date(cls, db_conn: DatabaseConnection, date: str) -> list[Todo]:
"""Collect all Todos for Day of date."""
- todos = []
- for id_ in db_conn.column_where('todos', 'id', 'day', date):
- todos += [cls.by_id(db_conn, id_)]
- return todos
+ return cls.by_date_range(db_conn, (date, date))
@property
def is_doable(self) -> bool:
for condition in self.conditions:
if not condition.is_active:
return False
+ for condition in self.blockers:
+ if condition.is_active:
+ return False
+ return True
+
+ @property
+ def is_deletable(self) -> bool:
+ """Decide whether self be deletable (not if preserve-worthy values)."""
+ if self.comment:
+ return False
+ if self.effort and self.effort >= 0:
+ return False
return True
+ @property
+ def performed_effort(self) -> float:
+ """Return performed effort, i.e. self.effort or default if done.."""
+ if self.effort is not None:
+ return self.effort
+ if self.is_done:
+ return self.effort_then
+ return 0
+
@property
def process_id(self) -> int | str | None:
"""Needed for super().save to save Processes as attributes."""
return self.process.id_
- @property
- def unsatisfied_dependencies(self) -> list[int]:
- """Return Process IDs of .process.explicit_steps not in .children."""
- unsatisfied = [s.step_process_id for s in self.process.explicit_steps
- if s.parent_step_id is None]
- for child_process_id in [c.process.id_ for c in self.children]:
- if child_process_id in unsatisfied:
- unsatisfied.remove(child_process_id)
- return unsatisfied
-
@property
def is_done(self) -> bool:
"""Wrapper around self._is_done so we can control its setter."""
"""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
+ @property
+ def title_then(self) -> str:
+ """Shortcut to .process.title.at(self.date)"""
+ title_then = self.process.title.at(self.date)
+ assert isinstance(title_then, str)
+ return title_then
+
+ @property
+ def effort_then(self) -> float:
+ """Shortcut to .process.effort.at(self.date)"""
+ effort_then = self.process.effort.at(self.date)
+ assert isinstance(effort_then, float)
+ return effort_then
- def make_missing_children(self, db_conn: DatabaseConnection) -> None:
- """Fill unsatisfied dependencies with new Todos."""
- for process_id in self.unsatisfied_dependencies:
- process = Process.by_id(db_conn, process_id)
- todo = self.__class__(None, process, False, self.date)
- todo.save(db_conn)
- self.add_child(todo)
+ @property
+ def has_doneness_in_path(self) -> bool:
+ """Check whether self is done or has any children that are."""
+ if self.is_done:
+ return True
+ for child in self.children:
+ if child.is_done:
+ return True
+ if child.has_doneness_in_path:
+ return True
+ return False
def get_step_tree(self, seen_todos: set[int]) -> TodoNode:
"""Return tree of depended-on Todos."""
return make_node(self)
+ @property
+ def tree_effort(self) -> float:
+ """Return sum of performed efforts of self and all descendants."""
+
+ def walk_tree(node: Todo) -> float:
+ local_effort = 0.0
+ for child in node.children:
+ local_effort += walk_tree(child)
+ return node.performed_effort + local_effort
+
+ return walk_tree(self)
+
def add_child(self, child: Todo) -> None:
"""Add child to self.children, avoid recursion, update parenthoods."""
self.children.remove(child)
child.parents.remove(self)
+ def save(self, db_conn: DatabaseConnection) -> None:
+ """On save calls, also check if auto-deletion by effort < 0."""
+ if self.effort and self.effort < 0 and self.is_deletable:
+ self.remove(db_conn)
+ return
+ super().save(db_conn)
+
def remove(self, db_conn: DatabaseConnection) -> None:
"""Remove from DB, including relations."""
+ if not self.is_deletable:
+ raise HandledException('Cannot remove non-deletable Todo.')
children_to_remove = self.children[:]
parents_to_remove = self.parents[:]
for child in children_to_remove:
from sqlite3 import Row
from time import sleep
from plomtask.db import DatabaseConnection
+from plomtask.exceptions import HandledException, BadFormatException
TIMESTAMP_FMT = '%Y-%m-%d %H:%M:%S.%f'
return self.default
return self.history[self._newest_timestamp]
+ def reset_timestamp(self, old_str: str, new_str: str) -> None:
+ """Rename self.history key (timestamp) old to new.
+
+ Chronological sequence of keys must be preserved, i.e. cannot move
+ key before earlier or after later timestamp.
+ """
+ try:
+ new = datetime.strptime(new_str, TIMESTAMP_FMT)
+ old = datetime.strptime(old_str, TIMESTAMP_FMT)
+ except ValueError as exc:
+ raise BadFormatException('Timestamp of illegal format.') from exc
+ timestamps = list(self.history.keys())
+ if old_str not in timestamps:
+ raise HandledException(f'Timestamp {old} not found in history.')
+ sorted_timestamps = sorted([datetime.strptime(t, TIMESTAMP_FMT)
+ for t in timestamps])
+ expected_position = sorted_timestamps.index(old)
+ sorted_timestamps.remove(old)
+ sorted_timestamps += [new]
+ sorted_timestamps.sort()
+ if sorted_timestamps.index(new) != expected_position:
+ raise HandledException('Timestamp not respecting chronology.')
+ value = self.history[old_str]
+ del self.history[old_str]
+ self.history[new_str] = value
+
def set(self, value: str | float) -> None:
"""Add to self.history if and only if not same value as newest one.
def at(self, queried_time: str) -> str | float:
"""Retrieve value of timestamp nearest queried_time from the past."""
+ if len(queried_time) == 10:
+ queried_time += ' 23:59:59.999'
sorted_timestamps = sorted(self.history.keys())
if 0 == len(sorted_timestamps):
return self.default
Jinja2==3.1.3
+unittest-parallel==1.6.1
echo "Running pylint on ${dir}/ …"
python3 -m pylint ${dir}/*.py
done
-echo "Running unittest on tests/."
-python3 -m unittest tests/*.py
+echo "Running unittest-parallel on tests/."
+unittest-parallel -t . -s tests/ -p '*.py'
+set +e
+rm test_db:*.*
+set -e
exit 0
font-family: monospace;
text-align: left;
padding: 0;
+ background-color: white;
+}
+input[type="text"] {
+ width: 100em;
+}
+input.timestamp {
+ width: 11em;
+}
+input.date {
+ width: 6em;
}
input.btn-harmless {
color: green;
text-align: right;
}
td, th, tr, table {
- vertical-align: top;
+ margin-top: 1em;
padding: 0;
+ border-collapse: collapse;
+}
+th, td {
+ padding-right: 1em;
+}
+a {
+ color: black;
+}
+table.edit_table > tbody > tr > td,
+table.edit_table > tbody > tr > th {
+ border-bottom: 0.1em solid #bbbbbb;
+ padding-top: 0.5em;
+ padding-bottom: 0.5em;
+}
+td.number, input[type="number"] {
+ text-align: right;
+}
+input[name="effort"] {
+ width: 3.5em;
+}
+textarea {
+ width: 100%;
+}
+table.alternating > tbody > tr:nth-child(odd) {
+ background-color: #dfdfdf;
+}
+div.edit_buttons {
+ margin-top: 1em;
}
{% 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>
+<a href="conditions">conditions</a>
+<a href="processes">processes</a>
+<a href="todos">todos</a>
<hr>
{% block content %}
{% endblock %}
{% macro edit_buttons() %}
+<div class="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>
+</div>
{% endmacro %}
-{% macro datalist_of_titles(title, candidates) %}
+{% macro datalist_of_titles(title, candidates, historical=false, with_comments=false) %}
<datalist id="{{title}}">
{% for candidate in candidates %}
-<option value="{{candidate.id_}}">{{candidate.title.newest|e}}</option>
+<option value="{{candidate.id_}}">
+{% if historical is true %}
+{{candidate.title_then|e}}
+{% else %}
+{{candidate.title.newest|e}}
+{% endif %}
+{% if with_comments and candidate.comment %}
+/ {{candidate.comment}}
+{% endif %}
+</option>
{% endfor %}
</datalist>
{% endmacro %}
-{% macro simple_checkbox_table(title, items, type_name, list_name, add_string="add") %}
+{% macro simple_checkbox_table(title, items, type_name, list_name, add_string="add", historical=false) %}
+{% if items|length > 0 %}
<table>
{% for item in items %}
<tr>
<input type="checkbox" name="{{title}}" value="{{item.id_}}" checked />
</td>
<td>
-<a href="{{type_name}}?id={{item.id_}}">{{item.title.newest|e}}</a>
+<a href="{{type_name}}?id={{item.id_}}">{% if historical is true %}{{item.title_then}}{% else %}{{item.title.newest|e}}{% endif %}</a>
</td>
</tr>
{% endfor %}
</table>
-{{add_string}}: <input name="{{title}}" list="{{list_name}}" autocomplete="off" />
+{% endif %}
+{{add_string}}: <input name="{{title}}" type="text" 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>
+<form action="{{item_name}}_{{attribute_name}}s?id={{item.id_}}" method="POST">
<table>
<tr>
<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><input name="at:{{date}}" class="timestamp" value="{{date|truncate(19, True, '', 0)}}"></td>
<td>{% if as_pre %}<pre>{% endif %}{{attribute.history[date]}}{% if as_pre %}</pre>{% endif %}</td>
</tr>
{% endfor %}
</table>
+<input class="btn-harmless" type="submit" name="update" value="update" />
+</form>
{% endmacro %}
{% block css %}
tr.week_row td {
- height: 0.1em;
+ height: 0.3em;
background-color: black;
padding: 0;
margin: 0;
+ border-top: 0.2em solid white;
}
tr.month_row td {
- border: 0.1em solid black;
- text-align: center;
+ border-top: 0.2em solid white;
+ color: white;
+ background-color: #555555;
+}
+table {
+ width: 100%;
+}
+tr.day_row td {
+ background-color: #cccccc;
+ border-top: 0.2em solid white;
}
td.day_name {
padding-right: 0.5em;
}
+td.today {
+ font-weight: bold;
+}
{% endblock %}
{% block content %}
<h3>calendar</h3>
+<p><a href="/calendar_txt">basic view</a></p>
+
<form action="calendar" method="GET">
-from <input name="start" value="{{start}}" />
-to <input name="end" value="{{end}}" />
+from <input name="start" class="date" value="{{start}}" />
+to <input name="end" class="date" value="{{end}}" />
<input type="submit" value="OK" />
</form>
<table>
{% if day.first_of_month %}
<tr class="month_row">
-<td colspan=3>{{ day.month_name }}</td>
+<td colspan=2>{{ day.month_name }}</td>
</tr>
{% endif %}
{% if day.weekday == "Monday" %}
<tr class="week_row">
-<td colspan=3></td>
+<td colspan=2></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 class="day_row">
+<td class="day_name {% if day.date == today %}today{% endif %}"><a href="day?date={{day.date}}">{{day.weekday|truncate(2,True,'',0)}} {% if day.date == today %} {% endif %}{{day.date}}</a> {{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>
+<td>[{% if todo.is_done %}X{% else %} {% endif %}] <a href="todo?id={{todo.id_}}">{{todo.title_then|e}}</a>{% if todo.comment %} · {{todo.comment|e}}{% endif %}</td>
</tr>
{% endfor %}
{% block content %}
-<h3>condition</h3>
+<h3>
+{% if is_new %}
+add NEW condition
+{% else %}
+edit condition of ID {{condition.id_}}
+{% endif %}
+</h3>
<form action="condition?id={{condition.id_ or ''}}" method="POST">
-<table>
+<table class="edit_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>
+<td><input name="title" type="text" 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/>
-
<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/>
-
+<tr>
+<th>enables</th>
+<td>
+{% for process in enabled_processes %}
+<a href="process?id={{process.id_}}">{{process.title.newest|e}}</a><br />
+{% endfor %}
+</td>
+</tr>
+<tr>
+<th>disables</th>
+<td>
+{% for process in disabled_processes %}
+<a href="process?id={{process.id_}}">{{process.title.newest|e}}</a><br />
+{% endfor %}
+</td>
+</tr>
+<tr>
+<th>enabled by</th>
+<td>
+{% for process in enabling_processes %}
+<a href="process?id={{process.id_}}">{{process.title.newest|e}}</a><br />
+{% endfor %}
+</td>
+</tr>
+<tr>
+<th>disabled by</th>
+<td>
+{% for process in disabling_processes %}
+<a href="process?id={{process.id_}}">{{process.title.newest|e}}</a><br />
+{% endfor %}
+</td>
+</tr>
</table>
+
{{ macros.edit_buttons() }}
{% endblock %}
{% block content %}
<h3>conditions</h3>
-<table>
+<form action="conditions" method="GET">
+<input type="submit" value="filter" />
+<input name="pattern" type="text" value="{{pattern}}" />
+</form>
+
+<table class="alternating">
<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>
{% 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, td.cond_line_1, td.cond_line_2 {
+ padding: 0;
+ border-top: 1px solid white;
}
td.cond_line_0 {
- background-color: #ffbbbb;
+ background-color: #bfbfbf;
}
td.cond_line_1 {
- background-color: #bbffbb;
+ background-color: #dfdfdf;
}
td.cond_line_2 {
- background-color: #bbbbff;
+ background-color: #fffff;
+}
+td.cond_line_corner {
+ max-width: 0px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: clip;
}
td.todo_line {
- border-bottom: 1px solid #bbbbbb;
+ border-bottom: 1px solid #bfbfbf;
+ height: 1.7em;
+}
+tr.inactive > td.todo_line {
+ background-color: #bfbfbf;
+ border-bottom: 1px solid white;
+}
+tr.hidden_undone > td, tr.hidden_undone a {
+ color: #9f9f9f;
+}
+td.left_border {
+ border-left: 1px solid black;
+}
+td.right_border {
+ border-right: 1px solid black;
+}
+input.ablers {
+ width: 50em;
}
{% endblock %}
{% macro show_node_undone(node, indent) %}
{% if not node.todo.is_done %}
-<tr>
+<tr {% if node.seen or not node.todo.is_doable %}class="inactive"{% endif %}>
+{% if not node.seen %}
<input type="hidden" name="todo_id" value="{{node.todo.id_}}" />
+{% endif %}
{% 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>
+<td class="cond_line_{{loop.index0 % 3}}">
+{% if condition in node.todo.conditions and not condition.is_active %}
+O
+{% elif condition in node.todo.blockers and condition.is_active %}
+!
+{% endif %}
+</td>
{% endfor %}
-<td class="todo_line">-></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) %} {% endfor %} +
-{% if node.seen %}({% endif %}<a href="todo?id={{node.todo.id_}}">{{node.todo.process.title.newest|e}}</a>{% if node.seen %}){% endif %}
+{% if node.seen %}
+<td class="todo_line left_border"></td>
+<td class="todo_line">{% if node.todo.effort %}{{ node.todo.effort }}{% endif %}</td>
+{% else %}
+<td class="todo_line left_border"><input name="done" type="checkbox" value="{{node.todo.id_}}" {% if not node.todo.is_doable %}disabled{% endif %}/></td>
+<td class="todo_line"><input name="effort" type="number" step=0.1 placeholder={{node.todo.effort_then}} value={{node.todo.effort}} /></td>
+{% endif %}
+<td class="todo_line right_border">
+{% for i in range(indent) %} {% endfor %} +
+{% if node.seen %}({% endif %}<a href="todo?id={{node.todo.id_}}">{{node.todo.title_then|e}}</a>{% if node.seen %}){% endif %}
</td>
-<td class="todo_line">-></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>
+<td class="cond_line_{{(conditions_present|length - loop.index) % 3}}">{% 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>
+<td colspan=2>
+{% if node.seen %}
+{{node.todo.comment|e}}
+{% else %}
+<input name="comment" type="text" value="{{node.todo.comment|e}}" />
+{% endif %}
+</td>
</tr>
{% endif %}
{% macro show_node_done(node, indent, path) %}
-{% if node.todo.is_done %}
-
-<tr>
-{% if path|length > 0 and not path[-1].todo.is_done %}
+{% if node.todo.has_doneness_in_path %}
+<tr{% if not node.todo.is_done %} class="hidden_undone"{% endif %}>
+<td class="number">{{ '{:4.1f}'.format(node.todo.performed_effort) }}</td>
+<td class="number">{{ '{:4.1f}'.format(node.todo.tree_effort) }}</td>
<td>
-({% for path_node in path %}<a href="todo?id={{path_node.todo.id_}}">{{path_node.todo.process.title.newest|e}}</a> <- {% endfor %})
-</td>
+{% for i in range(indent) %} {% endfor %} +
+{% if not node.todo.is_done %}({% endif %}{% if node.seen %}[{% endif %}<a href="todo?id={{node.todo.id_}}">{{node.todo.title_then|e}}</a>{% if node.seen %}]{% endif %}{% if not node.todo.is_done %}){% endif %}{% if node.todo.comment %} · {{node.todo.comment|e}}{% endif %}</td>
</tr>
-
-<tr>
-<td>
- +
-{% else %}
-<td>
-{% for i in range(indent) %} {% 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 %}
-{{ show_node_done(child, indent+1, path + [node]) }}
+{{ show_node_done(child, indent+1) }}
{% endfor %}
{% endif %}
-
+{% endif %}
{% endmacro %}
{% block content %}
-<h3>{{day.date}} / {{day.weekday}}</h3>
+<h3>{{day.date}} / {{day.weekday}} ({{total_effort|round(1)}})</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="day_comment" value="{{day.comment|e}}" />
-<input type="submit" value="OK" /><br />
-add todo: <input name="new_todo" list="processes" autocomplete="off" />
-<h4>todo</h4>
+<p>
+comment:
+<input name="day_comment" type="text" value="{{day.comment|e}}" />
+<input type="submit" value="OK" /></td>
+</p>
+
+<h4>to do</h4>
+
+<p>
+add: <input type="text" name="new_todo" list="processes">
+</p>
+<p>
+<select name="make_type">
+<option value="full">make new todos with children</option>
+<option value="empty"{% if make_type == "empty" %}selected {% endif %}>make new todos without children</option>
+</select>
+</p>
<table>
<tr>
-<th colspan={{ conditions_present|length}}>c</th>
-<th colspan=4>states</th>
-<th colspan={{ conditions_present|length}}>t</th>
+<th colspan={{ conditions_present|length + 3 + conditions_present|length }}>conditions</th>
<th>add enabler</th>
+<th>add disabler</th>
</tr>
{% for condition in conditions_present %}
{% for _ in conditions_present %}
{% if outer_loop.index > loop.index %}
<td class="cond_line_{{loop.index0 % 3}}">
-{% else %}
+{% elif outer_loop.index < loop.index %}
<td class="cond_line_{{outer_loop.index0 % 3}}">
-{% endif %}
-{% if outer_loop.index == loop.index %}
+{% else %}
+<td class="cond_line_{{outer_loop.index0 % 3}} cond_line_corner">×
{% endif %}
</td>
{% endfor %}
-<td class="cond_line_{{loop.index0 % 3}}">[{% if condition.is_active %}X{% else %} {% endif %}]</td>
-<td colspan=3 class="cond_line_{{loop.index0 % 3}}"><a href="condition?id={{condition.id_}}">{{condition.title.newest|e}}</a></td>
+<td class="cond_line_{{loop.index0 % 3}}"><input type="checkbox" disabled{% if condition.is_active %} checked{% endif %}></td>
+<td colspan=2 class="cond_line_{{loop.index0 % 3}}"><a href="condition?id={{condition.id_}}">{{condition.title.at(day.date)|e}}</a></td>
{% for _ in conditions_present %}
-{% if outer_loop.index0 + loop.index0 < conditions_present|length %}
+{% if outer_loop.index0 + loop.index < conditions_present|length %}
<td class="cond_line_{{outer_loop.index0 % 3}}">
-{% else %}
+{% elif outer_loop.index0 + loop.index > conditions_present|length %}
<td class="cond_line_{{(conditions_present|length - loop.index) % 3}}">
+{% else %}
+<td class="cond_line_{{outer_loop.index0 % 3}} cond_line_corner"> ×
{% endif %}
{% endfor %}
+
{% set list_name = "todos_for_%s"|format(condition.id_) %}
-<td><input name="new_todo" list="{{list_name}}" autocomplete="off" /></td>
+<td><input class="ablers" type="text" name="new_todo" list="{{list_name}}" autocomplete="off" /></td>
{{ macros.datalist_of_titles(list_name, enablers_for[condition.id_]) }}
</td>
+{% set list_name = "todos_against_%s"|format(condition.id_) %}
+<td><input class="ablers" type="text" name="new_todo" list="{{list_name}}" autocomplete="off" /></td>
+{{ macros.datalist_of_titles(list_name, disablers_for[condition.id_]) }}
+</td>
</tr>
{% endfor %}
{% for condition in conditions_present %}
<td class="cond_line_{{loop.index0 % 3}}"></td>
{% endfor %}
-<th colspan={{ 4 }}>doables</th>
+<th colspan=3>doables</th>
{% for condition in conditions_present %}
<td class="cond_line_{{(conditions_present|length - loop.index) % 3}}"></td>
{% endfor %}
-<th>comments</th>
+<th colspan=2>comments</th>
</tr>
{% for node in top_nodes %}
{{ show_node_undone(node, 0) }}
<h4>done</h4>
-<table>
+<table class="alternating">
+<tr>
+<th colspan=2>effort</th><th rowspan=2>action · comment</th>
+</tr>
+<tr>
+<th>self</th><th>tree</th>
+</tr>
{% for node in top_nodes %}
{{ show_node_done(node, 0, []) }}
{% endfor %}
+{% block css %}
+details > summary::after {
+ content: '[+]';
+}
+details summary {
+ list-style: none;
+}
+details[open] > summary::after {
+ content: '[-]';
+}
+{% endblock %}
+
+
+
{% macro step_with_steps(step_id, step_node, indent) %}
<tr>
<td>
<input type="hidden" name="step_{{step_id}}_parent_id" value="{{step_node.parent_id or ''}}" />
{% endif %}
</td>
-<td>{% for i in range(indent) %}+{%endfor %}
-{% if (not step_node.is_explicit) and step_node.seen %}
+
+{% if step_node.is_explicit and not step_node.seen %}
+<td colspan=2>
+<details>
+<summary>
+{% else %}
+<td>
+{% endif %}
+
+{% for i in range(indent) %}+{%endfor %}
+{% if step_node.is_suppressed %}<del>{% endif %}
+{% if step_node.seen %}
<a href="process?id={{step_node.process.id_}}">({{step_node.process.title.newest|e}})</a>
{% else %}
<a href="process?id={{step_node.process.id_}}">{{step_node.process.title.newest|e}}</a>
{% endif %}
+{% if step_node.is_suppressed %}</del>{% endif %}
+
+
+{% if step_node.is_explicit and not step_node.seen %}
+</summary>
+<div>add sub-step: <input name="new_step_to_{{step_id}}" list="process_candidates" autocomplete="off" size="100" /></div>
+</details>
+{% endif %}
+
</td>
+{% if (not step_node.is_explicit) and (not step_node.seen) %}
<td>
-{% if step_node.is_explicit %}
-add: <input name="new_step_to_{{step_id}}" list="candidates" autocomplete="off" />
-{% endif %}
+<input type="checkbox" name="suppresses" value="{{step_id}}" {% if step_node.is_suppressed %}checked{% endif %}> suppress
</td>
+{% endif %}
</tr>
{% if step_node.is_explicit or not step_node.seen %}
{% for substep_id, substep in step_node.steps.items() %}
{% block content %}
-<h3>process</h3>
+<h3>
+{% if is_new %}
+add NEW process
+{% else %}
+edit process of ID {{process.id_}}
+{% endif %}
+</h3>
<form action="process?id={{process.id_ or ''}}" method="POST">
-<table>
+<table class="edit_table">
<tr>
<th>title</th>
-<td><input name="title" value="{{process.title.newest|e}}" />{% if process.id_ %} [<a href="process_titles?id={{process.id_}}">history</a>]{% endif %}</td>
+<td><input name="title" type="text" 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}} />{% if process.id_ %} [<a href="process_efforts?id={{process.id_}}">history</a>]{% endif %}</td>
+<th>effort</th>
+<td><input type="number" name="effort" 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><br />{% if process.id_ %} [<a href="process_descriptions?id={{process.id_}}">history</a>]{% endif %}</td>
</tr>
-
<tr>
<th>calendarize</th>
<td><input type="checkbox" name="calendarize" {% if process.calendarize %}checked {% endif %}</td>
</tr>
-
<tr>
<th>conditions</th>
<td>{{ macros.simple_checkbox_table("condition", process.conditions, "condition", "condition_candidates") }}</td>
</tr>
-
+<tr>
+<th>blockers</th>
+<td>{{ macros.simple_checkbox_table("blocker", process.blockers, "condition", "condition_candidates") }}</td>
+</tr>
<tr>
<th>enables</th>
<td>{{ macros.simple_checkbox_table("enables", process.enables, "condition", "condition_candidates") }}</td>
</tr>
-
<tr>
<th>disables</th>
<td>{{ macros.simple_checkbox_table("disables", process.disables, "condition", "condition_candidates") }}</td>
</tr>
-
<tr>
<th>steps</th>
<td>
+{% if steps %}
<table>
{% for step_id, step_node in steps.items() %}
{{ step_with_steps(step_id, step_node, 0) }}
{% endfor %}
</table>
-add: <input name="new_top_step" list="step_candidates" autocomplete="off" />
+{% endif %}
+add: <input type="text" name="new_top_step" list="process_candidates" autocomplete="off" value="{{preset_top_step or ''}}" />
</td>
-<tr>
-
+</tr>
<tr>
<th>step of</th>
+<td>{{ macros.simple_checkbox_table("step_of", owners, "process", "process_candidates") }}</td>
+</tr>
+<tr>
+<th>todos</th>
<td>
-{% for owner in owners %}
-<a href="process?id={{owner.id_}}">{{owner.title.newest|e}}</a><br />
-{% endfor %}
+<a href="todos?process_id={{process.id_}}">{{n_todos}}</a><br />
</td>
-<tr>
-
+</tr>
</table>
+
{{ macros.edit_buttons() }}
</form>
-
{{ macros.datalist_of_titles("condition_candidates", condition_candidates) }}
-{{ macros.datalist_of_titles("step_candidates", step_candidates) }}
+{{ macros.datalist_of_titles("process_candidates", process_candidates) }}
{% endblock %}
{% block content %}
<h3>processes</h3>
-<table>
+<form action="processes" method="GET">
+<input type="submit" value="filter" />
+<input name="pattern" type="text" value="{{pattern}}" />
+</form>
+
+<table class="alternating">
<tr>
<th><a href="?sort_by={% if sort_by == "steps" %}-{% endif %}steps">steps</a></th>
+<th><a href="?sort_by={% if sort_by == "owners" %}-{% endif %}owners">owners</a></th>
+<th><a href="?sort_by={% if sort_by == "effort" %}-{% endif %}effort">effort</a></th>
<th><a href="?sort_by={% if sort_by == "title" %}-{% endif %}title">title</a></th>
</tr>
{% for process in processes %}
<tr>
-<td>{{ process.explicit_steps|count }}</td>
+<td class="number">{{ process.explicit_steps|count }}</td>
+<td class="number">{{ process.n_owners }}</td>
+<td class="number">{{ process.effort.newest }}</td>
<td><a href="process?id={{process.id_}}">{{process.title.newest}}</a></td>
</tr>
{% endfor %}
+{% block css %}
+select{ font-size: 0.5em; margin: 0; padding: 0; }
+{% endblock %}
+
+
+
+{% macro draw_tree_row(item, parent_todo, indent=0) %}
+<tr>
+<td>
+{% if item.todo %}
+{% if not item.process %}+{% else %} {% endif %}<input type="checkbox" name="adopt" value="{{item.todo.id_}}" checked {% if indent > 0 %}disabled{% endif %}/>
+{% endif %}
+</td>
+<td>
+{% for i in range(indent-1) %} {%endfor %}{% if indent > 0 %}·{% endif %}
+{% if item.todo %}
+<a href="todo?id={{item.todo.id_}}">{{item.todo.title_then|e}}</a>
+{% else %}
+{{item.process.title.newest|e}}
+{% if indent == 0 %}
+· fill: <select name="fill_for_{{item.id_}}">
+<option value="ignore">--</option>
+<option value="make_empty_{{item.process.id_}}">make empty</option>
+<option value="make_full_{{item.process.id_}}">make full</option>
+{% for adoptable in adoption_candidates_for[item.process.id_] %}
+<option value="{{adoptable.id_}}">adopt #{{adoptable.id_}}{% if adoptable.comment %} / {{adoptable.comment}}{% endif %}</option>
+{% endfor %}
+</select>
+{% endif %}
+{% endif %}
+</td>
+</tr>
+{% for child in item.children %}
+{{ draw_tree_row(child, item, indent+1) }}
+{% endfor %}
+{% endmacro %}
+
+
+
{% block content %}
-<h3>Todo: {{todo.process.title.newest|e}}</h3>
+<h3>Todo: {{todo.title_then|e}}</h3>
<form action="todo?id={{todo.id_}}" method="POST">
-<table>
+<table class="edit_table">
<tr>
<th>day</th>
<td><a href="day?date={{todo.date}}">{{todo.date}}</a></td>
</tr>
-
<tr>
<th>process</th>
<td><a href="process?id={{todo.process.id_}}">{{todo.process.title.newest|e}}</a></td>
</tr>
-
<tr>
<th>done</th>
-<td><input type="checkbox" name="done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/><br /></td>
+<td><input type="checkbox" name="done" {% if todo.is_done %}checked {% endif %} {% if not todo.is_doable %}disabled {% endif %}/>
+{% if not todo.is_doable and todo.is_done %}<input type="hidden" name="done" value="1" />{% endif %}
+</td>
</tr>
-
<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>
+<td><input type="number" name="effort" step=0.1 placeholder={{todo.effort_then}} value={{todo.effort}} /></td>
</tr>
-
<tr>
<th>comment</th>
-<td><input name="comment" value="{{todo.comment|e}}"/></td>
+<td><input name="comment" type="text" 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>blockers</th>
+<td>{{ macros.simple_checkbox_table("blocker", todo.blockers, "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 %}
-<a href="todo?id={{parent.id_}}">{{parent.process.title.newest|e}}</a><br />
+<a href="todo?id={{parent.id_}}">{{parent.title_then|e}}</a><br />
{% endfor %}
</td>
</tr>
-
<tr>
-<th>children</th>
-<td>{{ macros.simple_checkbox_table("adopt", todo.children, "adopt", "todo_candidates", "adopt") }}</td>
+<th>descendants</th>
+<td>
+{% if steps_todo_to_process|length > 0 %}
+<table>
+{% for step in steps_todo_to_process %}
+{{ draw_tree_row(step, todo) }}
+{% endfor %}
+</table>
+{% endif %}
+adopt: <input type="text" name="adopt" list="todo_candidates" autocomplete="off" /><br />
+make empty: <input type="text" name="make_empty" list="process_candidates" autocomplete="off" /><br />
+make full: <input type="text" name="make_full" list="process_candidates" autocomplete="off" />
+</td>
</tr>
-
</table>
+
{{ macros.edit_buttons() }}
</form>
-
{{ macros.datalist_of_titles("condition_candidates", condition_candidates) }}
-{{ macros.datalist_of_titles("todo_candidates", todo_candidates) }}
+{{ macros.datalist_of_titles("process_candidates", process_candidates) }}
+{{ macros.datalist_of_titles("todo_candidates", todo_candidates, historical=true, with_comments=true) }}
{% endblock %}
--- /dev/null
+{% extends '_base.html' %}
+{% import '_macros.html' as macros %}
+
+
+
+{% block content %}
+<h3>todos</h3>
+
+<form action="todos" method="GET">
+from <input name="start" class="date" value="{{start}}" />
+to <input name="end" class="date" value="{{end}}" /><br />
+process <input name="process_id" type="text" value="{{process_id or ''}}" list="processes" /><br />
+in comment <input name="comment_pattern" type="text" value="{{comment_pattern}}" /><br />
+<input type="submit" value="filter" />
+</form>
+
+<table class="alternating">
+<tr>
+<th><a href="?sort_by={% if sort_by == "doneness" %}-{% endif %}doneness">done</a></th>
+<th><a href="?sort_by={% if sort_by == "date" %}-{% endif %}date">date</a></th>
+<th><a href="?sort_by={% if sort_by == "title" %}-{% endif %}title">title</a></th>
+<th><a href="?sort_by={% if sort_by == "comment" %}-{% endif %}comment">comment</a></th>
+</tr>
+{% for todo in todos %}
+<tr>
+<td>[{% if todo.is_done %}x{% else %} {% endif %}]</td>
+<td><a href="day?date={{todo.date}}">{{todo.date}}</a></td>
+<td><a href="todo?id={{todo.id_}}">{{todo.title_then}}</a></td>
+<td>{{todo.comment}}</td>
+</tr>
+{% endfor %}
+</table>
+{{ macros.datalist_of_titles("processes", all_processes) }}
+{% endblock %}
+
from unittest import TestCase
from datetime import datetime
from tests.utils import TestCaseWithDB, TestCaseWithServer
-from plomtask.days import Day, todays_date
+from plomtask.dating import date_in_n_days
+from plomtask.days import Day
from plomtask.exceptions import BadFormatException
"""Test .by_id()."""
self.check_by_id()
- def test_Day_all(self) -> None:
- """Test Day.all(), especially in regards to date range filtering."""
+ def test_Day_by_date_range_filled(self) -> None:
+ """Test Day.by_date_range_filled."""
date1, date2, date3 = self.default_ids
day1, day2, day3 = self.check_all()
- self.assertEqual(Day.all(self.db_conn, ('', '')),
- [day1, day2, day3])
# check date range is a closed interval
- self.assertEqual(Day.all(self.db_conn, (date1, date3)),
+ self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date3),
[day1, day2, day3])
# check first date range value excludes what's earlier
- self.assertEqual(Day.all(self.db_conn, (date2, date3)),
+ self.assertEqual(Day.by_date_range_filled(self.db_conn, date2, date3),
[day2, day3])
- self.assertEqual(Day.all(self.db_conn, (date3, '')),
- [day3])
# check second date range value excludes what's later
- self.assertEqual(Day.all(self.db_conn, ('', date2)),
+ self.assertEqual(Day.by_date_range_filled(self.db_conn, date1, date2),
[day1, day2])
# check swapped (impossible) date range returns emptiness
- self.assertEqual(Day.all(self.db_conn, (date3, date1)),
+ self.assertEqual(Day.by_date_range_filled(self.db_conn, date3, date1),
[])
# check fill_gaps= instantiates unsaved dates within date range
# (but does not store them)
- day4 = Day('2024-01-04')
day5 = Day('2024-01-05')
day6 = Day('2024-01-06')
day6.save(self.db_conn)
- self.assertEqual(Day.all(self.db_conn, (date2, '2024-01-07'),
- fill_gaps=True),
- [day2, day3, day4, day5, day6])
+ day7 = Day('2024-01-07')
+ self.assertEqual(Day.by_date_range_filled(self.db_conn,
+ day5.date, day7.date),
+ [day5, day6, day7])
self.check_storage([day1, day2, day3, day6])
# check 'today' is interpreted as today's date
- today = Day(todays_date())
+ today = Day(date_in_n_days(0))
today.save(self.db_conn)
- self.assertEqual(Day.all(self.db_conn, ('today', 'today')), [today])
+ self.assertEqual(Day.by_date_range_filled(self.db_conn,
+ 'today', 'today'),
+ [today])
def test_Day_remove(self) -> None:
"""Test .remove() effects on DB and cache."""
def test_do_POST_day(self) -> None:
"""Test POST /day."""
- form_data = {'day_comment': ''}
+ form_data = {'day_comment': '', 'make_type': 'full'}
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)
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
self.check_post({'foo': ''}, '/day?date=2024-01-01', 400)
class TestsSansServer(TestCase):
"""Tests that do not require DB setup or a server."""
- def test_InputsParser_non_strict(self) -> None:
- """Test behavior of non-strict (= params) InputsParser."""
- params = InputsParser({}, False)
- self.assertEqual('', params.get_str('foo'))
- params = InputsParser({}, False)
- self.assertEqual('bar', params.get_str('foo', 'bar'))
- params = InputsParser({'foo': []}, False)
- self.assertEqual('bar', params.get_str('foo', 'bar'))
- params = InputsParser({'foo': ['baz']}, False)
- self.assertEqual('baz', params.get_str('foo', 'bar'))
- params = InputsParser({}, False)
- self.assertEqual(None, params.get_int_or_none('foo'))
- params = InputsParser({'foo': []}, False)
- self.assertEqual(None, params.get_int_or_none('foo'))
- params = InputsParser({'foo': ['']}, False)
- self.assertEqual(None, params.get_int_or_none('foo'))
- params = InputsParser({'foo': ['0']}, False)
- self.assertEqual(0, params.get_int_or_none('foo'))
+ def test_InputsParser_get_str(self) -> None:
+ """Test InputsParser.get_str on strict and non-strictk."""
+ parser = InputsParser({}, False)
+ self.assertEqual('', parser.get_str('foo'))
+ self.assertEqual('bar', parser.get_str('foo', 'bar'))
+ parser.strict = True
with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['None']}, False).get_int_or_none('foo')
+ parser.get_str('foo')
with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['0.1']}, False).get_int_or_none('foo')
- params = InputsParser({'foo': ['23']}, False)
- self.assertEqual(23, params.get_int_or_none('foo'))
-
- def test_InputsParser_strict(self) -> None:
- """Test behavior of strict (= postvars) InputsParser."""
- self.assertEqual([],
- InputsParser({}).get_all_str('foo'))
- self.assertEqual([],
- InputsParser({'foo': []}).get_all_str('foo'))
- self.assertEqual(['bar'],
- InputsParser({'foo': ['bar']}).get_all_str('foo'))
- self.assertEqual(['bar', 'baz'],
- InputsParser({'foo': ['bar', 'baz']}).
- get_all_str('foo'))
- self.assertEqual([],
- InputsParser({}).get_all_int('foo'))
- self.assertEqual([],
- InputsParser({'foo': []}).get_all_int('foo'))
- self.assertEqual([],
- InputsParser({'foo': ['']}).get_all_int('foo'))
- self.assertEqual([0],
- InputsParser({'foo': ['0']}).get_all_int('foo'))
- self.assertEqual([0, 17],
- InputsParser({'foo': ['0', '17']}).
- get_all_int('foo'))
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['0.1', '17']}).get_all_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['None', '17']}).get_all_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({}).get_str('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}).get_str('foo')
- self.assertEqual('bar',
- InputsParser({'foo': ['bar']}).get_str('foo'))
- self.assertEqual('',
- InputsParser({'foo': ['', 'baz']}).get_str('foo'))
- with self.assertRaises(BadFormatException):
- InputsParser({}).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['']}).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['bar']}).get_int('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['0.1']}).get_int('foo')
- self.assertEqual(0,
- InputsParser({'foo': ['0']}).get_int('foo'))
- self.assertEqual(17,
- InputsParser({'foo': ['17', '23']}).get_int('foo'))
+ parser.get_str('foo', 'bar')
+ parser = InputsParser({'foo': []}, False)
+ self.assertEqual('bar', parser.get_str('foo', 'bar'))
with self.assertRaises(BadFormatException):
- InputsParser({}).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': []}).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['']}).get_float('foo')
- with self.assertRaises(BadFormatException):
- InputsParser({'foo': ['bar']}).get_float('foo')
- self.assertEqual(0,
- InputsParser({'foo': ['0']}).get_float('foo'))
- self.assertEqual(0.1,
- InputsParser({'foo': ['0.1']}).get_float('foo'))
- self.assertEqual(1.23,
- InputsParser({'foo': ['1.23', '456']}).
- get_float('foo'))
+ InputsParser({'foo': []}, True).get_str('foo', 'bar')
+ for strictness in (False, True):
+ parser = InputsParser({'foo': ['baz']}, strictness)
+ self.assertEqual('baz', parser.get_str('foo', 'bar'))
+ parser = InputsParser({'foo': ['baz', 'quux']}, strictness)
+ self.assertEqual('baz', parser.get_str('foo', 'bar'))
+
+ def test_InputsParser_get_first_strings_starting(self) -> None:
+ """Test InputsParser.get_first_strings_starting [non-]strict."""
+ for strictness in (False, True):
+ parser = InputsParser({}, strictness)
+ self.assertEqual({},
+ parser.get_first_strings_starting(''))
+ parser = InputsParser({}, strictness)
+ self.assertEqual({},
+ parser.get_first_strings_starting('foo'))
+ parser = InputsParser({'foo': ['bar']}, strictness)
+ self.assertEqual({'foo': 'bar'},
+ parser.get_first_strings_starting(''))
+ parser = InputsParser({'x': ['y']}, strictness)
+ self.assertEqual({'x': 'y'},
+ parser.get_first_strings_starting('x'))
+ parser = InputsParser({'xx': ['y']}, strictness)
+ self.assertEqual({'xx': 'y'},
+ parser.get_first_strings_starting('x'))
+ parser = InputsParser({'xx': ['y']}, strictness)
+ self.assertEqual({},
+ parser.get_first_strings_starting('xxx'))
+ d = {'xxx': ['x'], 'xxy': ['y'], 'xyy': ['z']}
+ parser = InputsParser(d, strictness)
+ self.assertEqual({'xxx': 'x', 'xxy': 'y'},
+ parser.get_first_strings_starting('xx'))
+ d = {'xxx': ['x', 'y', 'z'], 'xxy': ['y', 'z']}
+ parser = InputsParser(d, strictness)
+ self.assertEqual({'xxx': 'x', 'xxy': 'y'},
+ parser.get_first_strings_starting('xx'))
+
+ def test_InputsParser_get_int(self) -> None:
+ """Test InputsParser.get_int on strict and non-strict."""
+ for strictness in (False, True):
+ with self.assertRaises(BadFormatException):
+ InputsParser({}, strictness).get_int('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': []}, strictness).get_int('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['']}, strictness).get_int('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['bar']}, strictness).get_int('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['0.1']}).get_int('foo')
+ parser = InputsParser({'foo': ['0']}, strictness)
+ self.assertEqual(0, parser.get_int('foo'))
+ parser = InputsParser({'foo': ['17', '23']}, strictness)
+ self.assertEqual(17, parser.get_int('foo'))
+
+ def test_InputsParser_get_int_or_none(self) -> None:
+ """Test InputsParser.get_int_or_none on strict and non-strict."""
+ for strictness in (False, True):
+ parser = InputsParser({}, strictness)
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': []}, strictness)
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': ['']}, strictness)
+ self.assertEqual(None, parser.get_int_or_none('foo'))
+ parser = InputsParser({'foo': ['0']}, strictness)
+ self.assertEqual(0, parser.get_int_or_none('foo'))
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['None']},
+ strictness).get_int_or_none('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['0.1']},
+ strictness).get_int_or_none('foo')
+ parser = InputsParser({'foo': ['23']}, strictness)
+ self.assertEqual(23, parser.get_int_or_none('foo'))
+
+ def test_InputsParser_get_float(self) -> None:
+ """Test InputsParser.get_float on strict and non-strict."""
+ for strictness in (False, True):
+ with self.assertRaises(BadFormatException):
+ InputsParser({}, strictness).get_float('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': []}, strictness).get_float('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['']}, strictness).get_float('foo')
+ with self.assertRaises(BadFormatException):
+ InputsParser({'foo': ['bar']}, strictness).get_float('foo')
+ parser = InputsParser({'foo': ['0']}, strictness)
+ self.assertEqual(0, parser.get_float('foo'))
+ parser = InputsParser({'foo': ['0.1']}, strictness)
+ self.assertEqual(0.1, parser.get_float('foo'))
+ parser = InputsParser({'foo': ['1.23', '456']}, strictness)
+ self.assertEqual(1.23, parser.get_float('foo'))
+
+ def test_InputsParser_get_all_str(self) -> None:
+ """Test InputsParser.get_all_str on strict and non-strict."""
+ for strictness in (False, True):
+ parser = InputsParser({}, strictness)
+ self.assertEqual([], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': []}, strictness)
+ self.assertEqual([], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': ['bar']}, strictness)
+ self.assertEqual(['bar'], parser.get_all_str('foo'))
+ parser = InputsParser({'foo': ['bar', 'baz']}, strictness)
+ self.assertEqual(['bar', 'baz'], parser.get_all_str('foo'))
+
+ def test_InputsParser_strict_get_all_int(self) -> None:
+ """Test InputsParser.get_all_int on strict and non-strict."""
+ for strictness in (False, True):
+ parser = InputsParser({}, strictness)
+ self.assertEqual([], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': []}, strictness)
+ self.assertEqual([], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['']}, strictness)
+ self.assertEqual([], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['0']}, strictness)
+ self.assertEqual([0], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['0', '17']}, strictness)
+ self.assertEqual([0, 17], parser.get_all_int('foo'))
+ parser = InputsParser({'foo': ['0.1', '17']}, strictness)
+ with self.assertRaises(BadFormatException):
+ parser.get_all_int('foo')
+ parser = InputsParser({'foo': ['None', '17']}, strictness)
+ with self.assertRaises(BadFormatException):
+ parser.get_all_int('foo')
class TestsWithServer(TestCaseWithServer):
def test_Process_steps(self) -> None:
"""Test addition, nesting, and non-recursion of ProcessSteps"""
- def add_step(proc: Process,
- steps_proc: list[tuple[int | None, int, int | None]],
- step_tuple: tuple[int | None, int, int | None],
- expected_id: int) -> None:
- steps_proc += [step_tuple]
- proc.set_steps(self.db_conn, steps_proc)
- steps_proc[-1] = (expected_id, step_tuple[1], step_tuple[2])
+ # pylint: disable=too-many-locals
+ # pylint: disable=too-many-statements
p1, p2, p3 = self.three_processes()
assert isinstance(p1.id_, int)
assert isinstance(p2.id_, int)
assert isinstance(p3.id_, int)
- steps_p1: list[tuple[int | None, int, int | None]] = []
- add_step(p1, steps_p1, (None, p2.id_, None), 1)
+ steps_p1: list[ProcessStep] = []
+ # add step of process p2 as first (top-level) step to p1
+ s_p2_to_p1 = ProcessStep(None, p1.id_, p2.id_, None)
+ steps_p1 += [s_p2_to_p1]
+ p1.set_steps(self.db_conn, steps_p1)
p1_dict: dict[int, ProcessStepsNode] = {}
- p1_dict[1] = ProcessStepsNode(p2, None, True, {}, False)
+ p1_dict[1] = ProcessStepsNode(p2, None, True, {})
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- add_step(p1, steps_p1, (None, p3.id_, None), 2)
- step_2 = p1.explicit_steps[-1]
- p1_dict[2] = ProcessStepsNode(p3, None, True, {}, False)
+ # add step of process p3 as second (top-level) step to p1
+ s_p3_to_p1 = ProcessStep(None, p1.id_, p3.id_, None)
+ steps_p1 += [s_p3_to_p1]
+ p1.set_steps(self.db_conn, steps_p1)
+ p1_dict[2] = ProcessStepsNode(p3, None, True, {})
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- steps_p2: list[tuple[int | None, int, int | None]] = []
- add_step(p2, steps_p2, (None, p3.id_, None), 3)
- p1_dict[1].steps[3] = ProcessStepsNode(p3, None, False, {}, False)
+ # add step of process p3 as first (top-level) step to p2,
+ steps_p2: list[ProcessStep] = []
+ s_p3_to_p2 = ProcessStep(None, p2.id_, p3.id_, None)
+ steps_p2 += [s_p3_to_p2]
+ p2.set_steps(self.db_conn, steps_p2)
+ # expect it as implicit sub-step of p1's second (p3) step
+ p2_dict = {3: ProcessStepsNode(p3, None, False, {})}
+ p1_dict[1].steps[3] = p2_dict[3]
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- add_step(p1, steps_p1, (None, p2.id_, step_2.id_), 4)
- step_3 = ProcessStepsNode(p3, None, False, {}, True)
- p1_dict[2].steps[4] = ProcessStepsNode(p2, step_2.id_, True,
- {3: step_3}, False)
+ # add step of process p2 as explicit sub-step to p1's second sub-step
+ s_p2_to_p1_first = ProcessStep(None, p1.id_, p2.id_, s_p3_to_p1.id_)
+ steps_p1 += [s_p2_to_p1_first]
+ p1.set_steps(self.db_conn, steps_p1)
+ seen_3 = ProcessStepsNode(p3, None, False, {}, True)
+ p1_dict[2].steps[4] = ProcessStepsNode(p2, s_p3_to_p1.id_, True,
+ {3: seen_3})
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- add_step(p1, steps_p1, (None, p3.id_, 999), 5)
- p1_dict[5] = ProcessStepsNode(p3, None, True, {}, False)
+ # add step of process p3 as explicit sub-step to non-existing p1
+ # sub-step (of id=999), expect it to become another p1 top-level step
+ s_p3_to_p1_999 = ProcessStep(None, p1.id_, p3.id_, 999)
+ steps_p1 += [s_p3_to_p1_999]
+ p1.set_steps(self.db_conn, steps_p1)
+ p1_dict[5] = ProcessStepsNode(p3, None, True, {})
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
- add_step(p1, steps_p1, (None, p3.id_, 3), 6)
- p1_dict[6] = ProcessStepsNode(p3, None, True, {}, False)
+ # add step of process p3 as explicit sub-step to p1's implicit p3
+ # sub-step, expect it to become another p1 top-level step
+ s_p3_to_p1_impl_p3 = ProcessStep(None, p1.id_, p3.id_, s_p3_to_p2.id_)
+ steps_p1 += [s_p3_to_p1_impl_p3]
+ p1.set_steps(self.db_conn, steps_p1)
+ p1_dict[6] = ProcessStepsNode(p3, None, True, {})
self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
self.assertEqual(p1.used_as_step_by(self.db_conn), [])
self.assertEqual(p2.used_as_step_by(self.db_conn), [p1])
self.assertEqual(p3.used_as_step_by(self.db_conn), [p1, p2])
+ # # add step of process p3 as explicit sub-step to p1's first sub-step,
+ # # expect it to eliminate implicit p3 sub-step
+ # s_p3_to_p1_first_explicit = ProcessStep(None, p1.id_, p3.id_,
+ # s_p2_to_p1.id_)
+ # p1_dict[1].steps = {7: ProcessStepsNode(p3, 1, True, {})}
+ # p1_dict[2].steps[4].steps[3].seen = False
+ # steps_p1 += [s_p3_to_p1_first_explicit]
+ # p1.set_steps(self.db_conn, steps_p1)
+ # self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # ensure implicit steps non-top explicit steps are shown
+ s_p3_to_p2_first = ProcessStep(None, p2.id_, p3.id_, s_p3_to_p2.id_)
+ steps_p2 += [s_p3_to_p2_first]
+ p2.set_steps(self.db_conn, steps_p2)
+ p1_dict[1].steps[3].steps[7] = ProcessStepsNode(p3, 3, False, {})
+ p1_dict[2].steps[4].steps[3].steps[7] = ProcessStepsNode(p3, 3, False,
+ {}, True)
+ self.assertEqual(p1.get_steps(self.db_conn, None), p1_dict)
+ # ensure suppressed step nodes are hidden
+ assert isinstance(s_p3_to_p2.id_, int)
+ p1.set_step_suppressions(self.db_conn, [s_p3_to_p2.id_])
+ p1_dict[1].steps[3].steps = {}
+ p1_dict[1].steps[3].is_suppressed = True
+ p1_dict[2].steps[4].steps[3].steps = {}
+ p1_dict[2].steps[4].steps[3].is_suppressed = True
+ self.assertEqual(p1.get_steps(self.db_conn), p1_dict)
def test_Process_conditions(self) -> None:
"""Test setting Process.conditions/enables/disables."""
assert isinstance(p1.id_, int)
assert isinstance(p2.id_, int)
assert isinstance(p3.id_, int)
- p2.set_steps(self.db_conn, [(None, p1.id_, None)])
+ step = ProcessStep(None, p2.id_, p1.id_, None)
+ p2.set_steps(self.db_conn, [step])
with self.assertRaises(HandledException):
p1.remove(self.db_conn)
- step = p2.explicit_steps[0]
p2.set_steps(self.db_conn, [])
with self.assertRaises(NotFoundException):
ProcessStep.by_id(self.db_conn, step.id_)
p1.remove(self.db_conn)
- p2.set_steps(self.db_conn, [(None, p3.id_, None)])
- step = p2.explicit_steps[0]
+ step = ProcessStep(None, p2.id_, p3.id_, None)
+ p2.set_steps(self.db_conn, [step])
p2.remove(self.db_conn)
with self.assertRaises(NotFoundException):
ProcessStep.by_id(self.db_conn, step.id_)
p2 = Process(None)
p1.save(self.db_conn)
p2.save(self.db_conn)
+ assert isinstance(p1.id_, int)
assert isinstance(p2.id_, int)
- p1.set_steps(self.db_conn, [(None, p2.id_, None)])
- step = p1.explicit_steps[0]
+ step = ProcessStep(None, p1.id_, p2.id_, None)
+ p1.set_steps(self.db_conn, [step])
step.remove(self.db_conn)
self.assertEqual(p1.explicit_steps, [])
self.check_storage([])
self.check_post(form_data, '/process?id=6', 404)
self.check_post(form_data, '/process?id=5', 302, '/processes')
+ def test_do_POST_process_steps(self) -> None:
+ """Test behavior of ProcessStep posting."""
+ # pylint: disable=too-many-statements
+ form_data_1 = self.post_process(1)
+ self.post_process(2)
+ self.post_process(3)
+ # post first (top-level) step of process 2 to process 1
+ form_data_1['new_top_step'] = [2]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 1)
+ retrieved_step = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step.step_process_id, 2)
+ self.assertEqual(retrieved_step.owner_id, 1)
+ self.assertEqual(retrieved_step.parent_step_id, None)
+ # post empty steps list to process, expect clean slate, and old step to
+ # completely disappear
+ form_data_1['new_top_step'] = []
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(retrieved_process.explicit_steps, [])
+ with self.assertRaises(NotFoundException):
+ ProcessStep.by_id(self.db_conn, retrieved_step.id_)
+ # post new first (top_level) step of process 3 to process 1
+ form_data_1['new_top_step'] = [3]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ retrieved_step = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step.step_process_id, 3)
+ self.assertEqual(retrieved_step.owner_id, 1)
+ self.assertEqual(retrieved_step.parent_step_id, None)
+ # post to process steps list without keeps, expect clean slate
+ form_data_1['new_top_step'] = []
+ form_data_1['steps'] = [retrieved_step.id_]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(retrieved_process.explicit_steps, [])
+ # post to process empty steps list but keep, expect 400
+ form_data_1['steps'] = []
+ form_data_1['keep_step'] = [retrieved_step.id_]
+ self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
+ # post to process steps list with keep on non-created step, expect 400
+ form_data_1['steps'] = [retrieved_step.id_]
+ form_data_1['keep_step'] = [retrieved_step.id_]
+ self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
+ # post to process steps list with keep and process ID, expect 200
+ form_data_1[f'step_{retrieved_step.id_}_process_id'] = [2]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 1)
+ retrieved_step = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step.step_process_id, 2)
+ self.assertEqual(retrieved_step.owner_id, 1)
+ self.assertEqual(retrieved_step.parent_step_id, None)
+ # post nonsense, expect 400 and preservation of previous state
+ form_data_1['steps'] = ['foo']
+ form_data_1['keep_step'] = []
+ self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 1)
+ retrieved_step = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step.step_process_id, 2)
+ self.assertEqual(retrieved_step.owner_id, 1)
+ self.assertEqual(retrieved_step.parent_step_id, None)
+ # post to process steps list with keep and process ID, expect 200
+ form_data_1['new_top_step'] = [3]
+ form_data_1['steps'] = [retrieved_step.id_]
+ form_data_1['keep_step'] = [retrieved_step.id_]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 2)
+ retrieved_step_0 = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step_0.step_process_id, 2)
+ self.assertEqual(retrieved_step_0.owner_id, 1)
+ self.assertEqual(retrieved_step_0.parent_step_id, None)
+ retrieved_step_1 = retrieved_process.explicit_steps[1]
+ self.assertEqual(retrieved_step_1.step_process_id, 3)
+ self.assertEqual(retrieved_step_1.owner_id, 1)
+ self.assertEqual(retrieved_step_1.parent_step_id, None)
+ # post to process steps list with keeps etc., but trigger recursion
+ form_data_1['new_top_step'] = []
+ form_data_1['steps'] = [retrieved_step_0.id_, retrieved_step_1.id_]
+ form_data_1['keep_step'] = [retrieved_step_0.id_, retrieved_step_1.id_]
+ form_data_1[f'step_{retrieved_step_0.id_}_process_id'] = [2]
+ form_data_1[f'step_{retrieved_step_1.id_}_process_id'] = [1]
+ self.check_post(form_data_1, '/process?id=1', 400, '/process?id=1')
+ # check previous status preserved despite failed steps setting
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 2)
+ retrieved_step_0 = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step_0.step_process_id, 2)
+ self.assertEqual(retrieved_step_0.owner_id, 1)
+ self.assertEqual(retrieved_step_0.parent_step_id, None)
+ retrieved_step_1 = retrieved_process.explicit_steps[1]
+ self.assertEqual(retrieved_step_1.step_process_id, 3)
+ self.assertEqual(retrieved_step_1.owner_id, 1)
+ self.assertEqual(retrieved_step_1.parent_step_id, None)
+ form_data_1[f'step_{retrieved_step_1.id_}_process_id'] = [3]
+ # post sub-step to step
+ form_data_1[f'new_step_to_{retrieved_step_1.id_}'] = [3]
+ self.post_process(1, form_data_1)
+ retrieved_process = Process.by_id(self.db_conn, 1)
+ self.assertEqual(len(retrieved_process.explicit_steps), 3)
+ retrieved_step_0 = retrieved_process.explicit_steps[0]
+ self.assertEqual(retrieved_step_0.step_process_id, 2)
+ self.assertEqual(retrieved_step_0.owner_id, 1)
+ self.assertEqual(retrieved_step_0.parent_step_id, None)
+ retrieved_step_1 = retrieved_process.explicit_steps[1]
+ self.assertEqual(retrieved_step_1.step_process_id, 3)
+ self.assertEqual(retrieved_step_1.owner_id, 1)
+ self.assertEqual(retrieved_step_1.parent_step_id, None)
+ retrieved_step_2 = retrieved_process.explicit_steps[2]
+ self.assertEqual(retrieved_step_2.step_process_id, 3)
+ self.assertEqual(retrieved_step_2.owner_id, 1)
+ self.assertEqual(retrieved_step_2.parent_step_id, retrieved_step_1.id_)
+
def test_do_GET(self) -> None:
"""Test /process and /processes response codes."""
- self.post_process()
self.check_get_defaults('/process')
self.check_get('/processes', 200)
"""Test Todos module."""
from tests.utils import TestCaseWithDB, TestCaseWithServer
from plomtask.todos import Todo, TodoNode
-from plomtask.processes import Process
+from plomtask.processes import Process, ProcessStep
from plomtask.conditions import Condition
from plomtask.exceptions import (NotFoundException, BadFormatException,
HandledException)
t2.save(self.db_conn)
self.assertEqual(Todo.by_date(self.db_conn, self.date1), [t1, t2])
self.assertEqual(Todo.by_date(self.db_conn, self.date2), [])
- self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
+ with self.assertRaises(BadFormatException):
+ self.assertEqual(Todo.by_date(self.db_conn, 'foo'), [])
def test_Todo_on_conditions(self) -> None:
"""Test effect of Todos on Conditions."""
node_0.children += [node_4]
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."""
+ def test_Todo_create_with_children(self) -> None:
+ """Test parenthood guaranteeds of Todo.create_with_children."""
assert isinstance(self.proc.id_, int)
- todo_1 = Todo(None, self.proc, False, self.date1)
- todo_1.save(self.db_conn)
proc2 = Process(None)
proc2.save(self.db_conn)
assert isinstance(proc2.id_, int)
proc4 = Process(None)
proc4.save(self.db_conn)
assert isinstance(proc4.id_, int)
- proc3.set_steps(self.db_conn, [(None, proc4.id_, None)])
- proc2.set_steps(self.db_conn, [(None, self.proc.id_, None),
- (None, self.proc.id_, None),
- (None, proc3.id_, None)])
- todo_2 = Todo(None, proc2, False, self.date1)
- todo_2.save(self.db_conn)
- # test empty adoption does nothing
- todo_2.adopt_from([])
- self.assertEqual(todo_2.children, [])
- # test basic adoption
- todo_2.adopt_from([todo_1])
- self.assertEqual(todo_2.children, [todo_1])
- self.assertEqual(todo_1.parents, [todo_2])
- # test making missing children
- todo_2.make_missing_children(self.db_conn)
- todo_3 = Todo.by_id(self.db_conn, 3)
- todo_4 = Todo.by_id(self.db_conn, 4)
- self.assertEqual(todo_2.children, [todo_1, todo_3, todo_4])
- self.assertEqual(todo_3.process, self.proc)
- self.assertEqual(todo_3.parents, [todo_2])
- self.assertEqual(todo_3.children, [])
- self.assertEqual(todo_4.process, proc3)
- self.assertEqual(todo_4.parents, [todo_2])
- # test .make_missing_children doesn't further than top-level
- self.assertEqual(todo_4.children, [])
- # test .make_missing_children lower down the tree
- todo_4.make_missing_children(self.db_conn)
- todo_5 = Todo.by_id(self.db_conn, 5)
- self.assertEqual(todo_5.process, proc4)
- self.assertEqual(todo_4.children, [todo_5])
- self.assertEqual(todo_5.parents, [todo_4])
+ # make proc4 step of proc3
+ step = ProcessStep(None, proc3.id_, proc4.id_, None)
+ proc3.set_steps(self.db_conn, [step])
+ # give proc2 three steps; 2× proc1, 1× proc3
+ step1 = ProcessStep(None, proc2.id_, self.proc.id_, None)
+ step2 = ProcessStep(None, proc2.id_, self.proc.id_, None)
+ step3 = ProcessStep(None, proc2.id_, proc3.id_, None)
+ proc2.set_steps(self.db_conn, [step1, step2, step3])
+ # test mere creation does nothing
+ todo_ignore = Todo(None, proc2, False, self.date1)
+ todo_ignore.save(self.db_conn)
+ self.assertEqual(todo_ignore.children, [])
+ # test create_with_children on step-less does nothing
+ todo_1 = Todo.create_with_children(self.db_conn, self.proc.id_,
+ self.date1)
+ self.assertEqual(todo_1.children, [])
+ self.assertEqual(len(Todo.all(self.db_conn)), 2)
+ # test create_with_children adopts and creates, and down tree too
+ todo_2 = Todo.create_with_children(self.db_conn, proc2.id_, self.date1)
+ self.assertEqual(3, len(todo_2.children))
+ self.assertEqual(todo_1, todo_2.children[0])
+ self.assertEqual(self.proc, todo_2.children[1].process)
+ self.assertEqual(proc3, todo_2.children[2].process)
+ todo_3 = todo_2.children[2]
+ self.assertEqual(len(todo_3.children), 1)
+ self.assertEqual(todo_3.children[0].process, proc4)
def test_Todo_singularity(self) -> None:
"""Test pointers made for single object keep pointing to it."""
Todo.by_id(self.db_conn, todo_1.id_)
self.assertEqual(todo_0.children, [])
self.assertEqual(todo_2.parents, [])
+ todo_2.comment = 'foo'
+ with self.assertRaises(HandledException):
+ todo_2.remove(self.db_conn)
+ todo_2.comment = ''
+ todo_2.effort = 5
+ with self.assertRaises(HandledException):
+ todo_2.remove(self.db_conn)
+
+ def test_Todo_autoremoval(self) -> None:
+ """"Test automatic removal for Todo.effort < 0."""
+ todo_1 = Todo(None, self.proc, False, self.date1)
+ todo_1.save(self.db_conn)
+ todo_1.comment = 'foo'
+ todo_1.effort = -0.1
+ todo_1.save(self.db_conn)
+ Todo.by_id(self.db_conn, todo_1.id_)
+ todo_1.comment = ''
+ todo_1.save(self.db_conn)
+ with self.assertRaises(NotFoundException):
+ Todo.by_id(self.db_conn, todo_1.id_)
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 = {'day_comment': ''}
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ form_data = {'day_comment': '', 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
self.assertEqual(Todo.by_date(self.db_conn, '2024-01-01'), [])
form_data['new_todo'] = str(proc.id_)
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
todos = Todo.by_date(self.db_conn, '2024-01-01')
self.assertEqual(1, len(todos))
todo1 = todos[0]
self.assertEqual(todo1.process.id_, proc.id_)
self.assertEqual(todo1.is_done, False)
form_data['new_todo'] = str(proc2.id_)
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
todos = Todo.by_date(self.db_conn, '2024-01-01')
todo1 = todos[1]
self.assertEqual(todo1.id_, 2)
return Todo.by_date(self.db_conn, '2024-01-01')[0]
# test minimum
self.post_process()
- self.check_post({'day_comment': '', 'new_todo': 1},
- '/day?date=2024-01-01', 302)
+ self.check_post({'day_comment': '', 'new_todo': 1,
+ 'make_type': 'full'},
+ '/day?date=2024-01-01&make_type=full', 302)
# test posting to bad URLs
self.check_post({}, '/todo=', 404)
self.check_post({}, '/todo?id=', 400)
self.check_post({'adopt': 1}, '/todo?id=1', 400)
self.check_post({'adopt': 2}, '/todo?id=1', 404)
# test posting second todo of same process
- self.check_post({'day_comment': '', 'new_todo': 1},
- '/day?date=2024-01-01', 302)
+ self.check_post({'day_comment': '', 'new_todo': 1,
+ 'make_type': 'full'},
+ '/day?date=2024-01-01&make_type=full', 302)
# test todo 1 adopting todo 2
todo1 = post_and_reload({'adopt': 2})
todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
"""Test Todos posted to Day view may adopt existing Todos."""
form_data = self.post_process()
form_data = self.post_process(2, form_data | {'new_top_step': 1})
- form_data = {'day_comment': '', 'new_todo': 1}
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
form_data['new_todo'] = 2
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
self.assertEqual(todo1.children, [])
"""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)
+ form_data = {'day_comment': '', 'new_todo': [1, 2],
+ 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
todo1 = Todo.by_date(self.db_conn, '2024-01-01')[0]
todo2 = Todo.by_date(self.db_conn, '2024-01-01')[1]
self.assertEqual(todo1.process.id_, 1)
def test_do_POST_day_todo_multiple_inner_adoption(self) -> None:
"""Test multiple Todos can be posted to Day view w. inner adoption."""
+
+ def key_order_func(t: Todo) -> int:
+ assert isinstance(t.process.id_, int)
+ return t.process.id_
+
+ def check_adoption(date: str, new_todos: list[int]) -> None:
+ form_data = {'day_comment': '', 'new_todo': new_todos,
+ 'make_type': 'full'}
+ self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
+ day_todos = Todo.by_date(self.db_conn, date)
+ day_todos.sort(key=key_order_func)
+ todo1 = day_todos[0]
+ todo2 = day_todos[1]
+ self.assertEqual(todo1.children, [])
+ self.assertEqual(todo1.parents, [todo2])
+ self.assertEqual(todo2.children, [todo1])
+ self.assertEqual(todo2.parents, [])
+
+ def check_nesting_adoption(process_id: int, date: str,
+ new_top_steps: list[int]) -> None:
+ form_data = {'title': '', 'description': '', 'effort': 1,
+ 'step_of': [2]}
+ form_data = self.post_process(1, form_data)
+ form_data['new_top_step'] = new_top_steps
+ form_data['step_of'] = []
+ form_data = self.post_process(process_id, form_data)
+ form_data = {'day_comment': '', 'new_todo': [process_id],
+ 'make_type': 'full'}
+ self.check_post(form_data, f'/day?date={date}&make_type=full', 302)
+ day_todos = Todo.by_date(self.db_conn, date)
+ day_todos.sort(key=key_order_func, reverse=True)
+ self.assertEqual(len(day_todos), 3)
+ todo1 = day_todos[0] # process of process_id
+ todo2 = day_todos[1] # process 2
+ todo3 = day_todos[2] # process 1
+ self.assertEqual(sorted(todo1.children), sorted([todo2, todo3]))
+ self.assertEqual(todo1.parents, [])
+ self.assertEqual(todo2.children, [todo3])
+ self.assertEqual(todo2.parents, [todo1])
+ self.assertEqual(todo3.children, [])
+ self.assertEqual(sorted(todo3.parents), sorted([todo2, todo1]))
+
form_data = self.post_process()
form_data = self.post_process(2, form_data | {'new_top_step': 1})
- 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, [])
+ check_adoption('2024-01-01', [1, 2])
+ check_adoption('2024-01-02', [2, 1])
+ check_nesting_adoption(3, '2024-01-03', [1, 2])
+ check_nesting_adoption(4, '2024-01-04', [2, 1])
def test_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)
+ form_data = {'day_comment': '', 'new_todo': [1], 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
todo = Todo.by_date(self.db_conn, '2024-01-01')[0]
- form_data = {'day_comment': '', 'todo_id': [1]}
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ form_data = {'day_comment': '', 'todo_id': [1], 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 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)
+ form_data = {'day_comment': '', 'todo_id': [1], 'done': [1],
+ 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
self.assertEqual(todo.is_done, True)
def test_do_GET_todo(self) -> None:
"""Test GET /todo response codes."""
self.post_process()
- form_data = {'day_comment': '', 'new_todo': 1}
- self.check_post(form_data, '/day?date=2024-01-01', 302)
+ form_data = {'day_comment': '', 'new_todo': 1, 'make_type': 'full'}
+ self.check_post(form_data, '/day?date=2024-01-01&make_type=full', 302)
self.check_get('/todo', 400)
self.check_get('/todo?id=', 400)
self.check_get('/todo?id=foo', 400)
"""POST basic Process."""
if not form_data:
form_data = {'title': 'foo', 'description': 'foo', 'effort': 1.1}
- self.check_post(form_data, '/process?id=', 302, f'/process?id={id_}')
+ self.check_post(form_data, f'/process?id={id_}', 302,
+ f'/process?id={id_}')
return form_data