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)