add blocker presets
This commit is contained in:
12 files changed
+426
-83
No files matched your search
+27
-2
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import env_settings
|
||||
@@ -32,11 +32,36 @@ engine = create_engine(env_settings().database_url, **_engine_kwargs(env_setting
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
# Columns a refactor later dropped from the model but, with no migration tool
|
||||
# in this project, left behind on any database that was already running —
|
||||
# still `NOT NULL` with no server-side default, so every insert that no
|
||||
# longer sets them fails. `create_all` only ever adds; this is the other half.
|
||||
_DEAD_COLUMNS: dict[str, tuple[str, ...]] = {
|
||||
"tasks": ("completed_here",),
|
||||
}
|
||||
|
||||
|
||||
def _drop_dead_columns() -> None:
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
with engine.begin() as conn:
|
||||
for table, columns in _DEAD_COLUMNS.items():
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
existing_columns = {c["name"] for c in inspector.get_columns(table)}
|
||||
for column in columns:
|
||||
if column in existing_columns:
|
||||
conn.execute(text(f'ALTER TABLE {table} DROP COLUMN {column}'))
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create any missing tables. The schema is additive, so this is enough."""
|
||||
"""Create any missing tables. The schema is additive, so this is enough —
|
||||
plus the one-time cleanup above for columns a refactor removed from the
|
||||
model."""
|
||||
from app import models # noqa: F401 (register the mappers)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_drop_dead_columns()
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -216,6 +216,25 @@ def extend(session: Session, minutes: int) -> PomodoroSession:
|
||||
return row
|
||||
|
||||
|
||||
def rescale_current(session: Session, settings: AppSettings) -> PomodoroSession | None:
|
||||
"""Resize the running/paused interval to match a just-saved duration.
|
||||
|
||||
Without this, a preset (or any Focus/Short/Long edit) only takes effect
|
||||
from the *next* interval — the one already ticking keeps whatever length
|
||||
it was born with. `elapsed_seconds` is wall-clock, not stored, so this
|
||||
only has to move `planned_seconds`; time already spent still counts, it
|
||||
is just measured against the new total.
|
||||
"""
|
||||
row = current_session(session)
|
||||
if row is None:
|
||||
return None
|
||||
planned = duration_for(settings, row.kind) # type: ignore[arg-type]
|
||||
if planned != row.planned_seconds:
|
||||
row.planned_seconds = planned
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if task_id is None:
|
||||
|
||||
@@ -19,6 +19,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.services import pomodoro
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
@@ -193,4 +194,11 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
return save_settings(session, AppSettings.model_validate(merged))
|
||||
updated = save_settings(session, AppSettings.model_validate(merged))
|
||||
|
||||
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
|
||||
# whatever interval is already running or paused — see
|
||||
# `pomodoro.rescale_current`. Cheap and a no-op when nothing moved, so it
|
||||
# is simplest to always run it rather than detect which keys changed.
|
||||
pomodoro.rescale_current(session, updated)
|
||||
return updated
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Regression test for a schema-drift bug: a database created by an older
|
||||
build of the app still had a `completed_here NOT NULL` column on `tasks`
|
||||
that a later refactor dropped from the model. `create_all` never alters an
|
||||
existing table, so every task insert on such a database failed with
|
||||
|
||||
IntegrityError: null value in column "completed_here" ... violates
|
||||
not-null constraint
|
||||
|
||||
which, since task refresh runs from both the scheduler and `/api/tasks`
|
||||
endpoints, looked like "everything 500s". `app.db.init_db` now reconciles
|
||||
known-dead columns after `create_all`; this pins that behaviour down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal, engine, init_db
|
||||
from app.models import TaskRecord
|
||||
|
||||
|
||||
def _simulate_pre_refactor_database() -> None:
|
||||
"""Recreate the exact drift: today's `tasks` table, plus the long-gone
|
||||
`completed_here` column, `NOT NULL` and with no database-side default —
|
||||
just like a database that has been running since before the column was
|
||||
removed from the model.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("DROP TABLE IF EXISTS tasks"))
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
external_id VARCHAR(128) NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
url TEXT,
|
||||
project TEXT,
|
||||
status VARCHAR(64),
|
||||
priority VARCHAR(32),
|
||||
due_date DATE,
|
||||
start_date DATE,
|
||||
labels JSON,
|
||||
raw JSON,
|
||||
is_done BOOLEAN NOT NULL DEFAULT 0,
|
||||
completed_at DATETIME,
|
||||
completed_date DATE,
|
||||
completed_here BOOLEAN NOT NULL,
|
||||
estimated_pomodoros INTEGER,
|
||||
pinned BOOLEAN NOT NULL DEFAULT 0,
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
stale BOOLEAN NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_init_db_drops_the_dead_not_null_column(session):
|
||||
_simulate_pre_refactor_database()
|
||||
|
||||
init_db()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# This is exactly what `tasks_service._upsert` + `refresh_tasks` do on
|
||||
# a newly-seen task: insert a row without ever mentioning the column
|
||||
# the model no longer has.
|
||||
db.add(TaskRecord(source="jira", external_id="VPN-48", title="Renew VPN"))
|
||||
db.commit() # pre-fix: IntegrityError (null value in "completed_here")
|
||||
|
||||
stored = db.query(TaskRecord).filter_by(external_id="VPN-48").one()
|
||||
assert stored.title == "Renew VPN"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_init_db_is_a_no_op_once_the_column_is_already_gone(session):
|
||||
"""Running it twice (every boot does) must not blow up when there is
|
||||
nothing left to drop."""
|
||||
init_db()
|
||||
init_db()
|
||||
@@ -2,10 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services import settings_service
|
||||
from app.services import pomodoro, settings_service
|
||||
from app.settings_schema import SECRET_MASK
|
||||
|
||||
|
||||
def test_a_preset_change_resizes_the_running_interval(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
assert row.planned_seconds == settings.pomodoro.work_minutes * 60
|
||||
|
||||
updated = settings_service.apply_patch(
|
||||
session, {"pomodoro": {"work_minutes": 85, "short_break_minutes": 10}}
|
||||
)
|
||||
|
||||
current = pomodoro.current_session(session)
|
||||
assert current.id == row.id
|
||||
assert current.planned_seconds == 85 * 60
|
||||
# Elapsed time is wall-clock, not stored — resizing never has to touch it.
|
||||
assert pomodoro.elapsed_seconds(current) >= 0
|
||||
assert updated.pomodoro.work_minutes == 85
|
||||
|
||||
|
||||
def test_a_patch_unrelated_to_durations_leaves_the_running_interval_alone(session, settings):
|
||||
row = pomodoro.start(session, settings, kind="work")
|
||||
settings_service.apply_patch(session, {"pomodoro": {"daily_goal_pomodoros": 12}})
|
||||
current = pomodoro.current_session(session)
|
||||
assert current.id == row.id
|
||||
assert current.planned_seconds == row.planned_seconds
|
||||
|
||||
|
||||
def test_patch_merges_without_clobbering_siblings(session):
|
||||
settings_service.apply_patch(session, {"pomodoro": {"work_minutes": 50}})
|
||||
updated = settings_service.apply_patch(
|
||||
|
||||
Reference in new issue
Block a user