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
- 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
543 lines
17 KiB
Python
Executable File
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)
|
|
|