urmi-ai/backend/app/routes_logs.py
2026-10-02 11:22:40 +05:30

192 lines
6.3 KiB
Python

from datetime import datetime, timedelta, timezone
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload
from .auth import current_user
from .database import get_db
from .models import OtpMessageLog, SmsMessageLog, UserAccount, VoiceCall, VoiceSocketEndpoint
router = APIRouter(prefix="/api/v1/logs", tags=["logs"])
LOG_TYPES = frozenset({"outbound", "inbound", "sms", "otp"})
DEFAULT_RANGE_DAYS = 7
MAX_RANGE_DAYS = 31
PAGE_SIZE = 10
def _parse_time(value: str | None, *, fallback: datetime) -> datetime:
if not value:
return fallback
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid datetime format. Use ISO 8601.",
) from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _resolve_time_window(from_value: str | None, to_value: str | None) -> tuple[datetime, datetime]:
now = datetime.now(timezone.utc)
end = _parse_time(to_value, fallback=now)
start = _parse_time(from_value, fallback=end - timedelta(days=DEFAULT_RANGE_DAYS))
if start > end:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="'from' must be earlier than 'to'.",
)
if (end - start).days > MAX_RANGE_DAYS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Time range cannot exceed {MAX_RANGE_DAYS} days.",
)
return start, end
def _serialize_voice_call(call: VoiceCall, *, socket_name: str | None) -> dict:
if call.direction == "inbound":
from_number = call.to_number
to_number = call.from_did
else:
from_number = call.from_did
to_number = call.to_number
payload = {
"id": str(call.id),
"from": from_number,
"to": to_number,
"status": call.status,
"time": call.created_at,
}
if call.direction == "outbound":
payload["socket_name"] = socket_name or "—"
return payload
def _serialize_sms_log(entry: SmsMessageLog) -> dict:
return {
"id": str(entry.id),
"from": entry.sender_id,
"to": entry.to_number,
"template": entry.template_name,
"status": entry.status,
"time": entry.created_at,
}
def _serialize_otp_log(entry: OtpMessageLog) -> dict:
return {
"id": str(entry.id),
"to": entry.to_number,
"otp_type": entry.template_type,
"project": entry.project_name,
"status": entry.status,
"time": entry.created_at,
}
@router.get("")
def list_logs(
log_type: str = Query(..., alias="type"),
from_value: str | None = Query(default=None, alias="from"),
to_value: str | None = Query(default=None, alias="to"),
page: int = Query(default=1, ge=1),
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
normalized_type = log_type.strip().lower()
if normalized_type not in LOG_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"type must be one of: {', '.join(sorted(LOG_TYPES))}",
)
start, end = _resolve_time_window(from_value, to_value)
offset = (page - 1) * PAGE_SIZE
if normalized_type in {"outbound", "inbound"}:
direction = normalized_type
filters = [
VoiceCall.user_id == user.id,
VoiceCall.direction == direction,
VoiceCall.created_at >= start,
VoiceCall.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(VoiceCall).where(*filters)) or 0
calls = db.scalars(
select(VoiceCall)
.options(joinedload(VoiceCall.phone_number))
.where(*filters)
.order_by(VoiceCall.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).unique().all()
socket_by_phone: dict = {}
if direction == "outbound" and calls:
phone_ids = [call.phone_number_id for call in calls]
sockets = db.scalars(
select(VoiceSocketEndpoint).where(
VoiceSocketEndpoint.user_id == user.id,
VoiceSocketEndpoint.phone_number_id.in_(phone_ids),
)
).all()
socket_by_phone = {socket.phone_number_id: socket.name for socket in sockets}
items = [
_serialize_voice_call(call, socket_name=socket_by_phone.get(call.phone_number_id))
for call in calls
]
elif normalized_type == "sms":
filters = [
SmsMessageLog.user_id == user.id,
SmsMessageLog.created_at >= start,
SmsMessageLog.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(SmsMessageLog).where(*filters)) or 0
entries = db.scalars(
select(SmsMessageLog)
.where(*filters)
.order_by(SmsMessageLog.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).all()
items = [_serialize_sms_log(entry) for entry in entries]
else:
filters = [
OtpMessageLog.user_id == user.id,
OtpMessageLog.created_at >= start,
OtpMessageLog.created_at <= end,
]
total = db.scalar(select(func.count()).select_from(OtpMessageLog).where(*filters)) or 0
entries = db.scalars(
select(OtpMessageLog)
.where(*filters)
.order_by(OtpMessageLog.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
).all()
items = [_serialize_otp_log(entry) for entry in entries]
total_pages = ceil(total / PAGE_SIZE) if total else 0
return {
"type": normalized_type,
"page": page,
"page_size": PAGE_SIZE,
"total": total,
"total_pages": total_pages,
"from": start,
"to": end,
"items": items,
}