pounce/backend/app/api/price_alerts.py
yves.gugger 8de107f5ee
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
feat: Complete all missing features
Priorität 1 (Kritisch):
- Pricing Page: Stripe Checkout-Button mit API-Anbindung
- TLD Price Alert API + Frontend-Integration mit Toggle
- Blog Newsletter-Formular mit API-Anbindung

Priorität 2 (Wichtig):
- Favicon + Manifest.json für PWA-Support
- Dashboard: Stripe Billing Portal Link für zahlende Kunden
- Upgrade-Button für Scout-User

Priorität 3 (CI/CD):
- GitHub Actions CI Pipeline (lint, build, docker, security)
- GitHub Actions Deploy Pipeline (automated SSH deployment)

Neue Backend-Features:
- Price Alert Model + API (/api/v1/price-alerts)
- Toggle, Status-Check, CRUD-Operationen

Updates:
- README.md mit vollständiger API-Dokumentation
- Layout mit korrektem Manifest-Pfad
2025-12-08 14:55:41 +01:00

313 lines
8.2 KiB
Python

"""
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