192 lines
6.3 KiB
Python
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,
|
|
}
|