Compare commits

..

5 commits

Author SHA1 Message Date
archeious
2d7ce333ea docs: document monthly view, updated layout, and deferred work
Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:40:06 -06:00
archeious
9da7205f77 test: cover month snapshot, deviation states, and per-month target
Service tests assert that create_month produces origin fields matching
the budget, that edits flip deviation_state to edited, that added rows
are new_in_month, and that a budget entry deleted after snapshot leaves
the month entry unchanged. Route tests exercise the create flow,
applied updates, name edits producing the modified tag, per-month
target isolation, and the malformed-year-month 404.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:40:05 -06:00
archeious
647bf257f4 feat(month): add routes, templates, and nav between budget and months
Non-existent months return a page with a single "Create this month"
button; create POSTs return HX-Redirect to the newly-created month.
Each entry row carries three inline HTMX-wired inputs (name, planned,
applied) that trigger on change, posting only the field that changed.
Edits swap the section partial so totals and deviation tags update
together. Deleting a debt minimum in a month also re-renders the
target card via OOB swap. The budget page grows a This-month link and
a month picker; each month page has prev / next / picker / back-to-
config controls.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:40:02 -06:00
archeious
c6d4d65fe6 feat(month): add snapshot service with deviation tracking
create_month copies every budget entry into month_entry with
origin_name/origin_planned retained, resolves the budget's debt target
through source_entry_id to the corresponding MonthEntry, and is
idempotent. deviation_state classifies each row as unchanged, edited,
or new_in_month. Year-month handling (validation, shift across year
boundaries) lives here so the route layer stays thin.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:39:55 -06:00
archeious
b63daa958b feat(db): add Month, MonthEntry, and MonthDebtTarget models with migration
A month is a snapshot of the budget. MonthEntry holds the copied planned
amount plus applied and origin_name/origin_planned so the UI can mark
edited rows. source_entry_id links back to the budget but is nullable
with ON DELETE SET NULL, so deleting a budget row after snapshot leaves
the month intact. MonthDebtTarget is one row per month via CASCADE from
month.

Refs #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:39:50 -06:00
6 changed files with 0 additions and 260 deletions

1
.gitignore vendored
View file

@ -65,7 +65,6 @@ quartermaster.db
quartermaster.db-journal
*.sqlite
*.sqlite3
backups/
# Flask stuff:
instance/

View file

@ -1,46 +0,0 @@
# Quartermaster (repo instructions)
## Database safety rule
The SQLite database at `./quartermaster.db` (or whatever path
`QUARTERMASTER_DB_URL` points at) holds real user data. It is
`.gitignored`, so losing it means the data is gone.
**Before any operation that interacts with the database at the schema
or destructive level**, run:
```sh
./scripts/backup-db.sh <reason>
```
That includes:
* `rm`, `mv`, truncate, or otherwise replace `quartermaster.db`
* Ad-hoc `sqlite3` sessions that might `DROP`, `DELETE`, or `UPDATE`
* Any script that does data migration or cleanup outside the running
application
Alembic runs `scripts/backup-db.sh alembic` automatically on every
`alembic upgrade`, `alembic downgrade`, or `alembic revision --autogenerate`
via the hook in `alembic/env.py`, so you do not need to call it
manually for ordinary schema work. The hook is defense in depth, not
a substitute for thinking.
Routine writes through the running web application are NOT covered by
this rule. Those are normal application behaviour, not "interactions"
in the sense meant here.
## Where backups go
Default: `<dir-of-db-file>/backups/quartermaster-YYYYMMDD-HHMMSS-{slug}.db`.
For the standard `./quartermaster.db` that resolves to `./backups/`.
Override with `QUARTERMASTER_BACKUP_DIR=/some/path`.
The `backups/` directory is `.gitignored`. Retention is forever:
backups are small. Prune manually if you need to.
## Restoring
A backup is a complete SQLite file. To restore, stop the app, replace
`quartermaster.db` with the chosen backup (`cp backups/... quartermaster.db`),
then restart.

View file

