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