pounce/backend/app/api/portfolio.py
yves.gugger e3234e660e fix: Backend deps & transparent valuation schema
FIXED:
- Added get_current_user_optional to deps.py for mixed auth endpoints
- Added OptionalUser type alias for cleaner annotations
- Expanded ValuationResponse schema with full transparency:
  - ValuationScores (length, tld, keyword, brandability, overall)
  - ValuationFactors (length, tld, has_numbers, has_hyphens, etc.)
  - ValuationCalculation (base_value, all factors with reasons, formula)
  - RegistrationContext (tld_cost, value_to_cost_ratio)
  - Added disclaimer field
- Increased base_value from $10 to $50 for realistic valuations

VALUATION EXAMPLES:
- crypto.ai: $375 (was $70)
- x.com: $850 (1-letter premium)
- generic-domain-name.info: $5 (long with hyphens)

All API endpoints tested and working.
2025-12-08 13:51:09 +01:00

590 lines
18 KiB
Python

"""Portfolio API routes."""
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.portfolio import PortfolioDomain, DomainValuation
from app.services.valuation import valuation_service
router = APIRouter()
# ============== Schemas ==============
class PortfolioDomainCreate(BaseModel):
"""Schema for creating a portfolio domain."""
domain: str = Field(..., min_length=3, max_length=255)
purchase_date: Optional[datetime] = None
purchase_price: Optional[float] = Field(None, ge=0)
purchase_registrar: Optional[str] = None
registrar: Optional[str] = None
renewal_date: Optional[datetime] = None
renewal_cost: Optional[float] = Field(None, ge=0)
auto_renew: bool = True
notes: Optional[str] = None
tags: Optional[str] = None
class PortfolioDomainUpdate(BaseModel):
"""Schema for updating a portfolio domain."""
purchase_date: Optional[datetime] = None
purchase_price: Optional[float] = Field(None, ge=0)
purchase_registrar: Optional[str] = None
registrar: Optional[str] = None
renewal_date: Optional[datetime] = None
renewal_cost: Optional[float] = Field(None, ge=0)
auto_renew: Optional[bool] = None
status: Optional[str] = None
notes: Optional[str] = None
tags: Optional[str] = None
class PortfolioDomainSell(BaseModel):
"""Schema for marking a domain as sold."""
sale_date: datetime
sale_price: float = Field(..., ge=0)
class PortfolioDomainResponse(BaseModel):
"""Response schema for portfolio domain."""
id: int
domain: str
purchase_date: Optional[datetime]
purchase_price: Optional[float]
purchase_registrar: Optional[str]
registrar: Optional[str]
renewal_date: Optional[datetime]
renewal_cost: Optional[float]
auto_renew: bool
estimated_value: Optional[float]
value_updated_at: Optional[datetime]
is_sold: bool
sale_date: Optional[datetime]
sale_price: Optional[float]
status: str
notes: Optional[str]
tags: Optional[str]
roi: Optional[float]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PortfolioSummary(BaseModel):
"""Summary of user's portfolio."""
total_domains: int
active_domains: int
sold_domains: int
total_invested: float
total_value: float
total_sold_value: float
unrealized_profit: float
realized_profit: float
overall_roi: float
class ValuationScores(BaseModel):
"""Domain valuation scores breakdown."""
length: int
tld: int
keyword: int
brandability: int
overall: int
class ValuationFactors(BaseModel):
"""Domain valuation factors."""
length: int
tld: str
has_numbers: bool
has_hyphens: bool
is_dictionary_word: bool
detected_keywords: List[str] = []
class ValuationCalculation(BaseModel):
"""Transparent calculation breakdown."""
base_value: float
length_factor: float
length_reason: str
tld_factor: float
tld_reason: str
keyword_factor: float
keyword_reason: str
brand_factor: float
brand_reason: str
formula: str
raw_result: float
class RegistrationContext(BaseModel):
"""TLD registration cost context."""
tld_cost: Optional[float] = None
value_to_cost_ratio: Optional[float] = None
class ValuationResponse(BaseModel):
"""Response schema for domain valuation - fully transparent."""
domain: str
estimated_value: float
currency: str
confidence: str
# Detailed breakdowns
scores: ValuationScores
factors: ValuationFactors
calculation: ValuationCalculation
registration_context: RegistrationContext
# Metadata
source: str
calculated_at: str
disclaimer: str
# ============== Portfolio Endpoints ==============
@router.get("", response_model=List[PortfolioDomainResponse])
async def get_portfolio(
status: Optional[str] = Query(None, description="Filter by status"),
sort_by: str = Query("created_at", description="Sort field"),
sort_order: str = Query("desc", description="Sort order (asc/desc)"),
limit: int = Query(100, le=500),
offset: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get user's portfolio domains."""
query = select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
# Filter by status
if status:
query = query.where(PortfolioDomain.status == status)
# Sorting
sort_column = getattr(PortfolioDomain, sort_by, PortfolioDomain.created_at)
if sort_order == "asc":
query = query.order_by(sort_column.asc())
else:
query = query.order_by(sort_column.desc())
# Pagination
query = query.offset(offset).limit(limit)
result = await db.execute(query)
domains = result.scalars().all()
# Calculate ROI for each domain
responses = []
for d in domains:
response = PortfolioDomainResponse(
id=d.id,
domain=d.domain,
purchase_date=d.purchase_date,
purchase_price=d.purchase_price,
purchase_registrar=d.purchase_registrar,
registrar=d.registrar,
renewal_date=d.renewal_date,
renewal_cost=d.renewal_cost,
auto_renew=d.auto_renew,
estimated_value=d.estimated_value,
value_updated_at=d.value_updated_at,
is_sold=d.is_sold,
sale_date=d.sale_date,
sale_price=d.sale_price,
status=d.status,
notes=d.notes,
tags=d.tags,
roi=d.roi,
created_at=d.created_at,
updated_at=d.updated_at,
)
responses.append(response)
return responses
@router.get("/summary", response_model=PortfolioSummary)
async def get_portfolio_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get portfolio summary statistics."""
result = await db.execute(
select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
)
domains = result.scalars().all()
total_domains = len(domains)
active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold)
sold_domains = sum(1 for d in domains if d.is_sold)
total_invested = sum(d.purchase_price or 0 for d in domains)
total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold)
total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold)
# Calculate active investment for ROI
active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold)
sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold)
unrealized_profit = total_value - active_investment
realized_profit = total_sold_value - sold_investment
overall_roi = 0.0
if total_invested > 0:
overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100
return PortfolioSummary(
total_domains=total_domains,
active_domains=active_domains,
sold_domains=sold_domains,
total_invested=round(total_invested, 2),
total_value=round(total_value, 2),
total_sold_value=round(total_sold_value, 2),
unrealized_profit=round(unrealized_profit, 2),
realized_profit=round(realized_profit, 2),
overall_roi=round(overall_roi, 2),
)
@router.post("", response_model=PortfolioDomainResponse, status_code=status.HTTP_201_CREATED)
async def add_portfolio_domain(
data: PortfolioDomainCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Add a domain to portfolio."""
# Check if domain already exists in user's portfolio
existing = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.user_id == current_user.id,
PortfolioDomain.domain == data.domain.lower(),
)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Domain already in portfolio",
)
# Get initial valuation
valuation = await valuation_service.estimate_value(data.domain, db, save_result=True)
estimated_value = valuation.get("estimated_value") if "error" not in valuation else None
# Create portfolio entry
domain = PortfolioDomain(
user_id=current_user.id,
domain=data.domain.lower(),
purchase_date=data.purchase_date,
purchase_price=data.purchase_price,
purchase_registrar=data.purchase_registrar,
registrar=data.registrar or data.purchase_registrar,
renewal_date=data.renewal_date,
renewal_cost=data.renewal_cost,
auto_renew=data.auto_renew,
estimated_value=estimated_value,
value_updated_at=datetime.utcnow() if estimated_value else None,
notes=data.notes,
tags=data.tags,
)
db.add(domain)
await db.commit()
await db.refresh(domain)
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.get("/{domain_id}", response_model=PortfolioDomainResponse)
async def get_portfolio_domain(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a specific portfolio domain."""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.put("/{domain_id}", response_model=PortfolioDomainResponse)
async def update_portfolio_domain(
domain_id: int,
data: PortfolioDomainUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update a portfolio domain."""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
# Update fields
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(domain, field, value)
await db.commit()
await db.refresh(domain)
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.post("/{domain_id}/sell", response_model=PortfolioDomainResponse)
async def mark_domain_sold(
domain_id: int,
data: PortfolioDomainSell,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Mark a domain as sold."""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
domain.is_sold = True
domain.sale_date = data.sale_date
domain.sale_price = data.sale_price
domain.status = "sold"
await db.commit()
await db.refresh(domain)
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_portfolio_domain(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Delete a domain from portfolio."""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
await db.delete(domain)
await db.commit()
@router.post("/{domain_id}/refresh-value", response_model=PortfolioDomainResponse)
async def refresh_domain_value(
domain_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Refresh the estimated value of a portfolio domain."""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.id == domain_id,
PortfolioDomain.user_id == current_user.id,
)
)
)
domain = result.scalar_one_or_none()
if not domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found in portfolio",
)
# Get new valuation
valuation = await valuation_service.estimate_value(domain.domain, db, save_result=True)
if "error" not in valuation:
domain.estimated_value = valuation["estimated_value"]
domain.value_updated_at = datetime.utcnow()
await db.commit()
await db.refresh(domain)
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
created_at=domain.created_at,
updated_at=domain.updated_at,
)
# ============== Valuation Endpoints ==============
@router.get("/valuation/{domain}", response_model=ValuationResponse)
async def get_domain_valuation(
domain: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get estimated value for any domain."""
valuation = await valuation_service.estimate_value(domain, db, save_result=True)
if "error" in valuation:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=valuation["error"],
)
return ValuationResponse(**valuation)