""" Price Alert API endpoints. Allows users to subscribe to TLD price notifications. Endpoints: - GET /price-alerts - List user's price alerts - POST /price-alerts - Create new price alert - GET /price-alerts/{tld} - Get alert for specific TLD - PUT /price-alerts/{tld} - Update alert settings - DELETE /price-alerts/{tld} - Delete alert """ import logging from datetime import datetime from typing import Optional, List from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select, delete from app.api.deps import Database, CurrentUser, CurrentUserOptional from app.models.price_alert import PriceAlert logger = logging.getLogger(__name__) router = APIRouter() # ============== Schemas ============== class PriceAlertCreate(BaseModel): """Create a new price alert.""" tld: str = Field(..., min_length=1, max_length=50, description="TLD without dot (e.g., 'com')") target_price: Optional[float] = Field(None, ge=0, description="Alert when price drops below this") threshold_percent: float = Field(5.0, ge=1, le=50, description="Alert on % change (default 5%)") class PriceAlertUpdate(BaseModel): """Update price alert settings.""" is_active: Optional[bool] = None target_price: Optional[float] = Field(None, ge=0) threshold_percent: Optional[float] = Field(None, ge=1, le=50) class PriceAlertResponse(BaseModel): """Price alert response.""" id: int tld: str is_active: bool target_price: Optional[float] threshold_percent: float last_notified_at: Optional[datetime] last_notified_price: Optional[float] created_at: datetime class Config: from_attributes = True class PriceAlertStatus(BaseModel): """Status check for a TLD alert (for unauthenticated users).""" tld: str has_alert: bool is_active: bool = False # ============== Endpoints ============== @router.get("", response_model=List[PriceAlertResponse]) async def list_price_alerts( current_user: CurrentUser, db: Database, active_only: bool = False, ): """ List all price alerts for the current user. Args: active_only: If true, only return active alerts """ query = select(PriceAlert).where(PriceAlert.user_id == current_user.id) if active_only: query = query.where(PriceAlert.is_active == True) query = query.order_by(PriceAlert.created_at.desc()) result = await db.execute(query) alerts = result.scalars().all() return alerts @router.post("", response_model=PriceAlertResponse, status_code=status.HTTP_201_CREATED) async def create_price_alert( alert_data: PriceAlertCreate, current_user: CurrentUser, db: Database, ): """ Create a new price alert for a TLD. - One alert per TLD per user - Default threshold: 5% price change - Optional: set target price to only alert below threshold """ tld = alert_data.tld.lower().strip().lstrip(".") # Check if alert already exists existing = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"You already have a price alert for .{tld}", ) # Create alert alert = PriceAlert( user_id=current_user.id, tld=tld, target_price=alert_data.target_price, threshold_percent=alert_data.threshold_percent, is_active=True, ) db.add(alert) await db.commit() await db.refresh(alert) logger.info(f"User {current_user.id} created price alert for .{tld}") return alert @router.get("/status/{tld}", response_model=PriceAlertStatus) async def get_alert_status( tld: str, current_user: CurrentUserOptional, db: Database, ): """ Check if user has an alert for a specific TLD. Works for both authenticated and unauthenticated users. Returns has_alert=False for unauthenticated users. """ tld = tld.lower().strip().lstrip(".") if not current_user: return PriceAlertStatus(tld=tld, has_alert=False) result = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) alert = result.scalar_one_or_none() if not alert: return PriceAlertStatus(tld=tld, has_alert=False) return PriceAlertStatus( tld=tld, has_alert=True, is_active=alert.is_active, ) @router.get("/{tld}", response_model=PriceAlertResponse) async def get_price_alert( tld: str, current_user: CurrentUser, db: Database, ): """Get price alert for a specific TLD.""" tld = tld.lower().strip().lstrip(".") result = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) alert = result.scalar_one_or_none() if not alert: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No price alert found for .{tld}", ) return alert @router.put("/{tld}", response_model=PriceAlertResponse) async def update_price_alert( tld: str, update_data: PriceAlertUpdate, current_user: CurrentUser, db: Database, ): """Update price alert settings.""" tld = tld.lower().strip().lstrip(".") result = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) alert = result.scalar_one_or_none() if not alert: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No price alert found for .{tld}", ) # Update fields if update_data.is_active is not None: alert.is_active = update_data.is_active if update_data.target_price is not None: alert.target_price = update_data.target_price if update_data.threshold_percent is not None: alert.threshold_percent = update_data.threshold_percent await db.commit() await db.refresh(alert) return alert @router.delete("/{tld}", status_code=status.HTTP_204_NO_CONTENT) async def delete_price_alert( tld: str, current_user: CurrentUser, db: Database, ): """Delete a price alert.""" tld = tld.lower().strip().lstrip(".") result = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) alert = result.scalar_one_or_none() if not alert: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No price alert found for .{tld}", ) await db.execute( delete(PriceAlert).where(PriceAlert.id == alert.id) ) await db.commit() logger.info(f"User {current_user.id} deleted price alert for .{tld}") @router.post("/{tld}/toggle", response_model=PriceAlertResponse) async def toggle_price_alert( tld: str, current_user: CurrentUser, db: Database, ): """ Toggle a price alert on/off. If alert exists, toggles is_active. If alert doesn't exist, creates a new one. """ tld = tld.lower().strip().lstrip(".") result = await db.execute( select(PriceAlert).where( PriceAlert.user_id == current_user.id, PriceAlert.tld == tld, ) ) alert = result.scalar_one_or_none() if alert: # Toggle existing alert alert.is_active = not alert.is_active await db.commit() await db.refresh(alert) logger.info(f"User {current_user.id} toggled alert for .{tld} to {alert.is_active}") else: # Create new alert alert = PriceAlert( user_id=current_user.id, tld=tld, is_active=True, threshold_percent=5.0, ) db.add(alert) await db.commit() await db.refresh(alert) logger.info(f"User {current_user.id} created new price alert for .{tld}") return alert