Yves Gugger 3485668b5e
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
feat: add Alpha Terminal HUNT/CFO modules and Analyze framework
Adds HUNT (Sniper/Trend/Forge), CFO dashboard (burn rate + kill list), and a plugin-based Analyze side panel with caching and SSRF hardening.
2025-12-15 16:15:58 +01:00

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))