From: Christian Heller Date: Sat, 11 Jan 2025 02:15:20 +0000 (+0100) Subject: Redo Day.id_ from former .days_since_millennium. X-Git-Url: https://plomlompom.com/repos/%7B%7Bdb.prefix%7D%7D/static/%7B%7B%20web_path%20%7D%7D/decks/edit?a=commitdiff_plain;h=25ea6009b6fe152e97668797df8ac62323bd07a9;p=plomtask Redo Day.id_ from former .days_since_millennium. --- diff --git a/migrations/7_redo_Day_id.sql b/migrations/7_redo_Day_id.sql new file mode 100644 index 0000000..0c69acf --- /dev/null +++ b/migrations/7_redo_Day_id.sql @@ -0,0 +1,39 @@ +ALTER TABLE todos ADD COLUMN new_day_id INTEGER; +UPDATE todos SET new_day_id = ( + SELECT days.days_since_millennium + FROM days + WHERE days.id = todos.day); + +CREATE TABLE days_new ( + id INTEGER PRIMARY KEY, + comment TEXT NOT NULL +); +INSERT INTO days_new SELECT + days_since_millennium, + comment +FROM days; +DROP TABLE days; +ALTER TABLE days_new RENAME TO days; + +CREATE TABLE todos_new ( + id INTEGER PRIMARY KEY, + process INTEGER NOT NULL, + is_done BOOLEAN NOT NULL, + day INTEGER 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) +); +INSERT INTO todos_new SELECT + id, + process, + is_done, + new_day_id, + comment, + effort, + calendarize +FROM todos; +DROP TABLE todos; +ALTER TABLE todos_new RENAME TO todos; diff --git a/migrations/init_6.sql b/migrations/init_6.sql deleted file mode 100644 index 6de27da..0000000 --- a/migrations/init_6.sql +++ /dev/null @@ -1,138 +0,0 @@ -CREATE TABLE condition_descriptions ( - parent INTEGER NOT NULL, - timestamp TEXT NOT NULL, - description TEXT NOT NULL, - PRIMARY KEY (parent, timestamp), - FOREIGN KEY (parent) REFERENCES conditions(id) -); -CREATE TABLE condition_titles ( - parent INTEGER NOT NULL, - timestamp TEXT NOT NULL, - title TEXT NOT NULL, - PRIMARY KEY (parent, timestamp), - FOREIGN KEY (parent) REFERENCES conditions(id) -); -CREATE TABLE conditions ( - id INTEGER PRIMARY KEY, - is_active BOOLEAN NOT NULL -); -CREATE TABLE days ( - id TEXT PRIMARY KEY, - comment TEXT NOT NULL, - days_since_millennium INTEGER NOT NULL DEFAULT 0 -); -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) -); diff --git a/migrations/init_7.sql b/migrations/init_7.sql new file mode 100644 index 0000000..0617fe1 --- /dev/null +++ b/migrations/init_7.sql @@ -0,0 +1,137 @@ +CREATE TABLE "days" ( + id INTEGER PRIMARY KEY, + comment TEXT NOT NULL +); +CREATE TABLE "todos" ( + id INTEGER PRIMARY KEY, + process INTEGER NOT NULL, + is_done BOOLEAN NOT NULL, + day INTEGER 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) +); +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 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) +); diff --git a/plomtask/dating.py b/plomtask/dating.py index 5333751..73e0489 100644 --- a/plomtask/dating.py +++ b/plomtask/dating.py @@ -1,10 +1,10 @@ """Various utilities for handling dates.""" -from datetime import date as datetime_date, timedelta +from datetime import date as dt_date, timedelta from plomtask.exceptions import BadFormatException -def valid_date(date_str: str) -> str: - """Validate against ISO format/relative terms; return in ISO format.""" +def dt_date_from_str(date_str: str) -> dt_date: + """Validate against ISO format, colloq. terms; return as datetime.date.""" if date_str == 'today': date_str = date_in_n_days(0) elif date_str == 'yesterday': @@ -12,14 +12,24 @@ def valid_date(date_str: str) -> str: elif date_str == 'tomorrow': date_str = date_in_n_days(1) try: - date = datetime_date.fromisoformat(date_str) + date = dt_date.fromisoformat(date_str) except (ValueError, TypeError) as e: msg = f'Given date of wrong format: {date_str}' raise BadFormatException(msg) from e - return date.isoformat() + return date + + +def days_n_from_dt_date(date: dt_date) -> int: + """Return number of days from Jan 1st 2000 to datetime.date.""" + return (date - dt_date(2000, 1, 1)).days + + +def dt_date_from_days_n(days_n: int) -> dt_date: + """Return datetime.date for days_n after Jan 1st 2000.""" + return dt_date(2000, 1, 1) + timedelta(days=days_n) def date_in_n_days(n: int) -> str: """Return in ISO format date from today + n days.""" - date = datetime_date.today() + timedelta(days=n) + date = dt_date.today() + timedelta(days=n) return date.isoformat() diff --git a/plomtask/days.py b/plomtask/days.py index 68bc6db..aac59bb 100644 --- a/plomtask/days.py +++ b/plomtask/days.py @@ -2,119 +2,111 @@ from __future__ import annotations from typing import Any, Self from sqlite3 import Row -from datetime import date as datetime_date, timedelta +from datetime import date as dt_date, timedelta from plomtask.db import DatabaseConnection, BaseModel, BaseModelId from plomtask.todos import Todo -from plomtask.dating import valid_date +from plomtask.dating import dt_date_from_days_n, days_n_from_dt_date class Day(BaseModel): """Individual days defined by their dates.""" table_name = 'days' - to_save_simples = ['comment', 'days_since_millennium'] + to_save_simples = ['comment'] add_to_dict = ['todos'] can_create_by_id = True - def __init__(self, - date: str, - comment: str = '', - days_since_millennium: int = -1 - ) -> None: - id_ = valid_date(date) + def __init__(self, id_: int, comment: str = '') -> None: super().__init__(id_) - self.date = datetime_date.fromisoformat(self.date_str) self.comment = comment self.todos: list[Todo] = [] - self.days_since_millennium = days_since_millennium - - def __lt__(self, other: Self) -> bool: - return self.date_str < other.date_str @classmethod def from_table_row(cls, db_conn: DatabaseConnection, row: Row | list[Any] ) -> Self: """Make from DB row, with linked Todos.""" day = super().from_table_row(db_conn, row) - assert isinstance(day.id_, str) - day.todos = Todo.by_date(db_conn, day.id_) + day.todos = Todo.by_date(db_conn, day.date) return day @classmethod def by_id(cls, db_conn: DatabaseConnection, id_: BaseModelId) -> Self: - """Extend BaseModel.by_id - - Checks Todo.days_to_update if we need to a retrieved Day's .todos, - and also ensures we're looking for proper dates and not strings like - "yesterday" by enforcing the valid_date translation. - """ - assert isinstance(id_, str) - possibly_translated_date = valid_date(id_) - day = super().by_id(db_conn, possibly_translated_date) + """Checks Todo.days_to_update if we need to a retrieved Day's .todos""" + day = super().by_id(db_conn, id_) + assert isinstance(day.id_, int) if day.id_ in Todo.days_to_update: - assert isinstance(day.id_, str) Todo.days_to_update.remove(day.id_) - day.todos = Todo.by_date(db_conn, day.id_) + day.todos = Todo.by_date(db_conn, day.date) return day @classmethod - def with_filled_gaps(cls, days: list[Self], start_date: str, end_date: str - ) -> list[Self]: - """In days, fill with (un-stored) Days gaps between start/end_date.""" - days = days[:] - start_date, end_date = valid_date(start_date), valid_date(end_date) - if start_date > end_date: + def with_filled_gaps( + cls, conn: DatabaseConnection, dt_start: dt_date, dt_end: dt_date + ) -> list[Self]: + """Show days >= start_date, <= end_date, fill gaps with un-storeds.""" + if dt_start > dt_end: return [] - days = [d for d in days - if d.date_str >= start_date and d.date_str <= end_date] - days.sort() - if start_date not in [d.date_str for d in days]: - days[:] = [cls(start_date)] + days - if end_date not in [d.date_str for d in days]: - days += [cls(end_date)] - if len(days) > 1: - gapless_days = [] - for i, day in enumerate(days): - gapless_days += [day] - if i < len(days) - 1: - while day.next_date != days[i+1].date_str: - day = cls(day.next_date) - gapless_days += [day] - days[:] = gapless_days - return days + start_n_days = days_n_from_dt_date(dt_start) + end_n_days = days_n_from_dt_date(dt_end) + ranged_days = [d for d in cls.all(conn) + if isinstance(d.id_, int) + and d.id_ >= start_n_days and d.id_ <= end_n_days] + ranged_days.sort() + if (not ranged_days) or (isinstance(ranged_days[0].id_, int) + and start_n_days < ranged_days[0].id_): + ranged_days.insert(0, cls(start_n_days)) + assert isinstance(ranged_days[-1].id_, int) + if end_n_days > ranged_days[-1].id_: + ranged_days.append(cls(end_n_days)) + if len(ranged_days) > 1: + degapped_ranged_days = [] + for i, day in enumerate(ranged_days): + degapped_ranged_days += [day] + if i < len(ranged_days) - 1: + next_one = ranged_days[i+1] + assert isinstance(day.id_, int) + assert isinstance(next_one.id_, int) + while day.id_ + 1 != next_one.id_: + assert isinstance(day.id_, int) + day = cls(day.id_ + 1) + degapped_ranged_days += [day] + return degapped_ranged_days + return ranged_days + + @property + def _dt_date(self) -> dt_date: + """Return chronological location as datetime.date.""" + assert isinstance(self.id_, int) + return dt_date_from_days_n(self.id_) @property - def date_str(self) -> str: - """Return self.id_ under the assumption it's a date string.""" - assert isinstance(self.id_, str) - return self.id_ + def date(self) -> str: + """Return chronological location as ISO format date.""" + return self._dt_date.isoformat() @property def first_of_month(self) -> bool: """Return if self is first day of a month.""" - assert isinstance(self.id_, str) - return self.id_[-2:] == '01' + return self.date[-2:] == '01' @property def month_name(self) -> str: - """Return what month self is part of.""" - return self.date.strftime('%B') + """Return name of month self is part of.""" + return self._dt_date.strftime('%B') @property def weekday(self) -> str: - """Return what weekday matches self.""" - return self.date.strftime('%A') + """Return weekday name matching self.""" + return self._dt_date.strftime('%A') @property def prev_date(self) -> str: - """Return date preceding date of self.""" - prev_date = self.date - timedelta(days=1) - return prev_date.isoformat() + """Return ISO-formatted date preceding date of self.""" + return (self._dt_date - timedelta(days=1)).isoformat() @property def next_date(self) -> str: - """Return date succeeding date of this Day.""" - next_date = self.date + timedelta(days=1) - return next_date.isoformat() + """Return ISO-formatted date succeeding date of this Day.""" + return (self._dt_date + timedelta(days=1)).isoformat() @property def calendarized_todos(self) -> list[Todo]: diff --git a/plomtask/db.py b/plomtask/db.py index efcc497..2b2c18e 100644 --- a/plomtask/db.py +++ b/plomtask/db.py @@ -1,5 +1,6 @@ """Database management.""" from __future__ import annotations +from datetime import date as dt_date from os import listdir from os.path import basename, isfile from difflib import Differ @@ -8,9 +9,8 @@ from sqlite3 import ( from typing import Any, Self, Callable from plomtask.exceptions import (HandledException, NotFoundException, BadFormatException) -from plomtask.dating import valid_date -EXPECTED_DB_VERSION = 6 +EXPECTED_DB_VERSION = 7 MIGRATIONS_DIR = 'migrations' FILENAME_DB_SCHEMA = f'init_{EXPECTED_DB_VERSION}.sql' PATH_DB_SCHEMA = f'{MIGRATIONS_DIR}/{FILENAME_DB_SCHEMA}' @@ -86,8 +86,6 @@ class DatabaseMigration: def _mig_6_calc_days_since_millennium(conn: SqlConnection) -> None: - # pylint: disable=import-outside-toplevel - from datetime import date as dt_date rows = conn.execute('SELECT * FROM days').fetchall() for row in [list(r) for r in rows]: row[-1] = (dt_date.fromisoformat(row[0]) - dt_date(2000, 1, 1)).days @@ -510,29 +508,6 @@ class BaseModel: items[item.id_] = item return sorted(list(items.values())) - @classmethod - def by_date_range_with_limits(cls, - db_conn: DatabaseConnection, - date_range: tuple[str, str], - date_col: str = 'day' - ) -> tuple[list[Self], str, str]: - """Return list of items in DB within (closed) 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), - build_q_marks=False): - items += [cls.by_id(db_conn, row[0])] - return items, start_date, end_date - @classmethod def matching(cls, db_conn: DatabaseConnection, pattern: str) -> list[Self]: """Return all objects whose .to_search match pattern.""" diff --git a/plomtask/http.py b/plomtask/http.py index b30b22c..348feb0 100644 --- a/plomtask/http.py +++ b/plomtask/http.py @@ -9,7 +9,8 @@ from urllib.parse import urlparse, parse_qs from json import dumps as json_dumps from os.path import split as path_split from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader -from plomtask.dating import date_in_n_days +from plomtask.dating import ( + days_n_from_dt_date, dt_date_from_str, date_in_n_days) from plomtask.days import Day from plomtask.exceptions import (HandledException, BadFormatException, NotFoundException) @@ -326,13 +327,12 @@ class TaskHandler(BaseHTTPRequestHandler): """ start = self._params.get_str_or_fail('start', '') end = self._params.get_str_or_fail('end', '') - end = end if end != '' else date_in_n_days(366) - # - days, start, end = Day.by_date_range_with_limits(self._conn, - (start, end), 'id') - days = Day.with_filled_gaps(days, start, end) + dt_start = dt_date_from_str(start if start else date_in_n_days(-1)) + dt_end = dt_date_from_str(end if end else date_in_n_days(366)) + days = Day.with_filled_gaps(self._conn, dt_start, dt_end) today = date_in_n_days(0) - return {'start': start, 'end': end, 'days': days, 'today': today} + return {'start': dt_start.isoformat(), 'end': dt_end.isoformat(), + 'today': today, 'days': days} def do_GET_calendar(self) -> dict[str, object]: """Show Days from ?start= to ?end= – normal view.""" @@ -347,7 +347,9 @@ class TaskHandler(BaseHTTPRequestHandler): date = self._params.get_str('date', date_in_n_days(0)) make_type = self._params.get_str_or_fail('make_type', 'full') # - day = Day.by_id_or_create(self._conn, date) + assert isinstance(date, str) + day = Day.by_id_or_create(self._conn, + days_n_from_dt_date(dt_date_from_str(date))) conditions_present = [] enablers_for = {} disablers_for = {} @@ -616,13 +618,14 @@ class TaskHandler(BaseHTTPRequestHandler): for _ in [id_ for id_ in done_todos if id_ not in old_todos]: raise BadFormatException('"done" field refers to unknown Todo') # - day = Day.by_id_or_create(self._conn, date) + day_id = days_n_from_dt_date(dt_date_from_str(date)) + day = Day.by_id_or_create(self._conn, day_id) day.comment = day_comment day.save(self._conn) new_todos = [] for process_id in sorted(new_todos_by_process): process = Process.by_id(self._conn, process_id) - todo = Todo(None, process, False, date) + todo = Todo(None, process, False, day_id) todo.save(self._conn) new_todos += [todo] if 'full' == make_type: @@ -703,7 +706,7 @@ class TaskHandler(BaseHTTPRequestHandler): for process_id, parent_id in make_data: parent = Todo.by_id(self._conn, parent_id) process = Process.by_id(self._conn, process_id) - made = Todo(None, process, False, todo.date) + made = Todo(None, process, False, todo.day_id) made.save(self._conn) if 'full' == approach: made.ensure_children(self._conn) diff --git a/plomtask/todos.py b/plomtask/todos.py index f7d375d..2be57d4 100644 --- a/plomtask/todos.py +++ b/plomtask/todos.py @@ -1,5 +1,6 @@ """Actionables.""" from __future__ import annotations +from datetime import date as dt_date from typing import Any, Self, Set from sqlite3 import Row from plomtask.misc import DictableNode @@ -9,7 +10,8 @@ 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 +from plomtask.dating import ( + days_n_from_dt_date, dt_date_from_str, dt_date_from_days_n) class TodoNode(DictableNode): @@ -37,7 +39,7 @@ class Todo(BaseModel, ConditionsRelations): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods table_name = 'todos' - to_save_simples = ['process_id', 'is_done', 'date', 'comment', 'effort', + to_save_simples = ['process_id', 'is_done', 'day_id', 'comment', 'effort', 'calendarize'] to_save_relations = [('todo_conditions', 'todo', 'conditions', 0), ('todo_blockers', 'todo', 'blockers', 0), @@ -46,19 +48,19 @@ class Todo(BaseModel, ConditionsRelations): ('todo_children', 'parent', 'children', 0), ('todo_children', 'child', 'parents', 1)] to_search = ['comment'] - days_to_update: Set[str] = set() + days_to_update: Set[int] = set() children: list[Todo] parents: list[Todo] sorters = {'doneness': lambda t: t.is_done, 'title': lambda t: t.title_then, 'comment': lambda t: t.comment, - 'date': lambda t: t.date} + 'date': lambda t: t.day_id} # pylint: disable=too-many-arguments def __init__(self, id_: int | None, process: Process, is_done: bool, - date: str, + day_id: int, comment: str = '', effort: None | float = None, calendarize: bool = False @@ -69,7 +71,7 @@ class Todo(BaseModel, ConditionsRelations): raise NotFoundException('Process of Todo without ID (not saved?)') self.process = process self._is_done = is_done - self.date = valid_date(date) + self.day_id = day_id self.comment = comment self.effort = effort self.children = [] @@ -82,12 +84,34 @@ class Todo(BaseModel, ConditionsRelations): self.enables = self.process.enables[:] self.disables = self.process.disables[:] + @property + def date(self) -> str: + """Return ISO formatted date matching .day_id.""" + return dt_date_from_days_n(self.day_id).isoformat() + @classmethod - def by_date_range(cls, db_conn: DatabaseConnection, - date_range: tuple[str, str] = ('', '')) -> list[Self]: - """Collect Todos of Days within date_range.""" - todos, _, _ = cls.by_date_range_with_limits(db_conn, date_range) - return todos + def by_date_range_with_limits(cls, + db_conn: DatabaseConnection, + date_range: tuple[str, str], + ) -> tuple[list[Self], str, str]: + """Return Todos within (closed) date_range interval. + + If no range values provided, defaults them to 'yesterday' and + 'tomorrow'. Knows to properly interpret these and 'today' as value. + """ + dt_date_limits: list[dt_date] = [] + for i in range(2): + dt_date_limits += [ + dt_date_from_str(date_range[i] if date_range[i] + else ('yesterday', 'tomorrow')[i])] + items: list[Self] = [] + for row in db_conn.exec( + f'SELECT id FROM {cls.table_name} WHERE day >= ? AND day <= ?', + tuple(days_n_from_dt_date(d) for d in dt_date_limits), + build_q_marks=False): + items += [cls.by_id(db_conn, row[0])] + return (items, + dt_date_limits[0].isoformat(), dt_date_limits[1].isoformat()) def ensure_children(self, db_conn: DatabaseConnection) -> None: """Ensure Todo children (create or adopt) demanded by Process chain.""" @@ -103,7 +127,7 @@ class Todo(BaseModel, ConditionsRelations): break if not satisfier: satisfier = self.__class__(None, step_node.process, False, - parent.date) + parent.day_id) satisfier.save(db_conn) sub_step_nodes = sorted( step_node.steps, @@ -164,7 +188,7 @@ class Todo(BaseModel, ConditionsRelations): @classmethod def by_date(cls, db_conn: DatabaseConnection, date: str) -> list[Self]: """Collect all Todos for Day of date.""" - return cls.by_date_range(db_conn, (date, date)) + return cls.by_date_range_with_limits(db_conn, (date, date))[0] @property def is_doable(self) -> bool: @@ -229,7 +253,7 @@ class Todo(BaseModel, ConditionsRelations): @property def title_then(self) -> str: - """Shortcut to .process.title.at(self.date)""" + """Shortcut to .process.title.at(self.date).""" title_then = self.process.title.at(self.date) assert isinstance(title_then, str) return title_then @@ -316,7 +340,7 @@ class Todo(BaseModel, ConditionsRelations): self.remove(db_conn) return if self.id_ is None: - self.__class__.days_to_update.add(self.date) + self.__class__.days_to_update.add(self.day_id) super().save(db_conn) for condition in self.enables + self.disables + self.conditions: condition.save(db_conn) @@ -325,7 +349,7 @@ class Todo(BaseModel, ConditionsRelations): """Remove from DB, including relations.""" if not self.is_deletable: raise HandledException('Cannot remove non-deletable Todo.') - self.__class__.days_to_update.add(self.date) + self.__class__.days_to_update.add(self.day_id) children_to_remove = self.children[:] parents_to_remove = self.parents[:] for child in children_to_remove: diff --git a/tests/days.py b/tests/days.py index 595ff20..3ac657f 100644 --- a/tests/days.py +++ b/tests/days.py @@ -1,13 +1,13 @@ """Test Days module.""" -from datetime import date as datetime_date, datetime, timedelta +from datetime import date as dt_date, datetime, timedelta from typing import Any from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer, - Expected) + Expected, date_and_day_id, dt_date_from_day_id) from plomtask.dating import date_in_n_days as tested_date_in_n_days from plomtask.days import Day # Simply the ISO format for dates as used in plomtask.dating, but for testing -# purposes we state our expectations here independently from that +# purposes we state our expectations here independently and explicitly TESTING_DATE_FORMAT = '%Y-%m-%d' @@ -18,15 +18,17 @@ def _testing_date_in_n_days(n: int) -> str: at plomtask.dating.date_in_n_days, but want to state our expectations explicitly to rule out importing issues from the original. """ - date = datetime_date.today() + timedelta(days=n) + date = dt_date.today() + timedelta(days=n) return date.strftime(TESTING_DATE_FORMAT) +def _days_n_for_date(date: str) -> int: + return (dt_date.fromisoformat(date) - dt_date(2000, 1, 1)).days + + class TestsSansDB(TestCaseSansDB): """Days module tests not requiring DB setup.""" checked_class = Day - legal_ids = ['2024-01-01', '2024-02-29'] - illegal_ids = ['foo', '2023-02-29', '2024-02-30', '2024-02-01 23:00:00'] def test_date_in_n_days(self) -> None: """Test dating.date_in_n_days""" @@ -37,48 +39,41 @@ class TestsSansDB(TestCaseSansDB): def test_Day_date_weekday_neighbor_dates(self) -> None: """Test Day's date parsing and neighbourhood resolution.""" - self.assertEqual(datetime_date(2024, 5, 1), Day('2024-05-01').date) - self.assertEqual('Sunday', Day('2024-03-17').weekday) - self.assertEqual('March', Day('2024-03-17').month_name) - self.assertEqual('2023-12-31', Day('2024-01-01').prev_date) - self.assertEqual('2023-03-01', Day('2023-02-28').next_date) + self.assertEqual(dt_date(2000, 1, 2).isoformat(), Day(1).date) + self.assertEqual(dt_date(2001, 1, 2).isoformat(), Day(367).date) + self.assertEqual('Sunday', Day(1).weekday) + self.assertEqual('March', Day(75).month_name) + self.assertEqual('2000-12-31', Day(366).prev_date) + self.assertEqual('2001-03-01', Day(424).next_date) class TestsWithDB(TestCaseWithDB): """Tests requiring DB, but not server setup.""" checked_class = Day - default_ids = ('2024-01-01', '2024-01-02', '2024-01-03') - - def test_Day_by_date_range_with_limits(self) -> None: - """Test .by_date_range_with_limits.""" - self.check_by_date_range_with_limits('id', set_id_field=False) def test_Day_with_filled_gaps(self) -> None: """Test .with_filled_gaps.""" + day_ids = [n + 1 for n in range(9)] + dt_dates = [dt_date_from_day_id(id_) for id_ in day_ids] def expect_within_full_range_as_commented( range_indexes: tuple[int, int], indexes_to_provide: list[int] ) -> None: start_i, end_i = range_indexes - days_provided = [] - days_expected = days_sans_comment[:] - for i in indexes_to_provide: - day_with_comment = days_with_comment[i] - days_provided += [day_with_comment] - days_expected[i] = day_with_comment + days_expected = [Day(n) for n in day_ids] + to_remove = [] + for idx in indexes_to_provide: + days_expected[idx] = Day(day_ids[idx], '#') + days_expected[idx].save(self.db_conn) + to_remove += [days_expected[idx]] days_expected = days_expected[start_i:end_i+1] - start, end = dates[start_i], dates[end_i] - days_result = self.checked_class.with_filled_gaps(days_provided, - start, end) + days_result = Day.with_filled_gaps( + self.db_conn, dt_dates[start_i], dt_dates[end_i]) self.assertEqual(days_result, days_expected) + for day in to_remove: + day.remove(self.db_conn) - # for provided Days we use those from days_with_comment, to identify - # them against same-dated mere filler Days by their lack of comment - # (identity with Day at the respective position in days_sans_comment) - dates = [f'2024-02-0{n+1}' for n in range(9)] - days_with_comment = [Day(date, comment=date[-1:]) for date in dates] - days_sans_comment = [Day(date, comment='') for date in dates] # check provided Days recognizable in (full-range) interval expect_within_full_range_as_commented((0, 8), [0, 4, 8]) # check limited range, but limiting Days provided @@ -94,9 +89,9 @@ class TestsWithDB(TestCaseWithDB): # check single-element selection creating only filler beyond provided expect_within_full_range_as_commented((1, 1), [2, 4, 6]) # check (un-saved) filler Days don't show up in cache or DB - day = Day(dates[3]) + day = Day(day_ids[3]) day.save(self.db_conn) - self.checked_class.with_filled_gaps([day], dates[0], dates[-1]) + Day.with_filled_gaps(self.db_conn, dt_dates[0], dt_dates[-1]) self.check_identity_with_cache_and_db([day]) @@ -105,14 +100,17 @@ class ExpectedGetCalendar(Expected): def __init__(self, start: int, end: int, *args: Any, **kwargs: Any ) -> None: - self._fields = {'start': _testing_date_in_n_days(start), - 'end': _testing_date_in_n_days(end), - 'today': _testing_date_in_n_days(0)} - self._fields['days'] = [_testing_date_in_n_days(i) - for i in range(start, end+1)] + today_dt = dt_date.today() + today_iso = today_dt.isoformat() + self._fields = { + 'start': (today_dt + timedelta(days=start)).isoformat(), + 'end': (today_dt + timedelta(days=end)).isoformat(), + 'today': today_iso} + self._fields['days'] = [ + _days_n_for_date(today_iso) + i for i in range(start, end+1)] super().__init__(*args, **kwargs) - for date in self._fields['days']: - self.lib_set('Day', [self.day_as_dict(date)]) + for day_id in self._fields['days']: + self.lib_set('Day', [self.day_as_dict(day_id)]) class ExpectedGetDay(Expected): @@ -120,14 +118,14 @@ class ExpectedGetDay(Expected): _default_dict = {'make_type': 'full'} _on_empty_make_temp = ('Day', 'day_as_dict') - def __init__(self, date: str, *args: Any, **kwargs: Any) -> None: - self._fields = {'day': date} + def __init__(self, day_id: int, *args: Any, **kwargs: Any) -> None: + self._fields = {'day': day_id} super().__init__(*args, **kwargs) def recalc(self) -> None: super().recalc() todos = [t for t in self.lib_all('Todo') - if t['date'] == self._fields['day']] + if t['day_id'] == self._fields['day']] self.lib_get('Day', self._fields['day'])['todos'] = self.as_ids(todos) self._fields['top_nodes'] = [ {'children': [], 'seen': 0, 'todo': todo['id']} @@ -161,17 +159,17 @@ class TestsWithServer(TestCaseWithServer): self.check_get_defaults('/day', '2024-01-01', 'date') self.check_get('/day?date=2024-02-30', 400) # check undefined day - exp = ExpectedGetDay(_testing_date_in_n_days(0)) + today_iso = dt_date.today().isoformat() + exp = ExpectedGetDay(_days_n_for_date(today_iso)) self.check_json_get('/day', exp) # check defined day with make_type parameter - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) exp.set('make_type', 'bar') self.check_json_get(f'/day?date={date}&make_type=bar', exp) # check parsing of 'yesterday', 'today', 'tomorrow' for name, dist in [('yesterday', -1), ('today', 0), ('tomorrow', +1)]: - date = _testing_date_in_n_days(dist) - exp = ExpectedGetDay(date) + exp = ExpectedGetDay(_days_n_for_date(today_iso) + dist) self.check_json_get(f'/day?date={name}', exp) def test_fail_POST_day(self) -> None: @@ -243,14 +241,15 @@ class TestsWithServer(TestCaseWithServer): post_url = f'/day?date={name}' redir_url = f'{post_url}&make_type={post["make_type"]}' self.check_post(post, post_url, 302, redir_url) - exp = ExpectedGetDay(date) - exp.set_day_from_post(date, post) + day_id = _days_n_for_date(date) + exp = ExpectedGetDay(day_id) + exp.set_day_from_post(day_id, post) self.check_json_get(post_url, exp) def test_GET_day_with_processes_and_todos(self) -> None: """Test GET /day displaying Processes and Todos (no trees).""" - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) # check Processes get displayed in ['processes'] and ['_library'], # even without any Todos referencing them proc_posts = [{'title': 'foo', 'description': 'oof', 'effort': 1.1}, @@ -287,8 +286,8 @@ class TestsWithServer(TestCaseWithServer): def test_POST_day_todo_make_types(self) -> None: """Test behavior of POST /todo on 'make_type'='full' and 'empty'.""" - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) # create two Processes, with second one step of first one self.post_exp_process([exp], {}, 2) self.post_exp_process([exp], {'new_top_step': 2}, 1) @@ -327,8 +326,8 @@ class TestsWithServer(TestCaseWithServer): def test_POST_day_new_todo_order_commutative(self) -> None: """Check that order of 'new_todo' values in POST /day don't matter.""" - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) self.post_exp_process([exp], {}, 2) self.post_exp_process([exp], {'new_top_step': 2}, 1) exp.lib_set('ProcessStep', [ @@ -344,11 +343,11 @@ class TestsWithServer(TestCaseWithServer): exp.lib_get('Todo', 1)['children'] = [2] self.check_json_get(f'/day?date={date}', exp) # … and then in the other, expecting same node tree / relations - exp.lib_del('Day', date) - date = '2024-01-02' - exp.set('day', date) + exp.lib_del('Day', day_id) + date, day_id = date_and_day_id(2) + exp.set('day', day_id) day_post = {'make_type': 'full', 'new_todo': [2, 1]} - self.post_exp_day([exp], day_post, date) + self.post_exp_day([exp], day_post, day_id) exp.lib_del('Todo', 1) exp.lib_del('Todo', 2) top_nodes[0]['todo'] = 3 # was: 1 @@ -358,8 +357,8 @@ class TestsWithServer(TestCaseWithServer): def test_POST_day_todo_deletion_by_negative_effort(self) -> None: """Test POST /day removal of Todos by setting negative effort.""" - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) self.post_exp_process([exp], {}, 1) self.post_exp_day([exp], {'new_todo': [1]}) # check cannot remove Todo if commented @@ -375,8 +374,8 @@ class TestsWithServer(TestCaseWithServer): def test_GET_day_with_conditions(self) -> None: """Test GET /day displaying Conditions and their relations.""" - date = '2024-01-01' - exp = ExpectedGetDay(date) + date, day_id = date_and_day_id(1) + exp = ExpectedGetDay(day_id) # check non-referenced Conditions not shown cond_posts = [{'is_active': 0, 'title': 'A', 'description': 'a'}, {'is_active': 1, 'title': 'B', 'description': 'b'}] @@ -415,6 +414,7 @@ class TestsWithServer(TestCaseWithServer): date = _testing_date_in_n_days(-2) end_date = _testing_date_in_n_days(+5) exp = ExpectedGetCalendar(-5, +5) - self.post_exp_day([exp], {'day_comment': 'foo'}, date) + self.post_exp_day([exp], + {'day_comment': 'foo'}, _days_n_for_date(date)) url = f'/calendar?start={start_date}&end={end_date}' self.check_json_get(url, exp) diff --git a/tests/processes.py b/tests/processes.py index 8f8e2c6..78d396e 100644 --- a/tests/processes.py +++ b/tests/processes.py @@ -168,8 +168,8 @@ class TestsWithServer(TestCaseWithServer): self.post_exp_process([exp], p, 1) self.check_json_get('/process?id=1', exp) # check n_todos field - self.post_exp_day([], {'new_todo': ['1']}, '2024-01-01') - self.post_exp_day([], {'new_todo': ['1']}, '2024-01-02') + self.post_exp_day([], {'new_todo': ['1']}, 1) + self.post_exp_day([], {'new_todo': ['1']}, 2) exp.set('n_todos', 2) self.check_json_get('/process?id=1', exp) # check cannot delete if Todos to Process diff --git a/tests/todos.py b/tests/todos.py index 5e849b0..759bc12 100644 --- a/tests/todos.py +++ b/tests/todos.py @@ -1,7 +1,8 @@ """Test Todos module.""" from typing import Any +from datetime import date as dt_date, timedelta from tests.utils import (TestCaseSansDB, TestCaseWithDB, TestCaseWithServer, - Expected) + Expected, date_and_day_id) from plomtask.todos import Todo from plomtask.processes import Process from plomtask.exceptions import BadFormatException, HandledException @@ -14,8 +15,7 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB): Todo requiring a _saved_ Process wouldn't run without a DB. """ checked_class = Todo - default_init_kwargs = {'process': None, 'is_done': False, - 'date': '2024-01-01'} + default_init_kwargs = {'process': None, 'is_done': False, 'day_id': 1} def setUp(self) -> None: super().setUp() @@ -25,31 +25,81 @@ class TestsWithDB(TestCaseWithDB, TestCaseSansDB): def test_Todo_by_date(self) -> None: """Test findability of Todos by date.""" - date1, date2 = '2024-01-01', '2024-01-02' - t1 = Todo(None, self.proc, False, date1) + date_1, day_id_1 = date_and_day_id(1) + date_2, _ = date_and_day_id(2) + t1 = Todo(None, self.proc, False, day_id_1) t1.save(self.db_conn) - t2 = Todo(None, self.proc, False, date1) + t2 = Todo(None, self.proc, False, day_id_1) t2.save(self.db_conn) - self.assertEqual(Todo.by_date(self.db_conn, date1), [t1, t2]) - self.assertEqual(Todo.by_date(self.db_conn, date2), []) + self.assertEqual(Todo.by_date(self.db_conn, date_1), [t1, t2]) + self.assertEqual(Todo.by_date(self.db_conn, date_2), []) with self.assertRaises(BadFormatException): self.assertEqual(Todo.by_date(self.db_conn, 'foo'), []) def test_Todo_by_date_range_with_limits(self) -> None: """Test .by_date_range_with_limits.""" - self.check_by_date_range_with_limits('day') + # pylint: disable=too-many-locals + f = Todo.by_date_range_with_limits + # check illegal ranges + legal_range = ('yesterday', 'tomorrow') + for i in [0, 1]: + for bad_date in ['foo', '2024-02-30', '2024-01-01 12:00:00']: + date_range_l = list(legal_range[:]) + date_range_l[i] = bad_date + with self.assertRaises(HandledException): + f(self.db_conn, (date_range_l[0], date_range_l[1])) + # check empty, translation of 'yesterday' and 'tomorrow' + items, start, end = f(self.db_conn, legal_range) + self.assertEqual(items, []) + dt_today = dt_date.today() + dt_yesterday = dt_today + timedelta(days=-1) + dt_tomorrow = dt_today + timedelta(days=+1) + self.assertEqual(start, dt_yesterday.isoformat()) + self.assertEqual(end, dt_tomorrow.isoformat()) + # prepare dated items for non-empty results + kwargs = self.default_init_kwargs.copy() + todos = [] + dates_and_day_ids = [date_and_day_id(i) for i in range(5)] + for day_id in [t[1] for t in dates_and_day_ids[1:-1]]: + kwargs['day_id'] = day_id + todos += [Todo(None, **kwargs)] + # check ranges still empty before saving + date_range = (dates_and_day_ids[1][0], dates_and_day_ids[-2][0]) + self.assertEqual(f(self.db_conn, date_range)[0], []) + # check all objs displayed within interval + for todo in todos: + todo.save(self.db_conn) + self.assertEqual(f(self.db_conn, date_range)[0], todos) + # check that only displayed what exists within interval + date_range = (dates_and_day_ids[1][0], dates_and_day_ids[-3][0]) + expected = [todos[0], todos[1]] + self.assertEqual(f(self.db_conn, date_range)[0], expected) + date_range = (dates_and_day_ids[-2][0], dates_and_day_ids[-1][0]) + expected = [todos[2]] + self.assertEqual(f(self.db_conn, date_range)[0], expected) + # check that inverted interval displays nothing + date_range = (dates_and_day_ids[-1][0], dates_and_day_ids[0][0]) + self.assertEqual(f(self.db_conn, date_range)[0], []) + # check that "today" is interpreted, and single-element interval + kwargs['day_id'] = (dt_today - dt_date(2000, 1, 1)).days + todo_today = Todo(None, **kwargs) + todo_today.save(self.db_conn) + date_range = ('today', 'today') + items, start, end = f(self.db_conn, date_range) + self.assertEqual(start, dt_today.isoformat()) + self.assertEqual(start, end) + self.assertEqual(items, [todo_today]) def test_Todo_children(self) -> None: """Test Todo.children relations.""" - date1 = '2024-01-01' - todo_1 = Todo(None, self.proc, False, date1) - todo_2 = Todo(None, self.proc, False, date1) + todo_1 = Todo(None, self.proc, False, 1) + todo_2 = Todo(None, self.proc, False, 1) todo_2.save(self.db_conn) # check un-saved Todo cannot parent with self.assertRaises(HandledException): todo_1.add_child(todo_2) todo_1.save(self.db_conn) - todo_3 = Todo(None, self.proc, False, date1) + todo_3 = Todo(None, self.proc, False, 1) # check un-saved Todo cannot be parented with self.assertRaises(HandledException): todo_1.add_child(todo_3) diff --git a/tests/utils.py b/tests/utils.py index 41037c9..b243357 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,11 +1,11 @@ """Shared test utilities.""" # pylint: disable=too-many-lines from __future__ import annotations +from datetime import datetime, date as dt_date, timedelta from unittest import TestCase from typing import Mapping, Any, Callable from threading import Thread from http.client import HTTPConnection -from datetime import date as datetime_date, datetime, timedelta from time import sleep from json import loads as json_loads, dumps as json_dumps from urllib.parse import urlencode @@ -22,10 +22,20 @@ from plomtask.versioned_attributes import VersionedAttribute, TIMESTAMP_FMT from plomtask.exceptions import NotFoundException, HandledException -VERSIONED_VALS: dict[str, - list[str] | list[float]] = {'str': ['A', 'B'], - 'float': [0.3, 1.1]} -VALID_TRUES = {True, 'True', 'true', '1', 'on'} +_VERSIONED_VALS: dict[str, + list[str] | list[float]] = {'str': ['A', 'B'], + 'float': [0.3, 1.1]} +_VALID_TRUES = {True, 'True', 'true', '1', 'on'} + + +def dt_date_from_day_id(day_id: int) -> dt_date: + """Return datetime.date of adding day_id days to 2000-01-01.""" + return dt_date(2000, 1, 1) + timedelta(days=day_id) + + +def date_and_day_id(day_id: int) -> tuple[str, int]: + """Interpet day_id as n of days since millennium, return (date, day_id).""" + return dt_date_from_day_id(day_id).isoformat(), day_id class TestCaseAugmented(TestCase): @@ -42,7 +52,7 @@ class TestCaseAugmented(TestCase): default = self.checked_class.versioned_defaults[attr_name] owner = self.checked_class(None, **self.default_init_kwargs) attr = getattr(owner, attr_name) - to_set = VERSIONED_VALS[attr.value_type_name] + to_set = _VERSIONED_VALS[attr.value_type_name] f(self, owner, attr_name, attr, default, to_set) return wrapper @@ -226,66 +236,6 @@ class TestCaseWithDB(TestCaseAugmented): hashes_db_found = [hash(x) for x in db_found] self.assertEqual(sorted(hashes_content), sorted(hashes_db_found)) - def check_by_date_range_with_limits(self, - date_col: str, - set_id_field: bool = True - ) -> None: - """Test .by_date_range_with_limits.""" - # pylint: disable=too-many-locals - f = self.checked_class.by_date_range_with_limits - # check illegal ranges - legal_range = ('yesterday', 'tomorrow') - for i in [0, 1]: - for bad_date in ['foo', '2024-02-30', '2024-01-01 12:00:00']: - date_range = list(legal_range[:]) - date_range[i] = bad_date - with self.assertRaises(HandledException): - f(self.db_conn, date_range, date_col) - # check empty, translation of 'yesterday' and 'tomorrow' - items, start, end = f(self.db_conn, legal_range, date_col) - self.assertEqual(items, []) - yesterday = datetime_date.today() + timedelta(days=-1) - tomorrow = datetime_date.today() + timedelta(days=+1) - self.assertEqual(start, yesterday.isoformat()) - self.assertEqual(end, tomorrow.isoformat()) - # prepare dated items for non-empty results - kwargs_with_date = self.default_init_kwargs.copy() - if set_id_field: - kwargs_with_date['id_'] = None - objs = [] - dates = ['2024-01-01', '2024-01-02', '2024-01-04'] - for date in ['2024-01-01', '2024-01-02', '2024-01-04']: - kwargs_with_date['date'] = date - obj = self.checked_class(**kwargs_with_date) - objs += [obj] - # check ranges still empty before saving - date_range = [dates[0], dates[-1]] - self.assertEqual(f(self.db_conn, date_range, date_col)[0], []) - # check all objs displayed within closed interval - for obj in objs: - obj.save(self.db_conn) - self.assertEqual(f(self.db_conn, date_range, date_col)[0], objs) - # check that only displayed what exists within interval - date_range = ['2023-12-20', '2024-01-03'] - expected = [objs[0], objs[1]] - self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected) - date_range = ['2024-01-03', '2024-01-30'] - expected = [objs[2]] - self.assertEqual(f(self.db_conn, date_range, date_col)[0], expected) - # check that inverted interval displays nothing - date_range = [dates[-1], dates[0]] - self.assertEqual(f(self.db_conn, date_range, date_col)[0], []) - # check that "today" is interpreted, and single-element interval - today_date = datetime_date.today().isoformat() - kwargs_with_date['date'] = today_date - obj_today = self.checked_class(**kwargs_with_date) - obj_today.save(self.db_conn) - date_range = ['today', 'today'] - items, start, end = f(self.db_conn, date_range, date_col) - self.assertEqual(start, today_date) - self.assertEqual(start, end) - self.assertEqual(items, [obj_today]) - @TestCaseAugmented._run_if_with_db_but_not_server @TestCaseAugmented._run_on_versioned_attributes def test_saving_versioned_attributes(self, @@ -661,14 +611,13 @@ class Expected: return [item['id'] for item in items] @staticmethod - def day_as_dict(date: str, comment: str = '') -> dict[str, object]: + def day_as_dict(id_: int, comment: str = '') -> dict[str, object]: """Return JSON of Day to expect.""" - return {'id': date, 'comment': comment, 'todos': [], - 'days_since_millennium': -1} + return {'id': id_, 'comment': comment, 'todos': []} - def set_day_from_post(self, date: str, d: dict[str, Any]) -> None: - """Set Day of date in library based on POST dict d.""" - day = self.day_as_dict(date) + def set_day_from_post(self, id_: int, d: dict[str, Any]) -> None: + """Set Day of id_ in library based on POST dict d.""" + day = self.day_as_dict(id_) for k, v in d.items(): if 'day_comment' == k: day['comment'] = v @@ -678,7 +627,7 @@ class Expected: if next_id <= todo['id']: next_id = todo['id'] + 1 for proc_id in sorted([id_ for id_ in v if id_]): - todo = self.todo_as_dict(next_id, proc_id, date) + todo = self.todo_as_dict(next_id, proc_id, id_) self.lib_set('Todo', [todo]) next_id += 1 elif 'done' == k: @@ -717,7 +666,7 @@ class Expected: cond = self.lib_get('Condition', id_) if cond: cond['is_active'] = 'is_active' in d and\ - d['is_active'] in VALID_TRUES + d['is_active'] in _VALID_TRUES for category in ['title', 'description']: history = cond['_versioned'][category] if len(history) > 0: @@ -733,7 +682,7 @@ class Expected: @staticmethod def todo_as_dict(id_: int = 1, process_id: int = 1, - date: str = '2024-01-01', + day_id: int = 1, conditions: None | list[int] = None, disables: None | list[int] = None, blockers: None | list[int] = None, @@ -748,7 +697,7 @@ class Expected: """Return JSON of Todo to expect.""" # pylint: disable=too-many-arguments d = {'id': id_, - 'date': date, + 'day_id': day_id, 'process_id': process_id, 'is_done': is_done, 'calendarize': calendarize, @@ -773,7 +722,7 @@ class Expected: new_children = v if isinstance(v, list) else [v] corrected_kwargs['children'] += new_children continue - if k in {'is_done', 'calendarize'} and v in VALID_TRUES: + if k in {'is_done', 'calendarize'} and v in _VALID_TRUES: v = True corrected_kwargs[k] = v todo = self.lib_get('Todo', id_) @@ -851,7 +800,7 @@ class Expected: if k in ignore\ or k.startswith('step_') or k.startswith('new_step_to'): continue - if k in {'calendarize'} and v in VALID_TRUES: + if k in {'calendarize'} and v in _VALID_TRUES: v = True elif k in {'suppressed_steps', 'explicit_steps', 'conditions', 'disables', 'enables', 'blockers'}: @@ -902,18 +851,19 @@ class TestCaseWithServer(TestCaseWithDB): def post_exp_day(self, exps: list[Expected], payload: dict[str, Any], - date: str = '2024-01-01' + day_id: int = 1 ) -> None: """POST /day, appropriately update Expecteds.""" if 'make_type' not in payload: payload['make_type'] = 'empty' if 'day_comment' not in payload: payload['day_comment'] = '' + date = dt_date_from_day_id(day_id).isoformat() target = f'/day?date={date}' redir_to = f'{target}&make_type={payload["make_type"]}' self.check_post(payload, target, 302, redir_to) for exp in exps: - exp.set_day_from_post(date, payload) + exp.set_day_from_post(day_id, payload) def post_exp_process(self, exps: list[Expected],