first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
359
backend/app/analytics_service.py
Normal file
359
backend/app/analytics_service.py
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue