from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Literal from sqlalchemy import case, func, select from sqlalchemy.orm import Session from .models import OtpMessageLog, PhoneNumber, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint Period = Literal["weekly", "monthly", "quarterly"] Service = Literal["sms", "inbound", "outbound", "otp"] GroupBy = Literal["dlt_sender", "dlt_template", "did", "socket", "project", "otp_type"] PERIOD_BUCKETS = { "weekly": ("week", 12), "monthly": ("month", 12), "quarterly": ("quarter", 4), } SERVICE_GROUP_BY: dict[str, frozenset[str]] = { "sms": frozenset({"dlt_sender", "dlt_template"}), "inbound": frozenset({"did"}), "outbound": frozenset({"socket", "did"}), "otp": frozenset({"project", "otp_type"}), } DEFAULT_GROUP_BY: dict[str, str] = { "sms": "dlt_sender", "inbound": "did", "outbound": "socket", "otp": "project", } def _truncate_start(value: datetime, unit: str) -> datetime: value = value.astimezone(timezone.utc) if unit == "week": start = value - timedelta(days=value.weekday()) return start.replace(hour=0, minute=0, second=0, microsecond=0) if unit == "month": return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0) quarter_month = ((value.month - 1) // 3) * 3 + 1 return value.replace(month=quarter_month, day=1, hour=0, minute=0, second=0, microsecond=0) def _shift_bucket(start: datetime, unit: str, offset: int) -> datetime: if unit == "week": return start + timedelta(weeks=offset) if unit == "month": month = start.month + offset year = start.year + (month - 1) // 12 month = ((month - 1) % 12) + 1 return start.replace(year=year, month=month, day=1) month = start.month + (offset * 3) year = start.year + (month - 1) // 12 month = ((month - 1) % 12) + 1 return start.replace(year=year, month=month, day=1) def _format_bucket_label(start: datetime, unit: str) -> str: if unit == "week": return start.strftime("%d %b") if unit == "month": return start.strftime("%b %Y") quarter = ((start.month - 1) // 3) + 1 return f"Q{quarter} {start.year}" def _build_bucket_keys(end: datetime, unit: str, count: int) -> list[tuple[datetime, str]]: current = _truncate_start(end, unit) keys: list[tuple[datetime, str]] = [] for offset in range(-(count - 1), 1): start = _shift_bucket(current, unit, offset) keys.append((start, _format_bucket_label(start, unit))) return keys def _bucket_key(value: datetime, unit: str) -> str: return _truncate_start(value, unit).isoformat() def _bucket_map(rows: list[tuple[datetime, int]], unit: str) -> dict[str, int]: mapped: dict[str, int] = {} for bucket, count in rows: if bucket.tzinfo is None: bucket = bucket.replace(tzinfo=timezone.utc) mapped[_bucket_key(bucket.astimezone(timezone.utc), unit)] = int(count) return mapped def _breakdown_item(label: str, detail: str, count: int) -> dict: return {"label": label, "detail": detail, "count": int(count)} def _count_by_bucket( db: Session, *, model, user_id, start: datetime, end: datetime, unit: str, extra_filters=(), ) -> dict[str, int]: bucket = func.date_trunc(unit, model.created_at).label("bucket") rows = db.execute( select(bucket, func.count()) .where( model.user_id == user_id, model.created_at >= start, model.created_at <= end, *extra_filters, ) .group_by(bucket) .order_by(bucket) ).all() return _bucket_map(rows, unit) def _voice_bucket_map( db: Session, *, user_id, direction: str, start: datetime, end: datetime, unit: str, ) -> dict[str, int]: bucket = func.date_trunc(unit, VoiceCall.created_at).label("bucket") rows = db.execute( select(bucket, func.count()) .where( VoiceCall.user_id == user_id, VoiceCall.direction == direction, VoiceCall.created_at >= start, VoiceCall.created_at <= end, ) .group_by(bucket) .order_by(bucket) ).all() return _bucket_map(rows, unit) def _breakdown_sms_sender(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(SmsMessageLog.sender_id, func.count()) .where( SmsMessageLog.user_id == user_id, SmsMessageLog.created_at >= start, SmsMessageLog.created_at <= end, ) .group_by(SmsMessageLog.sender_id) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(sender_id, "DLT sender header", count) for sender_id, count in rows] def _breakdown_sms_template(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(SmsMessageLog.template_name, func.count()) .where( SmsMessageLog.user_id == user_id, SmsMessageLog.created_at >= start, SmsMessageLog.created_at <= end, ) .group_by(SmsMessageLog.template_name) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(template_name, "DLT template", count) for template_name, count in rows] def _breakdown_inbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(VoiceCall.from_did, func.count()) .where( VoiceCall.user_id == user_id, VoiceCall.direction == "inbound", VoiceCall.created_at >= start, VoiceCall.created_at <= end, ) .group_by(VoiceCall.from_did) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(did, "Your Urmi number", count) for did, count in rows] def _breakdown_outbound_did(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(VoiceCall.from_did, func.count()) .where( VoiceCall.user_id == user_id, VoiceCall.direction == "outbound", VoiceCall.created_at >= start, VoiceCall.created_at <= end, ) .group_by(VoiceCall.from_did) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(did, "Caller ID used", count) for did, count in rows] def _breakdown_outbound_socket(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: socket_name = VoiceSocketEndpoint.name phone_number = PhoneNumber.phone_number label = case( (socket_name.isnot(None), socket_name), else_="No voice socket linked", ).label("label") detail = phone_number.label("detail") rows = db.execute( select(label, detail, func.count()) .select_from(VoiceCall) .join(PhoneNumber, VoiceCall.phone_number_id == PhoneNumber.id) .outerjoin( VoiceSocketEndpoint, (VoiceSocketEndpoint.phone_number_id == VoiceCall.phone_number_id) & (VoiceSocketEndpoint.user_id == VoiceCall.user_id), ) .where( VoiceCall.user_id == user_id, VoiceCall.direction == "outbound", VoiceCall.created_at >= start, VoiceCall.created_at <= end, ) .group_by(label, detail) .order_by(func.count().desc()) .limit(10) ).all() return [ _breakdown_item( name, f"Linked to {number}" if name != "No voice socket linked" else f"Number {number}", count, ) for name, number, count in rows ] def _breakdown_otp_project(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(OtpMessageLog.project_name, func.count()) .where( OtpMessageLog.user_id == user_id, OtpMessageLog.created_at >= start, OtpMessageLog.created_at <= end, ) .group_by(OtpMessageLog.project_name) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(project, "UrmiOTP project", count) for project, count in rows] def _breakdown_otp_type(db: Session, user_id, start: datetime, end: datetime) -> list[dict]: rows = db.execute( select(OtpMessageLog.template_type, func.count()) .where( OtpMessageLog.user_id == user_id, OtpMessageLog.created_at >= start, OtpMessageLog.created_at <= end, ) .group_by(OtpMessageLog.template_type) .order_by(func.count().desc()) .limit(10) ).all() return [_breakdown_item(template_type.replace("_", " ").title(), "OTP template type", count) for template_type, count in rows] def _build_breakdown( db: Session, user_id, *, service: Service, group_by: GroupBy, start: datetime, end: datetime, ) -> list[dict]: builders = { ("sms", "dlt_sender"): _breakdown_sms_sender, ("sms", "dlt_template"): _breakdown_sms_template, ("inbound", "did"): _breakdown_inbound_did, ("outbound", "did"): _breakdown_outbound_did, ("outbound", "socket"): _breakdown_outbound_socket, ("otp", "project"): _breakdown_otp_project, ("otp", "otp_type"): _breakdown_otp_type, } builder = builders.get((service, group_by)) if not builder: return [] return builder(db, user_id, start, end) def build_usage_analytics( db: Session, user: UserAccount, *, period: Period, service: Service, group_by: GroupBy, ) -> dict: if group_by not in SERVICE_GROUP_BY[service]: group_by = DEFAULT_GROUP_BY[service] # type: ignore[assignment] unit, bucket_count = PERIOD_BUCKETS[period] end = datetime.now(timezone.utc) first_bucket_start = _build_bucket_keys(end, unit, bucket_count)[0][0] start = first_bucket_start sms_map = _count_by_bucket(db, model=SmsMessageLog, user_id=user.id, start=start, end=end, unit=unit) otp_map = _count_by_bucket(db, model=OtpMessageLog, user_id=user.id, start=start, end=end, unit=unit) inbound_map = _voice_bucket_map(db, user_id=user.id, direction="inbound", start=start, end=end, unit=unit) outbound_map = _voice_bucket_map(db, user_id=user.id, direction="outbound", start=start, end=end, unit=unit) timeline = [] for bucket_start, label in _build_bucket_keys(end, unit, bucket_count): key = _bucket_key(bucket_start, unit) timeline.append( { "label": label, "period_start": bucket_start, "sms": sms_map.get(key, 0), "inbound": inbound_map.get(key, 0), "outbound": outbound_map.get(key, 0), "otp": otp_map.get(key, 0), } ) totals = { "sms": sum(item["sms"] for item in timeline), "inbound": sum(item["inbound"] for item in timeline), "outbound": sum(item["outbound"] for item in timeline), "otp": sum(item["otp"] for item in timeline), } breakdown = _build_breakdown( db, user.id, service=service, group_by=group_by, start=start, end=end, ) return { "period": period, "service": service, "group_by": group_by, "from": start, "to": end, "totals": totals, "timeline": timeline, "breakdown": breakdown, }