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

518 lines
17 KiB
Python

from datetime import datetime, timezone
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from .auth import ADMIN_COOKIE_NAME, create_admin_token, current_admin
from .config import settings
from .database import get_db
from .dlt_service import refresh_dlt_application_status, serialize_dlt
from .kyc_service import refresh_application_status, serialize_kyc
from .routes_messaging import serialize_sender, serialize_template
from .models import (
DltApplication,
DltDocument,
KycApplication,
KycDocument,
PhoneNumber,
SenderConfig,
TemplateConfig,
UrmiOtpProject,
UserAccount,
)
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
from .routes_urmiotp import serialize_project
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
class AdminLoginRequest(BaseModel):
username: str
password: str
class DocumentReviewRequest(BaseModel):
status: str = Field(pattern="^(approved|rejected)$")
review_note: str | None = Field(default=None, max_length=500)
class ProjectReviewRequest(BaseModel):
status: str = Field(pattern="^(approved|rejected)$")
review_note: str | None = Field(default=None, max_length=500)
class CreatePhoneNumberRequest(BaseModel):
phone_number: str = Field(min_length=10, max_length=10)
monthly_number_price: float = Field(ge=0)
inbound_price: float = Field(ge=0)
outbound_price: float = Field(ge=0)
user_email: str | None = Field(default=None, max_length=320)
class UpdatePhoneNumberRequest(BaseModel):
monthly_number_price: float | None = Field(default=None, ge=0)
inbound_price: float | None = Field(default=None, ge=0)
outbound_price: float | None = Field(default=None, ge=0)
user_email: str | None = Field(default=None, max_length=320)
release: bool = False
def set_admin_session(response: Response) -> None:
response.set_cookie(
ADMIN_COOKIE_NAME,
create_admin_token(settings.admin_username),
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
max_age=settings.access_token_minutes * 60,
path="/",
)
@router.post("/login")
def admin_login(payload: AdminLoginRequest, response: Response) -> dict[str, str]:
if payload.username != settings.admin_username or payload.password != settings.admin_password:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin credentials")
set_admin_session(response)
return {"username": settings.admin_username}
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def admin_logout(response: Response) -> None:
response.delete_cookie(ADMIN_COOKIE_NAME, path="/")
@router.get("/me")
def admin_me(username: str = Depends(current_admin)) -> dict[str, str]:
return {"username": username}
@router.get("/compliance/kyc")
def list_kyc_applications(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
applications = db.scalars(
select(KycApplication)
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
.order_by(KycApplication.updated_at.desc())
).unique().all()
return [
{
**serialize_kyc(app),
"user": {
"id": str(app.user.id),
"name": app.user.display_name,
"email": app.user.email,
},
}
for app in applications
if app.status != "not_started" or app.pan_number or any(doc.status != "missing" for doc in app.documents)
]
@router.patch("/compliance/kyc/{application_id}/documents/{document_id}")
def review_document(
application_id: UUID,
document_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
application = db.scalar(
select(KycApplication)
.options(joinedload(KycApplication.user), joinedload(KycApplication.documents))
.where(KycApplication.id == application_id)
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="KYC application not found")
document = db.get(KycDocument, document_id)
if not document or document.application_id != application.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
if document.status == "missing":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
document.status = payload.status
document.review_note = payload.review_note
document.reviewed_at = datetime.now(timezone.utc)
refresh_application_status(application)
db.commit()
db.refresh(application)
return {
**serialize_kyc(application),
"user": {
"id": str(application.user.id),
"name": application.user.display_name,
"email": application.user.email,
},
}
@router.get("/compliance/dlt")
def list_dlt_applications(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
applications = db.scalars(
select(DltApplication)
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
.order_by(DltApplication.updated_at.desc())
).unique().all()
return [
{
**serialize_dlt(app),
"user": {
"id": str(app.user.id),
"name": app.user.display_name,
"email": app.user.email,
},
}
for app in applications
if app.status != "not_started" or any(doc.status != "missing" for doc in app.documents)
]
@router.patch("/compliance/dlt/{application_id}/documents/{document_id}")
def review_dlt_document(
application_id: UUID,
document_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
application = db.scalar(
select(DltApplication)
.options(joinedload(DltApplication.user), joinedload(DltApplication.documents))
.where(DltApplication.id == application_id)
)
if not application:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DLT application not found")
document = db.get(DltDocument, document_id)
if not document or document.application_id != application.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
if document.status == "missing":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Document not uploaded yet")
document.status = payload.status
document.review_note = payload.review_note
document.reviewed_at = datetime.now(timezone.utc)
refresh_dlt_application_status(application)
db.commit()
db.refresh(application)
return {
**serialize_dlt(application),
"user": {
"id": str(application.user.id),
"name": application.user.display_name,
"email": application.user.email,
},
}
@router.get("/compliance/senders")
def list_sender_configs(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
senders = db.scalars(
select(SenderConfig)
.options(joinedload(SenderConfig.user))
.order_by(SenderConfig.created_at.desc())
).unique().all()
return [
{
**serialize_sender(sender),
"user": {
"id": str(sender.user.id),
"name": sender.user.display_name,
"email": sender.user.email,
},
}
for sender in senders
]
@router.patch("/compliance/senders/{sender_id}")
def review_sender(
sender_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
sender = db.scalar(
select(SenderConfig)
.options(joinedload(SenderConfig.user))
.where(SenderConfig.id == sender_id)
)
if not sender:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sender config not found")
if sender.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Sender already reviewed")
sender.status = payload.status
sender.review_note = payload.review_note
sender.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(sender)
return {
**serialize_sender(sender),
"user": {
"id": str(sender.user.id),
"name": sender.user.display_name,
"email": sender.user.email,
},
}
@router.get("/compliance/templates")
def list_template_configs(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
templates = db.scalars(
select(TemplateConfig)
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
.order_by(TemplateConfig.created_at.desc())
).unique().all()
return [
{
**serialize_template(template),
"user": {
"id": str(template.user.id),
"name": template.user.display_name,
"email": template.user.email,
},
}
for template in templates
]
@router.patch("/compliance/templates/{template_id}")
def review_template(
template_id: UUID,
payload: DocumentReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
template = db.scalar(
select(TemplateConfig)
.options(joinedload(TemplateConfig.user), joinedload(TemplateConfig.sender_config))
.where(TemplateConfig.id == template_id)
)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template config not found")
if template.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Template already reviewed")
template.status = payload.status
template.review_note = payload.review_note
template.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(template)
return {
**serialize_template(template),
"user": {
"id": str(template.user.id),
"name": template.user.display_name,
"email": template.user.email,
},
}
@router.get("/compliance/urmiotp-projects")
def list_urmiotp_projects(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
projects = db.scalars(
select(UrmiOtpProject)
.options(joinedload(UrmiOtpProject.user))
.order_by(UrmiOtpProject.created_at.desc())
).all()
return [
{
**serialize_project(project),
"user": {
"id": str(project.user.id),
"name": project.user.display_name,
"email": project.user.email,
},
}
for project in projects
]
@router.patch("/compliance/urmiotp-projects/{project_id}")
def review_urmiotp_project(
project_id: UUID,
payload: ProjectReviewRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
project = db.scalar(
select(UrmiOtpProject)
.options(joinedload(UrmiOtpProject.user))
.where(UrmiOtpProject.id == project_id)
)
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="UrmiOTP project not found")
if project.status != "pending":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Project already reviewed")
project.status = payload.status
project.review_note = payload.review_note
project.reviewed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(project)
return {
**serialize_project(project),
"user": {
"id": str(project.user.id),
"name": project.user.display_name,
"email": project.user.email,
},
}
def serialize_admin_phone_number(number: PhoneNumber) -> dict:
return {
"id": str(number.id),
"phone_number": number.phone_number,
"monthly_number_price": float(number.monthly_number_price),
"inbound_price": float(number.inbound_price),
"outbound_price": float(number.outbound_price),
"currency": number.currency,
"status": number.status,
"acquisition_type": number.acquisition_type,
"assigned_at": number.assigned_at,
"created_at": number.created_at,
"user": (
{
"id": str(number.user.id),
"name": number.user.display_name,
"email": number.user.email,
}
if number.user
else None
),
}
@router.get("/phone-numbers")
def list_phone_numbers(
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> list[dict]:
numbers = db.scalars(
select(PhoneNumber)
.options(joinedload(PhoneNumber.user))
.order_by(PhoneNumber.created_at.desc())
).all()
return [serialize_admin_phone_number(number) for number in numbers]
@router.post("/phone-numbers", status_code=status.HTTP_201_CREATED)
def create_phone_number(
payload: CreatePhoneNumberRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
try:
phone_number = normalize_phone_number(payload.phone_number)
monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(select(PhoneNumber).where(PhoneNumber.phone_number == phone_number))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This phone number already exists")
user = None
if payload.user_email:
user = db.scalar(select(UserAccount).where(UserAccount.email == payload.user_email.strip().lower()))
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No user account found for that email",
)
now = datetime.now(timezone.utc)
number = PhoneNumber(
phone_number=phone_number,
inbound_price=inbound_price,
outbound_price=outbound_price,
monthly_number_price=monthly_number_price,
user_id=user.id if user else None,
status="assigned" if user else "available",
acquisition_type="admin_assigned" if user else None,
assigned_at=now if user else None,
)
db.add(number)
db.commit()
db.refresh(number)
if user:
number.user = user
return serialize_admin_phone_number(number)
@router.patch("/phone-numbers/{number_id}")
def update_phone_number(
number_id: UUID,
payload: UpdatePhoneNumberRequest,
db: Session = Depends(get_db),
_: str = Depends(current_admin),
) -> dict:
number = db.scalar(
select(PhoneNumber)
.options(joinedload(PhoneNumber.user))
.where(PhoneNumber.id == number_id)
)
if not number:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Phone number not found")
if payload.release:
number.user_id = None
number.status = "available"
number.acquisition_type = None
number.assigned_at = None
number.user = None
elif payload.user_email is not None:
email = payload.user_email.strip().lower()
if not email:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="User email cannot be empty",
)
user = db.scalar(select(UserAccount).where(UserAccount.email == email))
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No user account found for that email")
number.user_id = user.id
number.status = "assigned"
number.acquisition_type = "admin_assigned"
number.assigned_at = datetime.now(timezone.utc)
number.user = user
if payload.monthly_number_price is not None:
try:
number.monthly_number_price = validate_price(payload.monthly_number_price, "Number monthly price")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if payload.inbound_price is not None:
try:
number.inbound_price = validate_per_min_price(payload.inbound_price, "Inbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
if payload.outbound_price is not None:
try:
number.outbound_price = validate_per_min_price(payload.outbound_price, "Outbound price per min")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
db.commit()
db.refresh(number)
return serialize_admin_phone_number(number)