@ -50,23 +50,6 @@ uv run pytest
Tests run against an in-memory SQLite database; no migration step needed.
## Backups
The SQLite data file is precious and gitignored. Before any schema
change or destructive operation, back it up:
```sh
./scripts/backup-db.sh <reason>
```
Backups land next to the database in `./backups/` by default
(`QUARTERMASTER_BACKUP_DIR=/some/path` to override) as
`quartermaster-YYYYMMDD-HHMMSS-{slug}.db` using SQLite's online backup
API (safe even while the app is writing). Alembic invokes the script
automatically before every migration via `alembic/env.py`; retention
is forever for now. To restore, stop the app, copy the chosen backup
over `quartermaster.db`, and restart.
## Project Layout
```

View file

@ -1,6 +1,4 @@
import subprocess
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import engine_from_config, pool
@ -9,28 +7,6 @@ from alembic import context
from quartermaster.config import DB_URL
from quartermaster.models import Base
def _backup_before_migrations() -> None:
script = Path(__file__).resolve().parents[1] / "scripts" / "backup-db.sh"
if not script.is_file():
return
result = subprocess.run(
[str(script), "alembic"],
check=False,
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout, end="")
if result.returncode != 0 and result.stderr:
print(
f"alembic: backup-db.sh returned {result.returncode}; "
f"continuing without a fresh backup.\n{result.stderr}",
)
_backup_before_migrations()
config = context.config
config.set_main_option("sqlalchemy.url", DB_URL)

View file

@ -1,75 +0,0 @@
#!/usr/bin/env bash
#
# Copy the quartermaster SQLite database into backups/ before any schema
# change, data migration, or destructive operation. Safe to run while the
# application is writing to the DB: uses Python's sqlite3.Connection.backup
# (the online backup API), not a plain file copy.
#
# Usage:
# scripts/backup-db.sh [reason]
#
# `reason` becomes part of the backup filename. Defaults to "manual".
#
# Resolves the database path in this order:
# 1. QUARTERMASTER_DB_URL if it starts with sqlite:/// (uses the path part)
# 2. ./quartermaster.db relative to the repo root
#
# Exit status:
# 0 backup written, or source DB does not exist (nothing to do)
# 1 backup failed, or a non-file sqlite URL was supplied
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
reason="${1:-manual}"
exec python3 - "$reason" <<'PY'
import os
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
reason = sys.argv[1]
db_path = Path("quartermaster.db")
url = os.environ.get("QUARTERMASTER_DB_URL")
if url:
if url.startswith("sqlite:///"):
db_path = Path(url[len("sqlite:///"):])
elif url.startswith("sqlite://"):
print(f"backup-db.sh: non-file sqlite URL not supported: {url}", file=sys.stderr)
sys.exit(1)
else:
print(f"backup-db.sh: QUARTERMASTER_DB_URL is not a sqlite URL: {url}", file=sys.stderr)
sys.exit(1)
if not db_path.is_file():
print(f"backup-db.sh: no database at {db_path}, nothing to back up.")
sys.exit(0)
slug = re.sub(r"[^A-Za-z0-9]+", "-", reason).strip("-") or "manual"
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_root = os.environ.get("QUARTERMASTER_BACKUP_DIR")
if backup_root:
dest_dir = Path(backup_root)
else:
dest_dir = db_path.resolve().parent / "backups"
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"quartermaster-{timestamp}-{slug}.db"
src = sqlite3.connect(str(db_path))
try:
dst = sqlite3.connect(str(dest))
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
print(f"backup-db.sh: {db_path} -> {dest} ({dest.stat().st_size} bytes)")
PY

View file

@ -1,97 +0,0 @@
from __future__ import annotations
import os
import shutil
import sqlite3
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts" / "backup-db.sh"
def _run(args: list[str], env_overrides: dict[str, str], cwd: Path):
env = os.environ.copy()
env.update(env_overrides)
return subprocess.run(
[str(SCRIPT), *args],
check=False,
capture_output=True,
text=True,
cwd=str(cwd),
env=env,
)
def test_backup_exit_zero_when_db_missing(tmp_path):
fake_db = tmp_path / "ghost.db"
result = _run(
[],
{"QUARTERMASTER_DB_URL": f"sqlite:///{fake_db}"},
tmp_path,
)
assert result.returncode == 0, result.stderr
assert "nothing to back up" in result.stdout
def test_backup_creates_file(tmp_path):
# seed a real sqlite DB
db_path = tmp_path / "qm.db"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE t (x INTEGER)")
conn.execute("INSERT INTO t VALUES (1), (2), (3)")
conn.commit()
conn.close()
result = _run(
["unit-test"],
{"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"},
tmp_path,
)
assert result.returncode == 0, result.stderr
backup_dir = tmp_path / "backups"
assert backup_dir.is_dir()
backups = list(backup_dir.glob("quartermaster-*-unit-test.db"))
assert len(backups) == 1
# restored backup reads the same rows
restored = sqlite3.connect(str(backups[0]))
rows = list(restored.execute("SELECT x FROM t ORDER BY x"))
restored.close()
assert rows == [(1,), (2,), (3,)]
def test_backup_rejects_non_sqlite_url(tmp_path):
result = _run(
[],
{"QUARTERMASTER_DB_URL": "postgres://example"},
tmp_path,
)
assert result.returncode == 1
assert "not a sqlite URL" in result.stderr
def test_backup_slug_defaults_to_manual(tmp_path):
db_path = tmp_path / "qm.db"
sqlite3.connect(str(db_path)).close()
result = _run(
[],
{"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"},
tmp_path,
)
assert result.returncode == 0, result.stderr
backups = list((tmp_path / "backups").glob("*manual*.db"))
assert len(backups) == 1
def test_backup_slug_sanitizes_reason(tmp_path):
db_path = tmp_path / "qm.db"
sqlite3.connect(str(db_path)).close()
result = _run(
["alembic upgrade!! head"],
{"QUARTERMASTER_DB_URL": f"sqlite:///{db_path}"},
tmp_path,
)
assert result.returncode == 0, result.stderr
backups = list((tmp_path / "backups").glob("*.db"))
assert len(backups) == 1
assert "alembic-upgrade-head" in backups[0].name