Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening.
198 lines
6.9 KiB
Python
198 lines
6.9 KiB
Python
"""CFO (Management) endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
from sqlalchemy import and_, case, func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_user
|
|
from app.database import get_db
|
|
from app.models.portfolio import PortfolioDomain
|
|
from app.models.user import User
|
|
from app.models.yield_domain import YieldDomain, YieldTransaction
|
|
from app.schemas.cfo import (
|
|
CfoKillListRow,
|
|
CfoMonthlyBucket,
|
|
CfoSummaryResponse,
|
|
CfoUpcomingCostRow,
|
|
SetToDropResponse,
|
|
)
|
|
from app.services.analyze.renewal_cost import get_tld_price_snapshot
|
|
|
|
router = APIRouter()
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
|
|
def _utcnow() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _month_key(dt: datetime) -> str:
|
|
return f"{dt.year:04d}-{dt.month:02d}"
|
|
|
|
|
|
async def _estimate_renewal_cost_usd(db: AsyncSession, domain: str) -> tuple[float | None, str]:
|
|
# If the user stored renewal_cost, we treat it as the source of truth.
|
|
# Else we estimate using our own collected `tld_prices` DB.
|
|
tld = domain.split(".")[-1].lower()
|
|
snap = await get_tld_price_snapshot(db, tld)
|
|
if snap.min_renew_usd is None:
|
|
return None, "unknown"
|
|
return float(snap.min_renew_usd), "tld_prices"
|
|
|
|
|
|
@router.get("/summary", response_model=CfoSummaryResponse)
|
|
@limiter.limit("30/minute")
|
|
async def cfo_summary(
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
CFO dashboard summary:
|
|
- Burn rate timeline (renewal costs)
|
|
- Upcoming costs (30d)
|
|
- Kill list (renewal soon + no yield signals)
|
|
"""
|
|
now = _utcnow()
|
|
now_naive = now.replace(tzinfo=None)
|
|
|
|
domains = (
|
|
await db.execute(select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id))
|
|
).scalars().all()
|
|
|
|
# Yield stats (last 60d) by domain
|
|
since_60d = now_naive - timedelta(days=60)
|
|
yd_rows = (
|
|
await db.execute(
|
|
select(
|
|
YieldDomain.domain,
|
|
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("net_sum"),
|
|
func.coalesce(func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)), 0).label("clicks"),
|
|
)
|
|
.join(
|
|
YieldTransaction,
|
|
and_(YieldTransaction.yield_domain_id == YieldDomain.id, YieldTransaction.created_at >= since_60d),
|
|
isouter=True,
|
|
)
|
|
.where(YieldDomain.user_id == current_user.id)
|
|
.group_by(YieldDomain.domain)
|
|
)
|
|
).all()
|
|
yield_by_domain = {str(d).lower(): {"net": float(n or 0), "clicks": int(c or 0)} for d, n, c in yd_rows}
|
|
|
|
# Monthly buckets next 12 months
|
|
buckets: dict[str, CfoMonthlyBucket] = {}
|
|
for i in range(0, 12):
|
|
d = (now + timedelta(days=30 * i)).replace(day=1)
|
|
buckets[_month_key(d)] = CfoMonthlyBucket(month=_month_key(d), total_cost_usd=0.0, domains=0)
|
|
|
|
upcoming_rows: list[CfoUpcomingCostRow] = []
|
|
kill_list: list[CfoKillListRow] = []
|
|
|
|
cutoff_30d = now_naive + timedelta(days=30)
|
|
|
|
for pd in domains:
|
|
if pd.is_sold:
|
|
continue
|
|
|
|
renewal_dt = pd.renewal_date
|
|
if not renewal_dt:
|
|
continue
|
|
|
|
if renewal_dt.tzinfo is not None:
|
|
renewal_dt_naive = renewal_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
|
else:
|
|
renewal_dt_naive = renewal_dt
|
|
|
|
# cost source: portfolio overrides
|
|
if pd.renewal_cost is not None:
|
|
cost = float(pd.renewal_cost)
|
|
source = "portfolio"
|
|
else:
|
|
cost, source = await _estimate_renewal_cost_usd(db, pd.domain)
|
|
|
|
# Monthly burn timeline
|
|
month = _month_key(renewal_dt_naive)
|
|
if month not in buckets:
|
|
buckets[month] = CfoMonthlyBucket(month=month, total_cost_usd=0.0, domains=0)
|
|
if cost is not None:
|
|
buckets[month].total_cost_usd = float(buckets[month].total_cost_usd) + float(cost)
|
|
buckets[month].domains = int(buckets[month].domains) + 1
|
|
|
|
# Upcoming 30d
|
|
if now_naive <= renewal_dt_naive <= cutoff_30d:
|
|
upcoming_rows.append(
|
|
CfoUpcomingCostRow(
|
|
domain_id=pd.id,
|
|
domain=pd.domain,
|
|
renewal_date=renewal_dt,
|
|
renewal_cost_usd=cost,
|
|
cost_source=source,
|
|
is_sold=bool(pd.is_sold),
|
|
)
|
|
)
|
|
|
|
y = yield_by_domain.get(pd.domain.lower(), {"net": 0.0, "clicks": 0})
|
|
if float(y["net"]) <= 0.0 and int(y["clicks"]) <= 0:
|
|
kill_list.append(
|
|
CfoKillListRow(
|
|
domain_id=pd.id,
|
|
domain=pd.domain,
|
|
renewal_date=renewal_dt,
|
|
renewal_cost_usd=cost,
|
|
cost_source=source,
|
|
auto_renew=bool(pd.auto_renew),
|
|
is_dns_verified=bool(getattr(pd, "is_dns_verified", False) or False),
|
|
yield_net_60d=float(y["net"]),
|
|
yield_clicks_60d=int(y["clicks"]),
|
|
reason="No yield signals tracked in the last 60 days and renewal is due within 30 days.",
|
|
)
|
|
)
|
|
|
|
# Sort rows
|
|
upcoming_rows.sort(key=lambda r: (r.renewal_date or now_naive))
|
|
kill_list.sort(key=lambda r: (r.renewal_date or now_naive))
|
|
|
|
upcoming_total = sum((r.renewal_cost_usd or 0) for r in upcoming_rows)
|
|
monthly_sorted = [buckets[k] for k in sorted(buckets.keys())]
|
|
|
|
return CfoSummaryResponse(
|
|
computed_at=now,
|
|
upcoming_30d_total_usd=float(round(upcoming_total, 2)),
|
|
upcoming_30d_rows=upcoming_rows,
|
|
monthly=monthly_sorted,
|
|
kill_list=kill_list[:50],
|
|
)
|
|
|
|
|
|
@router.post("/domains/{domain_id}/set-to-drop", response_model=SetToDropResponse)
|
|
@limiter.limit("30/minute")
|
|
async def set_to_drop(
|
|
request: Request,
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Mark portfolio domain as 'to drop' by turning off local auto-renew flag.
|
|
(We cannot disable auto-renew at the registrar automatically.)
|
|
"""
|
|
pd = (
|
|
await db.execute(
|
|
select(PortfolioDomain).where(and_(PortfolioDomain.id == domain_id, PortfolioDomain.user_id == current_user.id))
|
|
)
|
|
).scalar_one_or_none()
|
|
if not pd:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Portfolio domain not found")
|
|
pd.auto_renew = False
|
|
pd.updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
return SetToDropResponse(domain_id=pd.id, auto_renew=bool(pd.auto_renew), updated_at=pd.updated_at.replace(tzinfo=timezone.utc))
|
|
|