Email search/query + windowed digest preview (v0.1.0:83)
Communications tab (search/query roadmap items 1 & 2): - Fix the investor dropdown: the facet only listed grid investors, so it came back empty whenever email matched a classic contact or org domain (no grid id — the common case). It now mirrors the email list, resolving each link to a typed identity (fund:/org:/contact:/addr:) with precedence grid -> org -> contact -> address; investor_id accepts the typed key (bare id = fund: for back-compat) and an unknown prefix matches nothing. - Add a date-range filter and a click-to-expand full-body view (GET /api/email/detail, admin, soft-delete-gated; body_text only, never raw remote HTML). - Add a "Search content" mode: GET /api/email/search wraps the ingest hybrid_search over the Qdrant email index (doc_type=email), hydrated and soft-delete-filtered against SQLite (canonical), 503 if Spark/Qdrant down. Daily digest: - Settings -> Admin builds a digest over a chosen window (last 24h or since a date) as an in-app preview before sending (POST /api/admin/digest/preview), so the local-Spark summarizer can be verified on demand even on a quiet day. Manual send uses the same window; neither advances the daily cursor, so a preview never suppresses the scheduled digest. Code-only, migrations no-op. 22/22 backend tests, render-smoke pass.
This commit is contained in:
@@ -283,6 +283,70 @@ def test_scheduler_guards():
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_window_resolver():
|
||||
from datetime import timedelta
|
||||
nu = datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc)
|
||||
nl = datetime(2026, 6, 16, 8, 0, tzinfo=timezone(timedelta(hours=-7))) # PDT
|
||||
|
||||
s, u = digest_builder.resolve_digest_window(now_utc=nu, now_local=nl)
|
||||
check((s, u) == ("2026-06-15T15:00:00Z", "2026-06-16T15:00:00Z"), f"default = last 24h, got {(s,u)}")
|
||||
|
||||
s, u = digest_builder.resolve_digest_window(hours=48, now_utc=nu, now_local=nl)
|
||||
check(s == "2026-06-14T15:00:00Z", f"hours=48 lookback, got {s}")
|
||||
|
||||
# since = a local calendar date -> that day's LOCAL midnight, expressed in UTC
|
||||
s, u = digest_builder.resolve_digest_window(since="2026-06-01", now_utc=nu, now_local=nl)
|
||||
check(s == "2026-06-01T07:00:00Z", f"since-date -> local midnight in UTC, got {s}")
|
||||
|
||||
# a since older than the cap clamps to MAX_WINDOW_DAYS (response echoes real window)
|
||||
s, u = digest_builder.resolve_digest_window(since="2025-01-01", now_utc=nu, now_local=nl)
|
||||
check(s == (nu - timedelta(days=digest_builder.MAX_WINDOW_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
f"over-cap since clamps to {digest_builder.MAX_WINDOW_DAYS}d, got {s}")
|
||||
|
||||
# since wins over hours when both supplied
|
||||
s, u = digest_builder.resolve_digest_window(hours=1, since="2026-06-10", now_utc=nu, now_local=nl)
|
||||
check(s.startswith("2026-06-10"), f"since wins over hours, got {s}")
|
||||
|
||||
# same-day boundary: since = today's local date, now later in the day -> valid
|
||||
# window (local midnight is strictly before now), not a "start must be before now" raise
|
||||
s, u = digest_builder.resolve_digest_window(since="2026-06-16", now_utc=nu, now_local=nl)
|
||||
check(s == "2026-06-16T07:00:00Z" and u == "2026-06-16T15:00:00Z",
|
||||
f"since=today -> [local midnight, now], got {(s, u)}")
|
||||
|
||||
for bad in [dict(hours=0), dict(hours="abc"), dict(since="nope"), dict(since="2027-01-01")]:
|
||||
try:
|
||||
digest_builder.resolve_digest_window(now_utc=nu, now_local=nl, **bad)
|
||||
check(False, f"bad input {bad} should raise")
|
||||
except ValueError:
|
||||
check(True, f"bad input rejected: {bad}")
|
||||
|
||||
|
||||
def test_send_digest_window():
|
||||
sent = []
|
||||
build_fn = lambda conn, since, until: {"subject": "S", "body": f"{since}|{until}",
|
||||
"has_activity": True, "user_count": 1,
|
||||
"email_count": 2, "investor_count": 1}
|
||||
def send_fn(conn, to_addrs, subject, body, sender_email=None):
|
||||
sent.append((list(to_addrs), body))
|
||||
return {"transport": "stub"}
|
||||
|
||||
conn = _conn()
|
||||
before = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
|
||||
conn.close()
|
||||
|
||||
r = digest_scheduler.send_digest_window(_conn, since_iso="2026-05-01T00:00:00Z",
|
||||
until_iso="2026-06-16T00:00:00Z",
|
||||
build_fn=build_fn, send_fn=send_fn)
|
||||
check(r["status"] == "sent" and r["window"] == ["2026-05-01T00:00:00Z", "2026-06-16T00:00:00Z"],
|
||||
f"windowed send returns its window, got {r}")
|
||||
check(sent and sent[-1][0] == ["grant@ten31.xyz"], f"windowed send -> active admins only, got {sent}")
|
||||
|
||||
conn = _conn()
|
||||
after = digest_scheduler._get_setting(conn, digest_scheduler._LAST_AT_KEY)
|
||||
conn.close()
|
||||
check(before == after, "windowed manual send does not advance the daily cursor")
|
||||
|
||||
|
||||
def main():
|
||||
setup()
|
||||
print("collect_user_activity:"); test_collect()
|
||||
@@ -290,6 +354,8 @@ def main():
|
||||
print("build_digest + empty:"); test_build_and_empty()
|
||||
print("summary fallback:"); test_summary_fallback()
|
||||
print("digest policy:"); test_policy()
|
||||
print("window resolver:"); test_window_resolver()
|
||||
print("windowed manual send:"); test_send_digest_window()
|
||||
print("scheduler guards:"); test_scheduler_guards()
|
||||
if FAILS:
|
||||
print(f"\nFAILED ({len(FAILS)})")
|
||||
|
||||
Reference in New Issue
Block a user