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')),
|
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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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