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')), created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (item_key, url) 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 from __future__ import annotations
import json
import mimetypes import mimetypes
import random import random
import shutil import shutil
@@ -25,6 +26,7 @@ from importlib import resources
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from bib.dockets import Docket
from bib.item import Item from bib.item import Item
@@ -497,6 +499,93 @@ class Store:
).fetchall() ).fetchall()
return [{"name": r["name"], "count": r["cnt"]} for r in rows] 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 ────────────────────────────────────── # ── Attachments & Notes ──────────────────────────────────────
def attach_file(self, item_key: str, path: Path, *, title: str = "") -> str: 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"]