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