Spaces:
Running
Running
added fresh screens
Browse files- alembic/versions/0015_starred_at.py +33 -0
- app/api/leads.py +7 -0
- app/crud/leads.py +14 -0
- app/models.py +5 -0
- app/schemas/lead.py +3 -0
- scripts/check_star.py +63 -0
alembic/versions/0015_starred_at.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""leads.starred_at
|
| 2 |
+
|
| 3 |
+
"Star" priority mark (user, 2026-09-23): "agar humyn us lead ko priority deni ho toh sales team star mark
|
| 4 |
+
kr dy... yeh sirf mark hai koi action nhi" -- a rep flags a lead as worth prioritizing. Tag only, same as
|
| 5 |
+
call_picked_by (revision 0013): never changes pipeline_stage or queue position, not logged as a
|
| 6 |
+
call_events row. Pipeline shows/filters by it; a starred card renders with a blue accent.
|
| 7 |
+
|
| 8 |
+
Nullable, no default, no backfill: every existing lead is unaffected (not starred).
|
| 9 |
+
|
| 10 |
+
Revision ID: 0015_starred_at
|
| 11 |
+
Revises: 0014_hidden_and_callback
|
| 12 |
+
Create Date: 2026-09-23
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from typing import Sequence, Union
|
| 16 |
+
|
| 17 |
+
import sqlalchemy as sa
|
| 18 |
+
from alembic import op
|
| 19 |
+
|
| 20 |
+
revision: str = "0015_starred_at"
|
| 21 |
+
down_revision: Union[str, None] = "0014_hidden_and_callback"
|
| 22 |
+
branch_labels: Union[str, Sequence[str], None] = None
|
| 23 |
+
depends_on: Union[str, Sequence[str], None] = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def upgrade() -> None:
|
| 27 |
+
op.add_column("leads", sa.Column("starred_at", sa.DateTime(), nullable=True))
|
| 28 |
+
op.create_index("idx_leads_starred", "leads", ["starred_at"])
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def downgrade() -> None:
|
| 32 |
+
op.drop_index("idx_leads_starred", table_name="leads")
|
| 33 |
+
op.drop_column("leads", "starred_at")
|
app/api/leads.py
CHANGED
|
@@ -190,6 +190,13 @@ def needs_email(
|
|
| 190 |
return LeadOut.from_model(crud.mark_needs_email(db, lead, user))
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
@router.post("/leads/{lead_id}/meeting-outcome", response_model=LeadOut)
|
| 194 |
def meeting_outcome(
|
| 195 |
lead_id: int,
|
|
|
|
| 190 |
return LeadOut.from_model(crud.mark_needs_email(db, lead, user))
|
| 191 |
|
| 192 |
|
| 193 |
+
@router.post("/leads/{lead_id}/star", response_model=LeadOut)
|
| 194 |
+
def star(lead_id: int, db: Session = Depends(get_db), user: User | None = Depends(get_current_user)):
|
| 195 |
+
""""Star" priority mark -- toggle, see crud.toggle_star(). User request, 2026-09-23."""
|
| 196 |
+
lead = _require(db, lead_id)
|
| 197 |
+
return LeadOut.from_model(crud.toggle_star(db, lead, user))
|
| 198 |
+
|
| 199 |
+
|
| 200 |
@router.post("/leads/{lead_id}/meeting-outcome", response_model=LeadOut)
|
| 201 |
def meeting_outcome(
|
| 202 |
lead_id: int,
|
app/crud/leads.py
CHANGED
|
@@ -509,6 +509,20 @@ def set_call_picked_by(db: Session, lead: Lead, value: str, user=None) -> Lead:
|
|
| 509 |
return lead
|
| 510 |
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
def mark_needs_email(db: Session, lead: Lead, user=None) -> Lead:
|
| 513 |
"""
|
| 514 |
"Email Send" button -- structure-plan.md Phase 3/4. A forward-looking flag ("this lead now needs an
|
|
|
|
| 509 |
return lead
|
| 510 |
|
| 511 |
|
| 512 |
+
def toggle_star(db: Session, lead: Lead, user=None) -> Lead:
|
| 513 |
+
"""
|
| 514 |
+
"Star" priority mark (user, 2026-09-23) -- same tag-only rule as set_call_picked_by above: no stage
|
| 515 |
+
change, no queue movement, not logged as a call_events row. Just flips starred_at on/off.
|
| 516 |
+
"""
|
| 517 |
+
lead.starred_at = None if lead.starred_at else _now()
|
| 518 |
+
lead.updated_at = _now()
|
| 519 |
+
if user is not None:
|
| 520 |
+
lead.last_touched_by_user_id = user.id
|
| 521 |
+
db.commit()
|
| 522 |
+
db.refresh(lead)
|
| 523 |
+
return lead
|
| 524 |
+
|
| 525 |
+
|
| 526 |
def mark_needs_email(db: Session, lead: Lead, user=None) -> Lead:
|
| 527 |
"""
|
| 528 |
"Email Send" button -- structure-plan.md Phase 3/4. A forward-looking flag ("this lead now needs an
|
app/models.py
CHANGED
|
@@ -73,6 +73,7 @@ class Lead(Base):
|
|
| 73 |
Index("idx_leads_sent_to_desk", "sent_to_desk_at"),
|
| 74 |
Index("idx_leads_hidden", "hidden_at"),
|
| 75 |
Index("idx_leads_callback", "callback_at"),
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
@@ -198,6 +199,10 @@ class Lead(Base):
|
|
| 198 |
# Callback Scheduled's date/time (separate from meeting_at -- a callback is not a meeting and must
|
| 199 |
# never appear on the Meetings calendar). Pipeline shows/filters by this.
|
| 200 |
callback_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
contacts: Mapped[list["LeadContact"]] = relationship(
|
| 203 |
back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
|
|
|
|
| 73 |
Index("idx_leads_sent_to_desk", "sent_to_desk_at"),
|
| 74 |
Index("idx_leads_hidden", "hidden_at"),
|
| 75 |
Index("idx_leads_callback", "callback_at"),
|
| 76 |
+
Index("idx_leads_starred", "starred_at"),
|
| 77 |
)
|
| 78 |
|
| 79 |
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
| 199 |
# Callback Scheduled's date/time (separate from meeting_at -- a callback is not a meeting and must
|
| 200 |
# never appear on the Meetings calendar). Pipeline shows/filters by this.
|
| 201 |
callback_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 202 |
+
# "Star" priority mark (revision 0015, user 2026-09-23) -- tag only, same as call_picked_by: never
|
| 203 |
+
# changes pipeline_stage or queue position, not logged as a call_events row. A rep sets this to flag
|
| 204 |
+
# "this lead is worth prioritizing"; Pipeline shows/filters by it.
|
| 205 |
+
starred_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
| 206 |
|
| 207 |
contacts: Mapped[list["LeadContact"]] = relationship(
|
| 208 |
back_populates="lead", cascade="all, delete-orphan", passive_deletes=True
|
app/schemas/lead.py
CHANGED
|
@@ -173,6 +173,8 @@ class LeadOut(BaseModel):
|
|
| 173 |
hidden_at: str = ""
|
| 174 |
# "" = no callback scheduled. Pipeline shows/filters leads by this.
|
| 175 |
callback_at: str = ""
|
|
|
|
|
|
|
| 176 |
|
| 177 |
@classmethod
|
| 178 |
def from_model(cls, m: Any) -> "LeadOut":
|
|
@@ -234,6 +236,7 @@ class LeadOut(BaseModel):
|
|
| 234 |
call_picked_by=_s(getattr(m, "call_picked_by", "")),
|
| 235 |
hidden_at=_dt_utc(getattr(m, "hidden_at", None)),
|
| 236 |
callback_at=_dt_utc(getattr(m, "callback_at", None)),
|
|
|
|
| 237 |
call_tz=call_timezone(
|
| 238 |
_s(m.contact_phone),
|
| 239 |
_s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
|
|
|
|
| 173 |
hidden_at: str = ""
|
| 174 |
# "" = no callback scheduled. Pipeline shows/filters leads by this.
|
| 175 |
callback_at: str = ""
|
| 176 |
+
# "" = not starred. Priority mark, tag only -- see models.Lead.starred_at. User request, 2026-09-23.
|
| 177 |
+
starred_at: str = ""
|
| 178 |
|
| 179 |
@classmethod
|
| 180 |
def from_model(cls, m: Any) -> "LeadOut":
|
|
|
|
| 236 |
call_picked_by=_s(getattr(m, "call_picked_by", "")),
|
| 237 |
hidden_at=_dt_utc(getattr(m, "hidden_at", None)),
|
| 238 |
callback_at=_dt_utc(getattr(m, "callback_at", None)),
|
| 239 |
+
starred_at=_dt_utc(getattr(m, "starred_at", None)),
|
| 240 |
call_tz=call_timezone(
|
| 241 |
_s(m.contact_phone),
|
| 242 |
_s(getattr(m, "phone_country", "")) or _s(getattr(m, "country", "")),
|
scripts/check_star.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
check_star.py -- offline: the "Star" priority mark (user, 2026-09-23).
|
| 3 |
+
|
| 4 |
+
uv run python scripts/check_star.py
|
| 5 |
+
|
| 6 |
+
No database, no network -- throwaway in-memory SQLite. Covers: toggle_star() flips starred_at on/off,
|
| 7 |
+
never touches pipeline_stage, sent_to_desk_at, or hidden_at (tag only, same rule as call_picked_by), and
|
| 8 |
+
is not logged as a call_events row.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 17 |
+
|
| 18 |
+
from sqlalchemy import create_engine, select # noqa: E402
|
| 19 |
+
from sqlalchemy.orm import sessionmaker # noqa: E402
|
| 20 |
+
from sqlalchemy.pool import StaticPool # noqa: E402
|
| 21 |
+
|
| 22 |
+
import app.models # noqa: E402,F401
|
| 23 |
+
from app.crud import leads as crud_leads # noqa: E402
|
| 24 |
+
from app.database import Base # noqa: E402
|
| 25 |
+
from app.models import CallEvent # noqa: E402
|
| 26 |
+
|
| 27 |
+
FAILED = 0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def check(label: str, ok: bool, detail: str = "") -> None:
|
| 31 |
+
global FAILED
|
| 32 |
+
FAILED += 0 if ok else 1
|
| 33 |
+
print(f" [{'ok ' if ok else 'FAIL'}] {label}" + ("" if ok else f" {detail}"))
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> int:
|
| 37 |
+
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 38 |
+
Base.metadata.create_all(engine)
|
| 39 |
+
db = sessionmaker(bind=engine)()
|
| 40 |
+
|
| 41 |
+
lead = crud_leads.add_lead_dict(db, {"company_name": "Star Co"})
|
| 42 |
+
before_stage, before_desk, before_hidden = lead.pipeline_stage, lead.sent_to_desk_at, lead.hidden_at
|
| 43 |
+
events_before = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
|
| 44 |
+
|
| 45 |
+
print("\nStar on")
|
| 46 |
+
lead = crud_leads.toggle_star(db, lead)
|
| 47 |
+
check("starred_at set", lead.starred_at is not None)
|
| 48 |
+
check("pipeline_stage untouched", lead.pipeline_stage == before_stage)
|
| 49 |
+
check("sent_to_desk_at untouched", lead.sent_to_desk_at == before_desk)
|
| 50 |
+
check("hidden_at untouched", lead.hidden_at == before_hidden)
|
| 51 |
+
events_after = db.scalars(select(CallEvent).where(CallEvent.lead_id == lead.id)).all()
|
| 52 |
+
check("no call_events row logged", len(events_after) == len(events_before))
|
| 53 |
+
|
| 54 |
+
print("\nStar off (toggle again)")
|
| 55 |
+
lead = crud_leads.toggle_star(db, lead)
|
| 56 |
+
check("starred_at cleared", lead.starred_at is None)
|
| 57 |
+
|
| 58 |
+
print(f"\n{'FAILED: ' + str(FAILED) if FAILED else 'All checks passed.'}")
|
| 59 |
+
return 1 if FAILED else 0
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
sys.exit(main())
|