611 lines
28 KiB
Python
611 lines
28 KiB
Python
"""Durable, account-scoped ordered Today plans."""
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from src.private_state import connect_private_sqlite
|
|
|
|
|
|
class TodayPlanFull(ValueError):
|
|
"""Raised when an add would exceed the bounded Today plan."""
|
|
|
|
|
|
class TodaySessionConflict(ValueError):
|
|
"""Raised when a device updates an obsolete active-session revision."""
|
|
|
|
def __init__(self, session: dict):
|
|
super().__init__("active Today session changed on another device")
|
|
self.session = session
|
|
|
|
|
|
class TodayStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
limit: int = 5,
|
|
timeout: float = 1.0,
|
|
operation_limit: int = 4096,
|
|
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
|
recap_limit: int = 100,
|
|
clock=time.time,
|
|
):
|
|
self.path = Path(path)
|
|
self.limit = limit
|
|
self.timeout = timeout
|
|
self.operation_limit = operation_limit
|
|
self.operation_retention_seconds = operation_retention_seconds
|
|
self.recap_limit = recap_limit
|
|
self.clock = clock
|
|
self._initialize()
|
|
|
|
def _initialize(self) -> None:
|
|
connection = connect_private_sqlite(self.path, timeout=self.timeout)
|
|
if connection.execute("PRAGMA user_version").fetchone()[0] >= 3:
|
|
connection.close()
|
|
return
|
|
connection.execute("PRAGMA journal_mode=WAL")
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS today_plans (
|
|
login TEXT PRIMARY KEY,
|
|
revision INTEGER NOT NULL,
|
|
ids TEXT NOT NULL,
|
|
capacity_minutes INTEGER,
|
|
estimates TEXT NOT NULL DEFAULT '{}',
|
|
plan_date TEXT,
|
|
timezone TEXT
|
|
)
|
|
"""
|
|
)
|
|
plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
|
|
if "capacity_minutes" not in plan_columns:
|
|
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
|
if "estimates" not in plan_columns:
|
|
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
|
if "plan_date" not in plan_columns:
|
|
connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
|
|
if "timezone" not in plan_columns:
|
|
connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS today_operations (
|
|
login TEXT NOT NULL,
|
|
operation_id TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
PRIMARY KEY (login, operation_id)
|
|
)
|
|
"""
|
|
)
|
|
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
|
if "created_at" not in columns:
|
|
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
|
|
connection.execute(
|
|
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
|
(self.clock(),),
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS today_recaps (
|
|
login TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
items TEXT NOT NULL,
|
|
PRIMARY KEY (login, session_id)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
|
|
"ON today_recaps(login, created_at DESC)"
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS today_time_logs (
|
|
login TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
identity TEXT NOT NULL,
|
|
actual_minutes INTEGER NOT NULL,
|
|
status TEXT NOT NULL,
|
|
PRIMARY KEY (login, session_id, identity)
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS today_sessions (
|
|
login TEXT PRIMARY KEY,
|
|
revision INTEGER NOT NULL,
|
|
device_id TEXT NOT NULL,
|
|
identity TEXT NOT NULL,
|
|
elapsed_ms INTEGER NOT NULL,
|
|
running INTEGER NOT NULL,
|
|
break_deadline_at INTEGER,
|
|
updated_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")}
|
|
if "break_deadline_at" not in session_columns:
|
|
connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER")
|
|
connection.execute("PRAGMA user_version = 3")
|
|
connection.commit()
|
|
connection.close()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
return connect_private_sqlite(self.path, timeout=self.timeout)
|
|
|
|
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
|
now = self.clock()
|
|
connection.execute(
|
|
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
|
(login, operation_id, now),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
|
|
(login, now - self.operation_retention_seconds),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
|
|
"(SELECT rowid FROM today_operations WHERE login = ? "
|
|
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
|
(login, login, self.operation_limit),
|
|
)
|
|
|
|
@staticmethod
|
|
def _normalize_login(login: str) -> str:
|
|
normalized = login.strip().lower()
|
|
if not normalized:
|
|
raise ValueError("login is required")
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _snapshot(row) -> dict:
|
|
if row is None:
|
|
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
|
ids = json.loads(row[1])
|
|
estimates = json.loads(row[3] or "{}")
|
|
snapshot = {
|
|
"revision": int(row[0]),
|
|
"ids": ids,
|
|
"capacity_minutes": row[2],
|
|
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
|
}
|
|
if len(row) > 4 and row[4]:
|
|
snapshot["plan_date"] = row[4]
|
|
snapshot["timezone"] = row[5]
|
|
return snapshot
|
|
|
|
def get(self, login: str) -> dict:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
|
"FROM today_plans WHERE login = ?",
|
|
(self._normalize_login(login),),
|
|
).fetchone()
|
|
return self._snapshot(row)
|
|
|
|
@staticmethod
|
|
def _empty_session() -> dict:
|
|
return {
|
|
"revision": 0, "device_id": "", "identity": "",
|
|
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
|
}
|
|
|
|
def get_session(self, login: str) -> dict:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
|
"FROM today_sessions WHERE login = ?",
|
|
(self._normalize_login(login),),
|
|
).fetchone()
|
|
if row is None:
|
|
return self._empty_session()
|
|
return {
|
|
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
|
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
|
"break_deadline_at": row[5], "updated_at": row[6],
|
|
}
|
|
|
|
def update_session(
|
|
self, login: str, *, base_revision: int, device_id: str,
|
|
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
|
) -> dict:
|
|
login = self._normalize_login(login)
|
|
updated_at = self.clock()
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
current = connection.execute(
|
|
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
|
|
"FROM today_sessions WHERE login = ?", (login,)
|
|
).fetchone()
|
|
current_revision = int(current[0]) if current else 0
|
|
if base_revision != current_revision:
|
|
session = self._empty_session() if current is None else {
|
|
"revision": current_revision, "device_id": current[1], "identity": current[2],
|
|
"elapsed_ms": int(current[3]), "running": bool(current[4]),
|
|
"break_deadline_at": current[5], "updated_at": current[6],
|
|
}
|
|
raise TodaySessionConflict(session)
|
|
revision = current_revision + 1
|
|
connection.execute(
|
|
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
|
|
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
|
|
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
|
|
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
|
|
(login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
|
|
)
|
|
return self.get_session(login)
|
|
|
|
@staticmethod
|
|
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
|
if not isinstance(items, list) or not items:
|
|
raise ValueError("recap requires at least one item")
|
|
if len(items) > 20:
|
|
raise ValueError("recap is limited to 20 items")
|
|
normalized = []
|
|
seen = set()
|
|
for item in items:
|
|
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
|
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
|
raise ValueError("recap item identity is required and bounded")
|
|
identity = identity.strip()
|
|
if identity in seen:
|
|
raise ValueError("recap item identities must be unique")
|
|
actual = item.get("actual_minutes")
|
|
estimate = item.get("estimate_minutes")
|
|
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
|
|
raise ValueError("actual minutes must be between 0 and 1440")
|
|
if estimate is not None and (
|
|
not isinstance(estimate, int) or isinstance(estimate, bool)
|
|
or estimate < 5 or estimate > 1440
|
|
):
|
|
raise ValueError("estimate minutes must be between 5 and 1440")
|
|
seen.add(identity)
|
|
normalized.append({
|
|
"identity": identity,
|
|
"estimate_minutes": estimate,
|
|
"actual_minutes": actual,
|
|
})
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
|
|
items = json.loads(serialized)
|
|
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
|
|
actual = sum(item["actual_minutes"] for item in items)
|
|
return {
|
|
"session_id": session_id,
|
|
"created_at": created_at,
|
|
"items": items,
|
|
"estimated_minutes": estimated,
|
|
"actual_minutes": actual,
|
|
"variance_minutes": actual - estimated,
|
|
}
|
|
|
|
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
|
|
login = self._normalize_login(login)
|
|
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
|
|
raise ValueError("session_id is required and bounded")
|
|
session_id = session_id.strip()
|
|
normalized = self._normalize_recap_items(items)
|
|
serialized = json.dumps(normalized, separators=(",", ":"))
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
existing = connection.execute(
|
|
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
|
|
(login, session_id),
|
|
).fetchone()
|
|
if existing is not None:
|
|
return self._recap_snapshot(session_id, existing[0], existing[1])
|
|
created_at = self.clock()
|
|
connection.execute(
|
|
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
|
|
(login, session_id, created_at, serialized),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
|
|
"(SELECT rowid FROM today_recaps WHERE login = ? "
|
|
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
|
(login, login, self.recap_limit),
|
|
)
|
|
return self._recap_snapshot(session_id, created_at, serialized)
|
|
|
|
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
|
|
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
|
|
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
|
(self._normalize_login(login), bounded_limit),
|
|
).fetchall()
|
|
return [self._recap_snapshot(*row) for row in rows]
|
|
|
|
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
|
|
"""Claim one recap item for upstream logging, returning its current state."""
|
|
login = self._normalize_login(login)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT actual_minutes, status FROM today_time_logs "
|
|
"WHERE login = ? AND session_id = ? AND identity = ?",
|
|
(login, session_id, identity),
|
|
).fetchone()
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
|
|
"VALUES (?, ?, ?, ?, 'pending')",
|
|
(login, session_id, identity, actual_minutes),
|
|
)
|
|
return "claimed"
|
|
if row[0] != actual_minutes:
|
|
raise ValueError("logged recap time cannot be changed")
|
|
if row[1] == "failed":
|
|
connection.execute(
|
|
"UPDATE today_time_logs SET status = 'pending' "
|
|
"WHERE login = ? AND session_id = ? AND identity = ?",
|
|
(login, session_id, identity),
|
|
)
|
|
return "claimed"
|
|
return row[1]
|
|
|
|
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"UPDATE today_time_logs SET status = ? "
|
|
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
|
|
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
|
|
)
|
|
|
|
def apply(
|
|
self,
|
|
login: str,
|
|
operation_id: str,
|
|
action: str,
|
|
item_id: str,
|
|
*,
|
|
direction: str | None = None,
|
|
) -> dict:
|
|
login = self._normalize_login(login)
|
|
if not operation_id or not item_id:
|
|
raise ValueError("operation_id and item_id are required")
|
|
if action not in {"add", "remove", "move"}:
|
|
raise ValueError("unsupported Today action")
|
|
if action == "move" and direction not in {"up", "down"}:
|
|
raise ValueError("move direction must be up or down")
|
|
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
|
"FROM today_plans WHERE login = ?", (login,)
|
|
).fetchone()
|
|
snapshot = self._snapshot(row)
|
|
duplicate = connection.execute(
|
|
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
|
(login, operation_id),
|
|
).fetchone()
|
|
if duplicate:
|
|
return snapshot
|
|
|
|
ids = list(snapshot["ids"])
|
|
estimates = dict(snapshot["estimates"])
|
|
changed = False
|
|
if action == "add":
|
|
if item_id not in ids:
|
|
if len(ids) >= self.limit:
|
|
raise TodayPlanFull("Today is limited to five items")
|
|
ids.append(item_id)
|
|
changed = True
|
|
elif action == "remove":
|
|
if item_id in ids:
|
|
ids.remove(item_id)
|
|
estimates.pop(item_id, None)
|
|
changed = True
|
|
else:
|
|
try:
|
|
index = ids.index(item_id)
|
|
except ValueError:
|
|
index = -1
|
|
target = index - 1 if direction == "up" else index + 1
|
|
if index >= 0 and 0 <= target < len(ids):
|
|
ids[index], ids[target] = ids[target], ids[index]
|
|
changed = True
|
|
|
|
revision = snapshot["revision"] + (1 if changed else 0)
|
|
serialized_ids = json.dumps(ids, separators=(",", ":"))
|
|
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
|
snapshot.get("plan_date"), snapshot.get("timezone")),
|
|
)
|
|
elif changed:
|
|
connection.execute(
|
|
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
|
|
(revision, serialized_ids, serialized_estimates, login),
|
|
)
|
|
self._record_operation(connection, login, operation_id)
|
|
result = {
|
|
"revision": revision,
|
|
"ids": ids,
|
|
"capacity_minutes": snapshot["capacity_minutes"],
|
|
"estimates": estimates,
|
|
}
|
|
if snapshot.get("plan_date"):
|
|
result["plan_date"] = snapshot["plan_date"]
|
|
result["timezone"] = snapshot["timezone"]
|
|
return result
|
|
|
|
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
|
"""Apply an ordered batch with one lock and receipt per operation."""
|
|
login = self._normalize_login(login)
|
|
with self._connect() as connection:
|
|
connection.execute("BEGIN IMMEDIATE")
|
|
row = connection.execute(
|
|
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
|
"FROM today_plans WHERE login = ?", (login,)
|
|
).fetchone()
|
|
snapshot = self._snapshot(row)
|
|
ids = list(snapshot["ids"])
|
|
capacity_minutes = snapshot["capacity_minutes"]
|
|
estimates = dict(snapshot["estimates"])
|
|
plan_date = snapshot.get("plan_date")
|
|
timezone = snapshot.get("timezone")
|
|
revision = snapshot["revision"]
|
|
accepted: list[str] = []
|
|
duplicates: list[str] = []
|
|
rejected: list[dict[str, str]] = []
|
|
|
|
for operation in operations:
|
|
operation_id = operation.get("operation_id", "")
|
|
action = operation.get("action", "")
|
|
item_id = operation.get("item_id", "")
|
|
direction = operation.get("direction")
|
|
if not operation_id or not item_id:
|
|
raise ValueError("operation_id and item_id are required")
|
|
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
|
raise ValueError("unsupported Today action")
|
|
if action == "move" and direction not in {"up", "down"}:
|
|
raise ValueError("move direction must be up or down")
|
|
duplicate = connection.execute(
|
|
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
|
(login, operation_id),
|
|
).fetchone()
|
|
if duplicate:
|
|
duplicates.append(operation_id)
|
|
continue
|
|
|
|
base_revision = operation.get("base_revision")
|
|
if base_revision is None:
|
|
base_revision = snapshot["revision"]
|
|
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
|
raise ValueError("base_revision must be a non-negative integer")
|
|
if base_revision < snapshot["revision"]:
|
|
self._record_operation(connection, login, operation_id)
|
|
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
|
continue
|
|
|
|
changed = False
|
|
if action == "add":
|
|
if item_id not in ids:
|
|
if len(ids) >= self.limit:
|
|
rejected.append({"operation_id": operation_id, "reason": "today_full"})
|
|
continue
|
|
ids.append(item_id)
|
|
changed = True
|
|
elif action == "remove":
|
|
if item_id in ids:
|
|
ids.remove(item_id)
|
|
estimates.pop(item_id, None)
|
|
changed = True
|
|
elif action == "move":
|
|
try:
|
|
index = ids.index(item_id)
|
|
except ValueError:
|
|
index = -1
|
|
target = index - 1 if direction == "up" else index + 1
|
|
if index >= 0 and 0 <= target < len(ids):
|
|
ids[index], ids[target] = ids[target], ids[index]
|
|
changed = True
|
|
elif action == "configure":
|
|
proposed_capacity = operation.get("capacity_minutes")
|
|
if proposed_capacity is not None and (
|
|
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
|
or proposed_capacity < 15 or proposed_capacity > 1440
|
|
):
|
|
raise ValueError("capacity_minutes must be between 15 and 1440")
|
|
proposed_estimates = operation.get("estimates", {})
|
|
if not isinstance(proposed_estimates, dict):
|
|
raise ValueError("estimates must be an object")
|
|
normalized_estimates = {}
|
|
for estimate_id, minutes in proposed_estimates.items():
|
|
if estimate_id not in ids:
|
|
continue
|
|
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
|
raise ValueError("estimate minutes must be between 5 and 1440")
|
|
normalized_estimates[estimate_id] = minutes
|
|
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
|
capacity_minutes = proposed_capacity
|
|
estimates = normalized_estimates
|
|
else:
|
|
proposed_date = operation.get("plan_date")
|
|
proposed_timezone = operation.get("timezone")
|
|
try:
|
|
if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
|
|
raise ValueError
|
|
except (TypeError, ValueError):
|
|
raise ValueError("plan_date must be an ISO calendar date") from None
|
|
if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
|
|
raise ValueError("timezone is required and bounded")
|
|
proposed_ids = operation.get("ids")
|
|
if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
|
|
not isinstance(candidate, str) or not candidate or len(candidate) > 500
|
|
for candidate in proposed_ids
|
|
):
|
|
raise ValueError("rollover IDs are invalid or exceed the Today limit")
|
|
if len(set(proposed_ids)) != len(proposed_ids):
|
|
raise ValueError("rollover IDs must be unique")
|
|
proposed_capacity = operation.get("capacity_minutes")
|
|
if proposed_capacity is not None and (
|
|
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
|
or proposed_capacity < 15 or proposed_capacity > 1440
|
|
):
|
|
raise ValueError("capacity_minutes must be between 15 and 1440")
|
|
proposed_estimates = operation.get("estimates", {})
|
|
if not isinstance(proposed_estimates, dict):
|
|
raise ValueError("estimates must be an object")
|
|
normalized_estimates = {}
|
|
for estimate_id, minutes in proposed_estimates.items():
|
|
if estimate_id not in proposed_ids:
|
|
continue
|
|
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
|
raise ValueError("estimate minutes must be between 5 and 1440")
|
|
normalized_estimates[estimate_id] = minutes
|
|
changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
|
|
estimates != normalized_estimates or plan_date != proposed_date or
|
|
timezone != proposed_timezone.strip())
|
|
ids = list(proposed_ids)
|
|
capacity_minutes = proposed_capacity
|
|
estimates = normalized_estimates
|
|
plan_date = proposed_date
|
|
timezone = proposed_timezone.strip()
|
|
|
|
revision += 1 if changed else 0
|
|
self._record_operation(connection, login, operation_id)
|
|
accepted.append(operation_id)
|
|
|
|
serialized = json.dumps(ids, separators=(",", ":"))
|
|
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
|
if row is None:
|
|
connection.execute(
|
|
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
|
)
|
|
elif accepted:
|
|
connection.execute(
|
|
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
|
"plan_date = ?, timezone = ? WHERE login = ?",
|
|
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
|
)
|
|
result = {
|
|
"revision": revision,
|
|
"ids": ids,
|
|
"capacity_minutes": capacity_minutes,
|
|
"estimates": estimates,
|
|
"accepted_operation_ids": accepted,
|
|
"duplicate_operation_ids": duplicates,
|
|
"rejected_operations": rejected,
|
|
}
|
|
if plan_date:
|
|
result["plan_date"] = plan_date
|
|
result["timezone"] = timezone
|
|
return result
|