Files
Priceflow/app/models.py
T

200 lines
10 KiB
Python

from datetime import UTC, datetime
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
"""User model for authentication"""
__tablename__ = "users"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
username: str = Column(String, unique=True, nullable=False, index=True) # type: ignore
password_hash: str = Column(String, nullable=False) # type: ignore
is_admin: bool = Column(Boolean, default=False) # type: ignore
is_active: bool = Column(Boolean, default=True) # type: ignore
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
last_login: datetime | None = Column(DateTime, nullable=True) # type: ignore
class Item(Base):
__tablename__ = "items"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
url: str = Column(String, index=True) # type: ignore
name: str = Column(String) # type: ignore
selector: str | None = Column(String, nullable=True) # type: ignore
target_price: float | None = Column(Float, nullable=True) # type: ignore
check_interval_minutes: int = Column(Integer, default=60) # type: ignore
# New fields
current_price: float | None = Column(Float, nullable=True) # type: ignore
in_stock: bool | None = Column(Boolean, nullable=True) # type: ignore
is_available: bool | None = Column(Boolean, nullable=True, default=True) # type: ignore # Product still exists
category: str | None = Column(String, nullable=True) # type: ignore # User-defined category
tags: str | None = Column(String, nullable=True) # type: ignore
description: str | None = Column(String, nullable=True) # type: ignore
# Confidence scores for latest extraction
current_price_confidence: float | None = Column(Float, nullable=True) # type: ignore
in_stock_confidence: float | None = Column(Float, nullable=True) # type: ignore
is_active: bool = Column(Boolean, default=True) # type: ignore
last_checked: datetime | None = Column(DateTime, nullable=True) # type: ignore
is_refreshing: bool = Column(Boolean, default=False) # type: ignore
last_error: str | None = Column(String, nullable=True) # type: ignore
price_history = relationship("PriceHistory", back_populates="item", cascade="all, delete-orphan")
notification_channel_id: int | None = Column(Integer, ForeignKey("notification_channels.id"), nullable=True) # type: ignore
notification_channel = relationship("NotificationChannel", back_populates="items")
class PriceHistory(Base):
__tablename__ = "price_history"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
item_id: int = Column(Integer, ForeignKey("items.id")) # type: ignore
price: float = Column(Float) # type: ignore
timestamp: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
screenshot_path: str | None = Column(String, nullable=True) # type: ignore
# Confidence scores and AI metadata
price_confidence: float | None = Column(Float, nullable=True) # type: ignore
in_stock_confidence: float | None = Column(Float, nullable=True) # type: ignore
ai_model: str | None = Column(String, nullable=True) # type: ignore
ai_provider: str | None = Column(String, nullable=True) # type: ignore
prompt_version: str | None = Column(String, nullable=True) # type: ignore
repair_used: bool | None = Column(Boolean, nullable=True, default=False) # type: ignore
item = relationship("Item", back_populates="price_history")
class Settings(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True, index=True)
value = Column(Text)
class SearchSite(Base):
"""Sites configurés pour la recherche de produits"""
__tablename__ = "search_sites"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
name: str = Column(String, nullable=False) # type: ignore # "Amazon France"
domain: str = Column(String, unique=True, nullable=False, index=True) # type: ignore # "amazon.fr"
logo_url: str | None = Column(String, nullable=True) # type: ignore
category: str | None = Column(String, nullable=True) # type: ignore # "Tech", "Déco", "Généraliste"
is_active: bool = Column(Boolean, default=True) # type: ignore
priority: int = Column(Integer, default=0) # type: ignore # Ordre d'affichage
requires_js: bool = Column(Boolean, default=False) # type: ignore # Force Browserless si True
debug_enabled: bool = Column(Boolean, default=False) # type: ignore # Activer le dump HTML pour ce site
price_selector: str | None = Column(String, nullable=True) # type: ignore # Sélecteur CSS pour le prix
search_url: str | None = Column(String, nullable=True) # type: ignore # URL de recherche avec {query} placeholder
product_link_selector: str | None = Column(String, nullable=True) # type: ignore # Sélecteur CSS pour les liens produits
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
updated_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) # type: ignore
class NotificationChannel(Base):
__tablename__ = "notification_channels"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
name: str = Column(String, nullable=False) # type: ignore
type: str = Column(String, nullable=False) # type: ignore # "email", "discord", "mattermost"
configuration: str = Column(Text, nullable=False) # type: ignore # JSON or encrypted string
is_active: bool = Column(Boolean, default=True) # type: ignore
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
items = relationship("Item", back_populates="notification_channel")
# === CATALOG MODULE MODELS ===
class Enseigne(Base):
"""Stores/brands for promotional catalogs"""
__tablename__ = "enseignes"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
nom: str = Column(String, nullable=False) # type: ignore # "Gifi"
slug_bonial: str = Column(String, unique=True, nullable=False, index=True) # type: ignore # "Gifi"
logo_url: str | None = Column(String, nullable=True) # type: ignore
couleur: str = Column(String, nullable=False) # type: ignore # "#E30613"
site_url: str | None = Column(String, nullable=True) # type: ignore
description: str | None = Column(String, nullable=True) # type: ignore
is_active: bool = Column(Boolean, default=True) # type: ignore
ordre_affichage: int = Column(Integer, default=0) # type: ignore
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
updated_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) # type: ignore
# Relationships
catalogues = relationship("Catalogue", back_populates="enseigne", cascade="all, delete-orphan")
scraping_logs = relationship("ScrapingLog", back_populates="enseigne")
class Catalogue(Base):
"""Promotional catalogs from stores"""
__tablename__ = "catalogues"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
enseigne_id: int = Column(Integer, ForeignKey("enseignes.id"), nullable=False, index=True) # type: ignore
titre: str = Column(String, nullable=False) # type: ignore
description: str | None = Column(Text, nullable=True) # type: ignore
date_debut: datetime = Column(DateTime, nullable=False, index=True) # type: ignore
date_fin: datetime = Column(DateTime, nullable=False, index=True) # type: ignore
image_couverture_url: str = Column(String, nullable=False) # type: ignore
catalogue_url: str = Column(String, nullable=False) # type: ignore # URL Bonial viewer
nombre_pages: int = Column(Integer, default=0) # type: ignore
statut: str = Column(String, default="actif", index=True) # type: ignore # actif, termine, erreur
content_hash: str = Column(String(64), unique=True, nullable=False, index=True) # type: ignore # SHA256
metadonnees: str | None = Column(Text, nullable=True) # type: ignore # JSON for additional data
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
updated_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) # type: ignore
# Relationships
enseigne = relationship("Enseigne", back_populates="catalogues")
pages = relationship("CataloguePage", back_populates="catalogue", cascade="all, delete-orphan")
class CataloguePage(Base):
"""Individual pages of a catalog"""
__tablename__ = "catalogue_pages"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
catalogue_id: int = Column(Integer, ForeignKey("catalogues.id"), nullable=False, index=True) # type: ignore
numero_page: int = Column(Integer, nullable=False) # type: ignore
image_url: str = Column(String, nullable=False) # type: ignore
image_thumbnail_url: str | None = Column(String, nullable=True) # type: ignore
largeur: int | None = Column(Integer, nullable=True) # type: ignore
hauteur: int | None = Column(Integer, nullable=True) # type: ignore
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
# Relationship
catalogue = relationship("Catalogue", back_populates="pages")
class ScrapingLog(Base):
"""Logs of scraping executions"""
__tablename__ = "scraping_logs"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
date_execution: datetime = Column(DateTime, default=lambda: datetime.now(UTC), index=True) # type: ignore
enseigne_id: int | None = Column(Integer, ForeignKey("enseignes.id"), nullable=True) # type: ignore # NULL = all stores
statut: str = Column(String, nullable=False) # type: ignore # success, error, partial
catalogues_trouves: int = Column(Integer, default=0) # type: ignore
catalogues_nouveaux: int = Column(Integer, default=0) # type: ignore
catalogues_mis_a_jour: int = Column(Integer, default=0) # type: ignore
duree_secondes: float | None = Column(Float, nullable=True) # type: ignore
message_erreur: str | None = Column(Text, nullable=True) # type: ignore
details: str | None = Column(Text, nullable=True) # type: ignore # JSON
# Relationship
enseigne = relationship("Enseigne", back_populates="scraping_logs")