feat(bib): dockets table + Store.docket_* CRUD; index items.url (refs #615)

This commit is contained in:
kert
2026-09-08 11:14:50 -04:00
parent 5c45dddc09
commit e1439b7381
3 changed files with 187 additions and 0 deletions

View File

@@ -137,3 +137,25 @@ CREATE TABLE IF NOT EXISTS fr_links (
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (item_key, url)
);
-- ── Dockets (P47) ────────────────────────────────────────────────
-- One row per reg.gov docket: what every stage would otherwise re-fetch
-- (document/object ids, close date), the pull watermark, and the seal
-- that marks the docket complete. See bib/dockets.py.
CREATE TABLE IF NOT EXISTS dockets (
id TEXT PRIMARY KEY,
rule_cms_id TEXT NOT NULL DEFAULT '',
fr_document_id TEXT NOT NULL DEFAULT '',
fr_object_id TEXT NOT NULL DEFAULT '',
comment_end_date TEXT NOT NULL DEFAULT '',
pull_watermark TEXT NOT NULL DEFAULT '',
last_pull_at TEXT NOT NULL DEFAULT '',
last_pull_new INTEGER,
sealed_at TEXT NOT NULL DEFAULT '',
seal_reason TEXT NOT NULL DEFAULT '',
counts_json TEXT NOT NULL DEFAULT '{}'
);
-- upsert() dedupes by URL on every comment; without this every upsert
-- was a full scan of items.
CREATE INDEX IF NOT EXISTS idx_items_url ON items(url);

View File

@@ -17,6 +17,7 @@ Usage::
from __future__ import annotations
import json
import mimetypes
import random
import shutil
@@ -25,6 +26,7 @@ from importlib import resources
from pathlib import Path
from typing import Any
from bib.dockets import Docket
from bib.item import Item
@@ -497,6 +499,93 @@ class Store:
).fetchall()
return [{"name": r["name"], "count": r["cnt"]} for r in rows]
# ── Dockets ──────────────────────────────────────────────────
_DOCKET_COLS = (
"id",
"rule_cms_id",
"fr_document_id",
"fr_object_id",
"comment_end_date",
"pull_watermark",
"last_pull_at",
"last_pull_new",
"sealed_at",
"seal_reason",
"counts_json",
)
def _docket_from_row(self, row: sqlite3.Row | None) -> Docket | None:
if row is None:
return None
d = {c: row[c] for c in self._DOCKET_COLS}
return Docket(**d)
def docket_get(self, docket_id: str) -> Docket | None:
row = (
self._con()
.execute("SELECT * FROM dockets WHERE id = ?", (docket_id,))
.fetchone()
)
return self._docket_from_row(row)
def docket_for_rule(self, cms_rule_id: str) -> Docket | None:
row = (
self._con()
.execute(
"SELECT * FROM dockets WHERE rule_cms_id = ? ORDER BY id LIMIT 1",
(cms_rule_id,),
)
.fetchone()
)
return self._docket_from_row(row)
def docket_upsert(self, docket: Docket) -> None:
cols = ", ".join(self._DOCKET_COLS)
marks = ", ".join(f":{c}" for c in self._DOCKET_COLS)
sets = ", ".join(f"{c} = excluded.{c}" for c in self._DOCKET_COLS if c != "id")
con = self._con()
con.execute(
f"INSERT INTO dockets ({cols}) VALUES ({marks}) " # noqa: S608
f"ON CONFLICT(id) DO UPDATE SET {sets}",
{c: getattr(docket, c) for c in self._DOCKET_COLS},
)
con.commit()
def dockets(self) -> list[Docket]:
rows = self._con().execute("SELECT * FROM dockets ORDER BY id").fetchall()
return [self._docket_from_row(r) for r in rows]
def sealed_dockets(self) -> dict[str, str]:
"""docket id → sealed_at for every sealed docket."""
rows = (
self._con()
.execute("SELECT id, sealed_at FROM dockets WHERE sealed_at <> ''")
.fetchall()
)
return {r["id"]: r["sealed_at"] for r in rows}
def docket_seal(
self, docket_id: str, *, reason: str, counts: dict[str, int]
) -> None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
con = self._con()
con.execute(
"UPDATE dockets SET sealed_at = ?, seal_reason = ?, counts_json = ? WHERE id = ?",
(now, reason, json.dumps(counts, sort_keys=True), docket_id),
)
con.commit()
def docket_unseal(self, docket_id: str) -> None:
con = self._con()
con.execute(
"UPDATE dockets SET sealed_at = '', seal_reason = '' WHERE id = ?",
(docket_id,),
)
con.commit()
# ── Attachments & Notes ──────────────────────────────────────
def attach_file(self, item_key: str, path: Path, *, title: str = "") -> str:

View File

@@ -0,0 +1,76 @@
"""Store.docket_* — persistence for bib.dockets.Docket."""
from __future__ import annotations
from bib.dockets import Docket
from bib.store import Store
def _store() -> Store:
return Store(":memory:", storage_dir="/tmp/nope")
def test_table_exists_after_init():
s = _store()
names = {
r[0]
for r in s._con().execute("SELECT name FROM sqlite_master WHERE type='table'")
}
assert "dockets" in names
idx = {
r[0]
for r in s._con().execute("SELECT name FROM sqlite_master WHERE type='index'")
}
assert "idx_items_url" in idx
def test_upsert_then_get_roundtrip():
s = _store()
d = Docket(
id="CMS-2026-2377",
rule_cms_id="CMS-1848-P",
fr_object_id="obj",
comment_end_date="2026-09-14",
)
s.docket_upsert(d)
got = s.docket_get("CMS-2026-2377")
assert got == d
assert s.docket_get("CMS-0000-0000") is None
def test_upsert_replaces_fields():
s = _store()
s.docket_upsert(Docket(id="D1", pull_watermark="a"))
s.docket_upsert(Docket(id="D1", pull_watermark="b", last_pull_new=4))
got = s.docket_get("D1")
assert got.pull_watermark == "b"
assert got.last_pull_new == 4
def test_docket_for_rule():
s = _store()
s.docket_upsert(Docket(id="D1", rule_cms_id="CMS-1848-P"))
assert s.docket_for_rule("CMS-1848-P").id == "D1"
assert s.docket_for_rule("CMS-9999-P") is None
def test_seal_unseal_and_sealed_dockets():
s = _store()
s.docket_upsert(Docket(id="D1"))
s.docket_upsert(Docket(id="D2"))
s.docket_seal("D1", reason="manual", counts={"comments": 3})
d1 = s.docket_get("D1")
assert d1.sealed and d1.seal_reason == "manual"
assert '"comments": 3' in d1.counts_json
assert set(s.sealed_dockets()) == {"D1"}
assert s.sealed_dockets()["D1"] == d1.sealed_at
s.docket_unseal("D1")
assert not s.docket_get("D1").sealed
assert s.sealed_dockets() == {}
def test_dockets_lists_sorted_by_id():
s = _store()
s.docket_upsert(Docket(id="CMS-2019-0111"))
s.docket_upsert(Docket(id="CMS-2017-0092"))
assert [d.id for d in s.dockets()] == ["CMS-2017-0092", "CMS-2019-0111"]