v0.1 wave 2C (step 8): email digest scheduler
- digest.py: DigestScheduler with daily 06:00 PT loop
- SmtpConfig env-driven (CRAFTING_SMTP_*)
- notify.on event filter respected per project
- GET /digests/{date} + POST /admin/digest/run-now (dry_run flag)
- migration 006: digest_runs (idempotency via UNIQUE(date, project_name))
- text + HTML email bodies; matches spec's worked example
- Server lifespan integration; gracefully disables if SMTP not configured
- tests/test_digest.py: 8 tests (aggregation / filter / smtp mock / idempotency / endpoint)
Patch-drafted line is a placeholder until wave 3 / step 9 ships.
Spec: memory/spec-crafting-table.md
This commit is contained in:
parent
2e16ec886d
commit
98306ca2e0
8 changed files with 1095 additions and 3 deletions
|
|
@ -112,6 +112,21 @@ MIGRATIONS: list[tuple[str, str]] = [
|
|||
CREATE INDEX IF NOT EXISTS idx_findings_fingerprint ON findings(fingerprint);
|
||||
""",
|
||||
),
|
||||
(
|
||||
"006_digest_runs",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS digest_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
project_name TEXT NOT NULL,
|
||||
sent_at INTEGER NOT NULL,
|
||||
recipient_count INTEGER NOT NULL,
|
||||
job_count INTEGER NOT NULL,
|
||||
UNIQUE(date, project_name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_digest_runs_date ON digest_runs(date);
|
||||
""",
|
||||
),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
|
@ -489,6 +504,52 @@ class DB:
|
|||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ---------- digest runs --------------------------------------------------
|
||||
|
||||
def record_digest_run(
|
||||
self,
|
||||
*,
|
||||
date: str,
|
||||
project_name: str,
|
||||
sent_at: int,
|
||||
recipient_count: int,
|
||||
job_count: int,
|
||||
) -> bool:
|
||||
"""Record an attempted/successful digest send. UNIQUE(date, project_name)
|
||||
enforces idempotency — a second call for the same date+project is a
|
||||
no-op (returns False)."""
|
||||
with self._conn() as c:
|
||||
cur = c.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO digest_runs
|
||||
(date, project_name, sent_at, recipient_count, job_count)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(date, project_name, sent_at, recipient_count, job_count),
|
||||
)
|
||||
return cur.rowcount == 1
|
||||
|
||||
def digest_run_exists(self, date: str, project_name: str) -> bool:
|
||||
with self._conn() as c:
|
||||
row = c.execute(
|
||||
"SELECT 1 FROM digest_runs WHERE date=? AND project_name=?",
|
||||
(date, project_name),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def list_digest_runs(self, date: str | None = None) -> list[dict]:
|
||||
with self._conn() as c:
|
||||
if date is None:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM digest_runs ORDER BY date DESC, project_name"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = c.execute(
|
||||
"SELECT * FROM digest_runs WHERE date=? ORDER BY project_name",
|
||||
(date,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ---------- async wrappers ----------------------------------------------
|
||||
|
||||
async def arun(self, fn, *args, **kwargs):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue