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
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
313 lines
8.2 KiB
Python
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
|
|
|