pounce/backend/app/api/yield_domains.py
yves.gugger dc12f14638
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
fix: resolve indentation and import errors in backend
- Fix indentation in main.py (scheduler if/else blocks)
- Fix indentation in deps.py (credentials check)
- Fix indentation in auctions.py (filter blocks)
- Add BackgroundTasks import to admin.py
- Fix settings import in yield_domains.py (use get_settings())
2025-12-12 15:06:47 +01:00

640 lines
21 KiB
Python

"""
Yield Domain API endpoints.
Manages domain activation for yield/intent routing and revenue tracking.
"""
import json
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy import func, and_, or_
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.models.user import User
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
from app.config import get_settings
settings = get_settings()
from app.schemas.yield_domain import (
YieldDomainCreate,
YieldDomainUpdate,
YieldDomainResponse,
YieldDomainListResponse,
YieldTransactionResponse,
YieldTransactionListResponse,
YieldPayoutResponse,
YieldPayoutListResponse,
YieldDashboardStats,
YieldDashboardResponse,
DomainYieldAnalysis,
IntentAnalysis,
YieldValueEstimate,
AffiliatePartnerResponse,
DNSVerificationResult,
DNSSetupInstructions,
ActivateYieldRequest,
ActivateYieldResponse,
)
from app.services.intent_detector import (
detect_domain_intent,
estimate_domain_yield,
get_intent_detector,
)
router = APIRouter(prefix="/yield", tags=["yield"])
# DNS Configuration (would be in config in production)
YIELD_NAMESERVERS = ["ns1.pounce.io", "ns2.pounce.io"]
YIELD_CNAME_TARGET = "yield.pounce.io"
# ============================================================================
# Intent Analysis (Public)
# ============================================================================
@router.post("/analyze", response_model=DomainYieldAnalysis)
async def analyze_domain_intent(
domain: str = Query(..., min_length=3, description="Domain to analyze"),
):
"""
Analyze a domain's intent and estimate yield potential.
This endpoint is public - no authentication required.
"""
analysis = estimate_domain_yield(domain)
intent_result = detect_domain_intent(domain)
return DomainYieldAnalysis(
domain=domain,
intent=IntentAnalysis(
category=intent_result.category,
subcategory=intent_result.subcategory,
confidence=intent_result.confidence,
keywords_matched=intent_result.keywords_matched,
suggested_partners=intent_result.suggested_partners,
monetization_potential=intent_result.monetization_potential,
),
value=YieldValueEstimate(
estimated_monthly_min=analysis["value"]["estimated_monthly_min"],
estimated_monthly_max=analysis["value"]["estimated_monthly_max"],
currency=analysis["value"]["currency"],
potential=analysis["value"]["potential"],
confidence=analysis["value"]["confidence"],
geo=analysis["value"]["geo"],
),
partners=analysis["partners"],
monetization_potential=analysis["monetization_potential"],
)
# ============================================================================
# Dashboard
# ============================================================================
@router.get("/dashboard", response_model=YieldDashboardResponse)
async def get_yield_dashboard(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get yield dashboard with stats, domains, and recent transactions.
"""
# Get user's yield domains
domains = db.query(YieldDomain).filter(
YieldDomain.user_id == current_user.id
).order_by(YieldDomain.total_revenue.desc()).all()
# Calculate stats
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# Monthly stats from transactions
monthly_stats = db.query(
func.count(YieldTransaction.id).label("count"),
func.sum(YieldTransaction.net_amount).label("revenue"),
func.sum(func.cast(YieldTransaction.event_type == "click", Integer)).label("clicks"),
func.sum(func.cast(YieldTransaction.event_type.in_(["lead", "sale"]), Integer)).label("conversions"),
).join(YieldDomain).filter(
YieldDomain.user_id == current_user.id,
YieldTransaction.created_at >= month_start,
).first()
# Aggregate domain stats
total_active = sum(1 for d in domains if d.status == "active")
total_pending = sum(1 for d in domains if d.status in ["pending", "verifying"])
lifetime_revenue = sum(d.total_revenue for d in domains)
lifetime_clicks = sum(d.total_clicks for d in domains)
lifetime_conversions = sum(d.total_conversions for d in domains)
# Pending payout
pending_payout = db.query(func.sum(YieldTransaction.net_amount)).filter(
YieldTransaction.yield_domain_id.in_([d.id for d in domains]),
YieldTransaction.status == "confirmed",
YieldTransaction.paid_at.is_(None),
).scalar() or Decimal("0")
# Get recent transactions
recent_txs = db.query(YieldTransaction).join(YieldDomain).filter(
YieldDomain.user_id == current_user.id,
).order_by(YieldTransaction.created_at.desc()).limit(10).all()
# Top performing domains
top_domains = sorted(domains, key=lambda d: d.total_revenue, reverse=True)[:5]
stats = YieldDashboardStats(
total_domains=len(domains),
active_domains=total_active,
pending_domains=total_pending,
monthly_revenue=monthly_stats.revenue or Decimal("0"),
monthly_clicks=monthly_stats.clicks or 0,
monthly_conversions=monthly_stats.conversions or 0,
lifetime_revenue=lifetime_revenue,
lifetime_clicks=lifetime_clicks,
lifetime_conversions=lifetime_conversions,
pending_payout=pending_payout,
next_payout_date=month_start + timedelta(days=32), # Approx next month
currency="CHF",
)
return YieldDashboardResponse(
stats=stats,
domains=[_domain_to_response(d) for d in domains],
recent_transactions=[_tx_to_response(tx) for tx in recent_txs],
top_domains=[_domain_to_response(d) for d in top_domains],
)
# ============================================================================
# Domain Management
# ============================================================================
@router.get("/domains", response_model=YieldDomainListResponse)
async def list_yield_domains(
status: Optional[str] = Query(None, description="Filter by status"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List user's yield domains.
"""
query = db.query(YieldDomain).filter(YieldDomain.user_id == current_user.id)
if status:
query = query.filter(YieldDomain.status == status)
total = query.count()
domains = query.order_by(YieldDomain.created_at.desc()).offset(offset).limit(limit).all()
# Aggregates
all_domains = db.query(YieldDomain).filter(YieldDomain.user_id == current_user.id).all()
total_active = sum(1 for d in all_domains if d.status == "active")
total_revenue = sum(d.total_revenue for d in all_domains)
total_clicks = sum(d.total_clicks for d in all_domains)
return YieldDomainListResponse(
domains=[_domain_to_response(d) for d in domains],
total=total,
total_active=total_active,
total_revenue=total_revenue,
total_clicks=total_clicks,
)
@router.get("/domains/{domain_id}", response_model=YieldDomainResponse)
async def get_yield_domain(
domain_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Get details of a specific yield domain.
"""
domain = db.query(YieldDomain).filter(
YieldDomain.id == domain_id,
YieldDomain.user_id == current_user.id,
).first()
if not domain:
raise HTTPException(status_code=404, detail="Yield domain not found")
return _domain_to_response(domain)
@router.post("/activate", response_model=ActivateYieldResponse)
async def activate_domain_for_yield(
request: ActivateYieldRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Activate a domain for yield/intent routing.
This creates the yield domain record and returns DNS setup instructions.
"""
domain = request.domain.lower().strip()
# Check if domain already exists
existing = db.query(YieldDomain).filter(YieldDomain.domain == domain).first()
if existing:
if existing.user_id == current_user.id:
raise HTTPException(
status_code=400,
detail="Domain already activated for yield"
)
else:
raise HTTPException(
status_code=400,
detail="Domain is already registered by another user"
)
# Analyze domain intent
intent_result = detect_domain_intent(domain)
value_estimate = get_intent_detector().estimate_value(domain)
# Create yield domain record
yield_domain = YieldDomain(
user_id=current_user.id,
domain=domain,
detected_intent=f"{intent_result.category}_{intent_result.subcategory}" if intent_result.subcategory else intent_result.category,
intent_confidence=intent_result.confidence,
intent_keywords=json.dumps(intent_result.keywords_matched),
status="pending",
)
# Find best matching partner
if intent_result.suggested_partners:
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.slug == intent_result.suggested_partners[0],
AffiliatePartner.is_active == True,
).first()
if partner:
yield_domain.partner_id = partner.id
yield_domain.active_route = partner.slug
db.add(yield_domain)
db.commit()
db.refresh(yield_domain)
# Create DNS instructions
dns_instructions = DNSSetupInstructions(
domain=domain,
nameservers=YIELD_NAMESERVERS,
cname_host="@",
cname_target=YIELD_CNAME_TARGET,
verification_url=f"{settings.site_url}/api/v1/yield/verify/{yield_domain.id}",
)
return ActivateYieldResponse(
domain_id=yield_domain.id,
domain=domain,
status=yield_domain.status,
intent=IntentAnalysis(
category=intent_result.category,
subcategory=intent_result.subcategory,
confidence=intent_result.confidence,
keywords_matched=intent_result.keywords_matched,
suggested_partners=intent_result.suggested_partners,
monetization_potential=intent_result.monetization_potential,
),
value_estimate=YieldValueEstimate(
estimated_monthly_min=value_estimate["estimated_monthly_min"],
estimated_monthly_max=value_estimate["estimated_monthly_max"],
currency=value_estimate["currency"],
potential=value_estimate["potential"],
confidence=value_estimate["confidence"],
geo=value_estimate["geo"],
),
dns_instructions=dns_instructions,
message="Domain registered! Point your DNS to our nameservers to complete activation.",
)
@router.post("/domains/{domain_id}/verify", response_model=DNSVerificationResult)
async def verify_domain_dns(
domain_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Verify DNS configuration for a yield domain.
"""
domain = db.query(YieldDomain).filter(
YieldDomain.id == domain_id,
YieldDomain.user_id == current_user.id,
).first()
if not domain:
raise HTTPException(status_code=404, detail="Yield domain not found")
# Perform DNS check (simplified - in production use dnspython)
verified = False
actual_ns = []
error = None
try:
import dns.resolver
# Check nameservers
try:
answers = dns.resolver.resolve(domain.domain, 'NS')
actual_ns = [str(rr.target).rstrip('.') for rr in answers]
# Check if our nameservers are set
our_ns_set = set(ns.lower() for ns in YIELD_NAMESERVERS)
actual_ns_set = set(ns.lower() for ns in actual_ns)
if our_ns_set.issubset(actual_ns_set):
verified = True
except dns.resolver.NXDOMAIN:
error = "Domain does not exist"
except dns.resolver.NoAnswer:
# Try CNAME instead
try:
cname_answers = dns.resolver.resolve(domain.domain, 'CNAME')
for rr in cname_answers:
if str(rr.target).rstrip('.').lower() == YIELD_CNAME_TARGET.lower():
verified = True
break
except Exception:
error = "No NS or CNAME records found"
except Exception as e:
error = str(e)
except ImportError:
# dnspython not installed - simulate for development
verified = True # Auto-verify in dev
actual_ns = YIELD_NAMESERVERS
# Update domain status
if verified and not domain.dns_verified:
domain.dns_verified = True
domain.dns_verified_at = datetime.utcnow()
domain.status = "active"
domain.activated_at = datetime.utcnow()
db.commit()
return DNSVerificationResult(
domain=domain.domain,
verified=verified,
expected_ns=YIELD_NAMESERVERS,
actual_ns=actual_ns,
cname_ok=verified and not actual_ns,
error=error,
checked_at=datetime.utcnow(),
)
@router.patch("/domains/{domain_id}", response_model=YieldDomainResponse)
async def update_yield_domain(
domain_id: int,
update: YieldDomainUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Update yield domain settings.
"""
domain = db.query(YieldDomain).filter(
YieldDomain.id == domain_id,
YieldDomain.user_id == current_user.id,
).first()
if not domain:
raise HTTPException(status_code=404, detail="Yield domain not found")
# Apply updates
if update.active_route is not None:
# Validate partner exists
partner = db.query(AffiliatePartner).filter(
AffiliatePartner.slug == update.active_route,
AffiliatePartner.is_active == True,
).first()
if not partner:
raise HTTPException(status_code=400, detail="Invalid partner route")
domain.active_route = update.active_route
domain.partner_id = partner.id
if update.landing_page_url is not None:
domain.landing_page_url = update.landing_page_url
if update.status is not None:
if update.status == "paused":
domain.status = "paused"
domain.paused_at = datetime.utcnow()
elif update.status == "active" and domain.dns_verified:
domain.status = "active"
domain.paused_at = None
db.commit()
db.refresh(domain)
return _domain_to_response(domain)
@router.delete("/domains/{domain_id}")
async def delete_yield_domain(
domain_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Remove a domain from yield program.
"""
domain = db.query(YieldDomain).filter(
YieldDomain.id == domain_id,
YieldDomain.user_id == current_user.id,
).first()
if not domain:
raise HTTPException(status_code=404, detail="Yield domain not found")
db.delete(domain)
db.commit()
return {"message": "Yield domain removed"}
# ============================================================================
# Transactions
# ============================================================================
@router.get("/transactions", response_model=YieldTransactionListResponse)
async def list_transactions(
domain_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List yield transactions for user's domains.
"""
# Get user's domain IDs
domain_ids = db.query(YieldDomain.id).filter(
YieldDomain.user_id == current_user.id
).subquery()
query = db.query(YieldTransaction).filter(
YieldTransaction.yield_domain_id.in_(domain_ids)
)
if domain_id:
query = query.filter(YieldTransaction.yield_domain_id == domain_id)
if status:
query = query.filter(YieldTransaction.status == status)
total = query.count()
transactions = query.order_by(YieldTransaction.created_at.desc()).offset(offset).limit(limit).all()
# Aggregates
total_gross = sum(tx.gross_amount for tx in transactions)
total_net = sum(tx.net_amount for tx in transactions)
return YieldTransactionListResponse(
transactions=[_tx_to_response(tx) for tx in transactions],
total=total,
total_gross=total_gross,
total_net=total_net,
)
# ============================================================================
# Payouts
# ============================================================================
@router.get("/payouts", response_model=YieldPayoutListResponse)
async def list_payouts(
status: Optional[str] = Query(None),
limit: int = Query(20, le=50),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
List user's yield payouts.
"""
query = db.query(YieldPayout).filter(YieldPayout.user_id == current_user.id)
if status:
query = query.filter(YieldPayout.status == status)
total = query.count()
payouts = query.order_by(YieldPayout.created_at.desc()).offset(offset).limit(limit).all()
# Aggregates
total_paid = sum(p.amount for p in payouts if p.status == "completed")
total_pending = sum(p.amount for p in payouts if p.status in ["pending", "processing"])
return YieldPayoutListResponse(
payouts=[_payout_to_response(p) for p in payouts],
total=total,
total_paid=total_paid,
total_pending=total_pending,
)
# ============================================================================
# Partners (Public info)
# ============================================================================
@router.get("/partners", response_model=list[AffiliatePartnerResponse])
async def list_partners(
category: Optional[str] = Query(None, description="Filter by intent category"),
db: Session = Depends(get_db),
):
"""
List available affiliate partners.
"""
query = db.query(AffiliatePartner).filter(AffiliatePartner.is_active == True)
partners = query.order_by(AffiliatePartner.priority.desc()).all()
# Filter by category if specified
if category:
partners = [p for p in partners if category in p.intent_list]
return [
AffiliatePartnerResponse(
slug=p.slug,
name=p.name,
network=p.network,
intent_categories=p.intent_list,
geo_countries=p.country_list,
payout_type=p.payout_type,
description=p.description,
logo_url=p.logo_url,
)
for p in partners
]
# ============================================================================
# Helpers
# ============================================================================
def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
"""Convert YieldDomain model to response schema."""
return YieldDomainResponse(
id=domain.id,
domain=domain.domain,
status=domain.status,
detected_intent=domain.detected_intent,
intent_confidence=domain.intent_confidence,
active_route=domain.active_route,
partner_name=domain.partner.name if domain.partner else None,
dns_verified=domain.dns_verified,
dns_verified_at=domain.dns_verified_at,
total_clicks=domain.total_clicks,
total_conversions=domain.total_conversions,
total_revenue=domain.total_revenue,
currency=domain.currency,
activated_at=domain.activated_at,
created_at=domain.created_at,
)
def _tx_to_response(tx: YieldTransaction) -> YieldTransactionResponse:
"""Convert YieldTransaction model to response schema."""
return YieldTransactionResponse(
id=tx.id,
event_type=tx.event_type,
partner_slug=tx.partner_slug,
gross_amount=tx.gross_amount,
net_amount=tx.net_amount,
currency=tx.currency,
status=tx.status,
geo_country=tx.geo_country,
created_at=tx.created_at,
confirmed_at=tx.confirmed_at,
)
def _payout_to_response(payout: YieldPayout) -> YieldPayoutResponse:
"""Convert YieldPayout model to response schema."""
return YieldPayoutResponse(
id=payout.id,
amount=payout.amount,
currency=payout.currency,
period_start=payout.period_start,
period_end=payout.period_end,
transaction_count=payout.transaction_count,
status=payout.status,
payment_method=payout.payment_method,
payment_reference=payout.payment_reference,
created_at=payout.created_at,
completed_at=payout.completed_at,
)
# Missing import
from sqlalchemy import Integer