Spaces:
Running
Running
File size: 6,893 Bytes
9300036 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | """
check_callback_attempts.py -- offline: the 3-checkbox re-contact tracker (crud/callback_attempts.py,
2026-09-29). No database, no network -- throwaway in-memory SQLite.
uv run python scripts/check_callback_attempts.py
Covers:
* record_attempt() appends a UTC timestamp per call, caps at 3 (a 4th call is a no-op)
* undo_last_attempt() pops the most recently checked box, is a no-op on an empty list
* next_attempt_due() is None with 0 or 3 attempts recorded, and gives the right gap otherwise
(48h after the 1st, 72h after the 2nd)
* the weekend push: a due date that would land on Saturday or Sunday (PKT) rolls forward to Monday,
same time -- checked by picking a 1st-attempt timestamp that lands the 48h-later due date ON a
Saturday with NO push, to prove the push is what moves it
* the three contexts (callback / no_show / proposal) are genuinely independent -- checking one does not
touch the other two on the same lead
* LeadOut.from_model() serializes the raw history with a "Z" suffix (matching every other
backend-generated timestamp) and exposes the same due date computed above
"""
from __future__ import annotations
import sys
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
import app.models # noqa: E402,F401
from app.crud import callback_attempts as crud # noqa: E402
from app.crud import leads as crud_leads # noqa: E402
from app.database import Base # noqa: E402
from app.schemas.lead import LeadOut # noqa: E402
FAILED = 0
def check(label: str, ok: bool, detail: str = "") -> None:
global FAILED
FAILED += 0 if ok else 1
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
def main() -> int:
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
lead = crud_leads.add_lead_dict(db, {"company_name": "Ancient Cherry Yachts"})
print("\nrecord_attempt: appends, caps at 3")
check("starts empty", (lead.callback_attempts or []) == [])
crud.record_attempt(db, lead, "callback")
check("1 recorded", len(lead.callback_attempts) == 1)
crud.record_attempt(db, lead, "callback")
crud.record_attempt(db, lead, "callback")
check("3 recorded", len(lead.callback_attempts) == 3)
third = list(lead.callback_attempts)
crud.record_attempt(db, lead, "callback")
check("4th call is a no-op, still 3", lead.callback_attempts == third)
print("\nundo_last_attempt: pops the most recent, no-op on empty")
crud.undo_last_attempt(db, lead, "callback")
check("back to 2 after undo", len(lead.callback_attempts) == 2)
crud.undo_last_attempt(db, lead, "callback")
crud.undo_last_attempt(db, lead, "callback")
check("back to 0 after 2 more undos", lead.callback_attempts == [])
crud.undo_last_attempt(db, lead, "callback")
check("undo on empty is a no-op, no crash", lead.callback_attempts == [])
print("\ncontexts are independent")
lead2 = crud_leads.add_lead_dict(db, {"company_name": "Raspy Darkness Marine"})
crud.record_attempt(db, lead2, "callback")
crud.record_attempt(db, lead2, "no_show")
crud.record_attempt(db, lead2, "no_show")
check("callback has 1", len(lead2.callback_attempts) == 1)
check("no_show has 2, not shared with callback's count", len(lead2.no_show_attempts) == 2)
check("proposal untouched", (lead2.proposal_attempts or []) == [])
print("\nnext_attempt_due: gap math")
check("0 attempts -> None (nothing to schedule yet)", crud.next_attempt_due([]) is None)
check("3 attempts -> None (confirm point, not a reschedule point)", crud.next_attempt_due(["a", "b", "c"]) is None)
# A Wednesday timestamp: 48h later is Friday -- no weekend push needed, isolates the gap math itself
# from the push logic (checked separately below).
wed = datetime(2026, 9, 30, 10, 0, 0) # 2026-09-30 is a Wednesday
due_after_1 = crud.next_attempt_due([wed.isoformat()])
check(
"after 1 attempt, due is +48h (no weekend in the way)",
due_after_1 == (wed + timedelta(hours=48)).isoformat(),
str(due_after_1),
)
due_after_2 = crud.next_attempt_due([wed.isoformat(), (wed + timedelta(hours=48)).isoformat()])
check(
"after 2 attempts, due is +72h from the 2nd (not the 1st)",
due_after_2 == (wed + timedelta(hours=48 + 72)).isoformat(),
str(due_after_2),
)
print("\nweekend push: a due date landing on Sat/Sun (PKT) rolls to Monday same time")
# Thursday 2026-10-01, 10:00 PKT +48h (raw, no push) = Saturday 2026-10-03, 10:00 PKT -- lands ON a
# weekend with NO push, so this specific timestamp proves the push actually moved it, not that it
# happened to land on a weekday anyway. Stored as naive UTC (matching _utcnow()), so PKT = UTC + 5h --
# pick the UTC instant whose PKT wall-clock is Thursday 10:00 (i.e. UTC 05:00).
thu_utc = datetime(2026, 10, 1, 5, 0, 0) # PKT: Thu 2026-10-01 10:00
raw_due = thu_utc + timedelta(hours=48) # PKT: Sat 2026-10-03 10:00 -- the weekend case
check(
"(context) the raw +48h WOULD land on a Saturday in PKT, proving a push is actually needed here",
(raw_due + timedelta(hours=5)).weekday() == 5,
str((raw_due + timedelta(hours=5)).weekday()),
)
pushed = crud.next_attempt_due([thu_utc.isoformat()])
pushed_dt = datetime.fromisoformat(pushed)
pushed_pkt_weekday = (pushed_dt + timedelta(hours=5)).weekday()
check("pushed date is no longer a weekend day in PKT", pushed_pkt_weekday < 5, str(pushed_pkt_weekday))
check(
"pushed to the following Monday, same wall-clock time (not shifted)",
pushed_dt == raw_due + timedelta(days=2) and pushed_dt.time() == raw_due.time(),
str(pushed_dt),
)
print("\nLeadOut.from_model: 'Z' suffix, due date exposed")
lead3 = crud_leads.add_lead_dict(db, {"company_name": "Z Suffix Check"})
crud.record_attempt(db, lead3, "proposal")
out = LeadOut.from_model(lead3)
check("proposal_attempts has exactly 1 entry, 'Z'-suffixed", len(out.proposal_attempts) == 1 and out.proposal_attempts[0].endswith("Z"))
check("proposal_next_attempt_due is set and 'Z'-suffixed", out.proposal_next_attempt_due.endswith("Z"), out.proposal_next_attempt_due)
check("callback_attempts empty on this lead", out.callback_attempts == [])
check("callback_next_attempt_due empty (0 attempts)", out.callback_next_attempt_due == "")
print("\nFAILED: %d" % FAILED)
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())
|