Harden the Hub-side polling protocol for on-prem TapTrack agents: - sync/poll: accept job_failed_ids (retry/fail on-prem delivery failures) - sync/poll: deduct credits + write ledger when on-prem reports sent jobs - sync/poll: return feature_flags (tier-based) + suspended flag in config - sync/poll: skip job dispatch for suspended/expired schools - sms_jobs: add delivered_via (pull|push) + processing_started_at columns - tasks/sms: new sms.reclaim_stale_jobs task resets processing→pending if on-prem goes offline (jobs stuck >5 min), enabling Celery push fallback - tasks/sms: tag Celery-sent jobs as delivered_via='push' - worker: schedule reclaim_stale_jobs every 5 minutes - migration: 001_phase5 adds delivered_via + processing_started_at to sms_jobs
53 lines
2.9 KiB
Python
53 lines
2.9 KiB
Python
import uuid
|
|
import enum
|
|
from datetime import datetime, timezone
|
|
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum, ForeignKey, Text, Numeric, Integer
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from app.database import Base
|
|
|
|
class SmsJobStatus(str, enum.Enum):
|
|
pending = "pending"
|
|
processing = "processing"
|
|
sent = "sent"
|
|
failed = "failed"
|
|
cancelled = "cancelled"
|
|
|
|
class SmsCreditTx(str, enum.Enum):
|
|
topup = "topup"
|
|
deduct = "deduct"
|
|
refund = "refund"
|
|
adjustment = "adjustment"
|
|
|
|
class SmsJob(Base):
|
|
__tablename__ = "sms_jobs"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
recipient_phone: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
sender_name: Mapped[str] = mapped_column(String(11), nullable=False)
|
|
status: Mapped[SmsJobStatus] = mapped_column(SAEnum(SmsJobStatus), default=SmsJobStatus.pending, nullable=False, index=True)
|
|
trigger: Mapped[str | None] = mapped_column(String(50), nullable=True) # "absent", "late", "manual"
|
|
semaphore_message_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
delivered_via: Mapped[str | None] = mapped_column(String(10), nullable=True) # "pull" | "push"
|
|
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
school: Mapped["School"] = relationship("School", back_populates="sms_jobs")
|
|
|
|
class SmsCreditLedger(Base):
|
|
__tablename__ = "sms_credit_ledger"
|
|
|
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
school_id: Mapped[str] = mapped_column(String(36), ForeignKey("schools.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
tx_type: Mapped[SmsCreditTx] = mapped_column(SAEnum(SmsCreditTx), nullable=False)
|
|
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
|
balance_after: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
reference_id: Mapped[str | None] = mapped_column(String(36), nullable=True) # invoice_id or sms_job_id
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
|
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # hub_user_id
|