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, }