pounce/backend/app/routes/portfolio.py
yves.gugger b5c456af1c
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
refactor: Rename Intel to Discover and apply Landing Page style
- Renamed /intel to /discover
- Updated styles to match dark/cinematic landing page theme
- Updated Header, Footer, and Sitemap
- Added redirects from /intel and /tld-pricing to /discover
- Optimized SEO metadata for new paths
2025-12-12 16:35:34 +01:00

543 lines
17 KiB
Python
Executable File

"""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.routes.auth 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(prefix="/portfolio", tags=["portfolio"])
# ============== 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 ValuationResponse(BaseModel):
"""Response schema for domain valuation."""
domain: str
estimated_value: float
currency: str
scores: dict
factors: dict
confidence: str
source: str
calculated_at: 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)