""" Sniper Alert models for hyper-personalized auction alerts. This implements "Strategie 4: Alerts nach Maß" from analysis_3.md: "Der User kann extrem spezifische Filter speichern: - Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält. - Informiere mich, wenn eine .ch Domain droppt, die das Wort 'Immo' enthält." DATABASE TABLES TO CREATE: 1. sniper_alerts - Saved filter configurations 2. sniper_alert_matches - Matched auctions for each alert 3. sniper_alert_notifications - Sent notifications Run migrations: alembic upgrade head """ from datetime import datetime from typing import Optional, List from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON from sqlalchemy.orm import Mapped, mapped_column, relationship from app.database import Base class SniperAlert(Base): """ Saved filter for hyper-personalized auction alerts. Users can define very specific criteria and get notified when matching domains appear in auctions. Example filters: - "4-letter .com without q or x" - ".ch domains containing 'immo'" - "Auctions under $100 ending in 1 hour" From analysis_3.md: "Wenn die SMS/Mail kommt, weiß der User: Das ist relevant." """ __tablename__ = "sniper_alerts" id: Mapped[int] = mapped_column(primary_key=True, index=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) # Alert name name: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Filter criteria (stored as JSON for flexibility) # Example: {"tlds": ["com", "io"], "max_length": 4, "exclude_chars": ["q", "x"]} filter_criteria: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) # Individual filter fields (for database queries) tlds: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated: "com,io,ai" keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must contain exclude_keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must not contain max_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) min_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) max_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) min_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) max_bids: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Low competition ending_within_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Urgency platforms: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Comma-separated # Advanced filters no_numbers: Mapped[bool] = mapped_column(Boolean, default=False) no_hyphens: Mapped[bool] = mapped_column(Boolean, default=False) exclude_chars: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # "q,x,z" # Notification settings notify_email: Mapped[bool] = mapped_column(Boolean, default=True) notify_sms: Mapped[bool] = mapped_column(Boolean, default=False) # Tycoon feature notify_push: Mapped[bool] = mapped_column(Boolean, default=False) # Frequency limits max_notifications_per_day: Mapped[int] = mapped_column(Integer, default=10) cooldown_minutes: Mapped[int] = mapped_column(Integer, default=30) # Min time between alerts # Status is_active: Mapped[bool] = mapped_column(Boolean, default=True) # Stats matches_count: Mapped[int] = mapped_column(Integer, default=0) notifications_sent: Mapped[int] = mapped_column(Integer, default=0) last_matched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships user: Mapped["User"] = relationship("User", back_populates="sniper_alerts") matches: Mapped[List["SniperAlertMatch"]] = relationship( "SniperAlertMatch", back_populates="alert", cascade="all, delete-orphan" ) def __repr__(self) -> str: return f"" def matches_domain(self, domain: str, tld: str, price: float, num_bids: int) -> bool: """Check if a domain matches this alert's criteria.""" name = domain.split('.')[0] if '.' in domain else domain # TLD filter if self.tlds: allowed_tlds = [t.strip().lower() for t in self.tlds.split(',')] if tld.lower() not in allowed_tlds: return False # Length filters if self.max_length and len(name) > self.max_length: return False if self.min_length and len(name) < self.min_length: return False # Price filters if self.max_price and price > self.max_price: return False if self.min_price and price < self.min_price: return False # Competition filter if self.max_bids and num_bids > self.max_bids: return False # Keyword filters if self.keywords: required = [k.strip().lower() for k in self.keywords.split(',')] if not any(kw in name.lower() for kw in required): return False if self.exclude_keywords: excluded = [k.strip().lower() for k in self.exclude_keywords.split(',')] if any(kw in name.lower() for kw in excluded): return False # Character filters if self.no_numbers and any(c.isdigit() for c in name): return False if self.no_hyphens and '-' in name: return False if self.exclude_chars: excluded_chars = [c.strip().lower() for c in self.exclude_chars.split(',')] if any(c in name.lower() for c in excluded_chars): return False return True class SniperAlertMatch(Base): """ Record of a domain that matched a sniper alert. """ __tablename__ = "sniper_alert_matches" id: Mapped[int] = mapped_column(primary_key=True, index=True) alert_id: Mapped[int] = mapped_column(ForeignKey("sniper_alerts.id"), index=True, nullable=False) # Matched auction info domain: Mapped[str] = mapped_column(String(255), nullable=False) platform: Mapped[str] = mapped_column(String(50), nullable=False) current_bid: Mapped[float] = mapped_column(Float, nullable=False) end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) auction_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Status notified: Mapped[bool] = mapped_column(Boolean, default=False) clicked: Mapped[bool] = mapped_column(Boolean, default=False) # Timestamps matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) # Relationships alert: Mapped["SniperAlert"] = relationship("SniperAlert", back_populates="matches") def __repr__(self) -> str: return f""