home · contact · privacy
Redo Day.id_ from former .days_since_millennium.
authorChristian Heller <c.heller@plomlompom.de>
Sat, 11 Jan 2025 02:15:20 +0000 (03:15 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 11 Jan 2025 02:15:20 +0000 (03:15 +0100)
12 files changed:
migrations/7_redo_Day_id.sql [new file with mode: 0644]
migrations/init_6.sql [deleted file]
migrations/init_7.sql [new file with mode: 0644]
plomtask/dating.py
plomtask/days.py
plomtask/db.py
plomtask/http.py
plomtask/todos.py
tests/days.py
tests/processes.py
tests/todos.py
tests/utils.py

diff --git a/migrations/7_redo_Day_id.sql b/migrations/7_redo_Day_id.sql
new file mode 100644 (file)
index 0000000..0c69acf
--- /dev/null
@@ -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 (file)
index 6de27da..0000000
+++ /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 (file)
index 0000000..0617fe1
--- /dev/null
@@ -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)
+);
index 533375184c4d6ed80132ffd4160faa24b15585fb..73e0489b2f18051e55fce24febd9fde9fa701d36 100644 (file)
@@ -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()
index 68bc6dbfec617bdc16cd3501e9d2615332780585..aac59bb0be6b689e72e39f65da6dd7d2ec46e3da 100644 (file)
 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]:
index efcc497e1d7674d79d2eef627534ccaaa5b6cbdc..2b2c18ef3b5317c0fdb273c451ec13e71e64af2d 100644 (file)
@@ -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."""
index b30b22c6e0edb6c92829fc3e5de06755b2a775b9..348feb0d60b0ff573e17f7ecb875868601045b06 100644 (file)
@@ -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)
index f7d375d9bac10d99ee9422c90e3e5a1b06aba5df..2be57d43686f42ba78efd644ffe34cc38ca3087b 100644 (file)
@@ -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:
index 595ff2048cf939183bdcbefbc5ff86c26131fc3b..3ac657f6fefcefc5f85cbfdfe0adf9cf92449a7e 100644 (file)
@@ -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)
index 8f8e2c64b90f7ac60e111329ecf02cc1f4fc30c9..78d396ee208334dc66d3eb79c4e7c7739953a3fc 100644 (file)
@@ -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
index 5e849b06e9414dccbcfe490709aba2aa1ce8787a..759bc12b1db802fd5f48530380c239d893d55c56 100644 (file)
@@ -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)
index 41037c921ca69157990f1a9f26bbfc9e90862f8d..b24335740e2cec1b5d0892e3d8fe08eb0b2735bb 100644 (file)
@@ -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],