359 lines
11 KiB
Python
359 lines
11 KiB
Python
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,
|
|
}
|