feat(bib): dockets table + Store.docket_* CRUD; index items.url (refs #615)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
76
tests/bib/test_store_dockets.py
Normal file
76
tests/bib/test_store_dockets.py
Normal 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"]
|
||||
Reference in New Issue
Block a user