mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-11 17:29:14 +02:00
feat: Hardcode French e-commerce websites configuration
- Remove ability to add/edit/delete search sites from settings UI - Configure 16 French e-commerce sites with search URLs: - Discount: Gifi, Stokomani, B&M, Centrakor, L'Incroyable, Action, La Foir'Fouille - Grandes Surfaces: E.Leclerc, Auchan, Carrefour - E-commerce: Amazon France, Cdiscount - Électronique: Darty, Boulanger, Fnac - Add automatic database seeding on application startup - Add /api/search-sites/seed and /api/search-sites/reset endpoints - Add cookie consent banner handling for sites that require it - Simplify Settings page to only show site list with toggle for active state
This commit is contained in:
5 files changed
+449
-468
No files matched your search
+17
-1
@@ -11,10 +11,11 @@ from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.database import engine
|
||||
from app.database import SessionLocal, engine
|
||||
from app.limiter import limiter
|
||||
from app.routers import items, jobs, notifications, openrouter, search, search_sites, settings
|
||||
from app.services.scheduler_service import scheduled_refresh, scheduler
|
||||
from app.services import search_service
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -80,6 +81,21 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Migration error: {e}")
|
||||
|
||||
# Seed default sites
|
||||
logger.info("Seeding default search sites...")
|
||||
try:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
created = search_service.seed_default_sites(db)
|
||||
if created > 0:
|
||||
logger.info(f"{created} site(s) de recherche créé(s)")
|
||||
else:
|
||||
logger.info("Sites de recherche déjà initialisés")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error seeding sites: {e}")
|
||||
|
||||
logger.info("Starting smart scheduler (Heartbeat: 1 minute)")
|
||||
scheduler.add_job(scheduled_refresh, IntervalTrigger(minutes=1), id="refresh_job", replace_existing=True)
|
||||
scheduler.start()
|
||||
|
||||
+44
-142
@@ -1,17 +1,16 @@
|
||||
"""
|
||||
API Router pour la gestion des sites de recherche
|
||||
Les sites sont configurés en dur dans direct_search_service.py
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import SessionLocal, get_db
|
||||
from app.schemas import SearchSiteCreate, SearchSiteResponse, SearchSiteUpdate
|
||||
from app.database import get_db
|
||||
from app.schemas import SearchSiteResponse, SearchSiteUpdate
|
||||
from app.services import search_service
|
||||
from app.services.search_url_discovery import discover_search_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,157 +33,60 @@ async def get_site(site_id: int, db: Session = Depends(get_db)):
|
||||
return site
|
||||
|
||||
|
||||
async def _discover_and_update_site(site_id: int, domain: str, db_url: str):
|
||||
"""Découvre l'URL de recherche en background et met à jour le site"""
|
||||
try:
|
||||
logger.info(f"Discovering search URL for site {site_id} ({domain})")
|
||||
result = await discover_search_url(domain)
|
||||
|
||||
if result.get("discovered"):
|
||||
# Mettre à jour le site avec l'URL découverte
|
||||
db = SessionLocal()
|
||||
try:
|
||||
update_data = {}
|
||||
if result.get("search_url"):
|
||||
update_data["search_url"] = result["search_url"]
|
||||
if result.get("product_link_selector"):
|
||||
update_data["product_link_selector"] = result["product_link_selector"]
|
||||
|
||||
if update_data:
|
||||
search_service.update_site(db, site_id, update_data)
|
||||
logger.info(f"Updated site {site_id} with discovered URL: {result.get('search_url')}")
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
logger.warning(f"Could not discover search URL for {domain}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background discovery for {domain}: {e}")
|
||||
|
||||
|
||||
@router.post("", response_model=SearchSiteResponse, status_code=201)
|
||||
async def create_site(
|
||||
site: SearchSiteCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Crée un nouveau site de recherche.
|
||||
Si search_url n'est pas fourni, tente de le découvrir automatiquement.
|
||||
"""
|
||||
try:
|
||||
new_site = search_service.create_site(db, site.model_dump())
|
||||
|
||||
# Si pas de search_url, lancer la découverte en background
|
||||
if not site.search_url:
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
background_tasks.add_task(
|
||||
_discover_and_update_site,
|
||||
new_site.id,
|
||||
new_site.domain,
|
||||
db_url,
|
||||
)
|
||||
logger.info(f"Started background discovery for site {new_site.id}")
|
||||
|
||||
return new_site
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.put("/{site_id}", response_model=SearchSiteResponse)
|
||||
async def update_site(
|
||||
site_id: int,
|
||||
site: SearchSiteUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Met à jour un site de recherche"""
|
||||
updated = search_service.update_site(
|
||||
db, site_id, site.model_dump(exclude_unset=True)
|
||||
)
|
||||
"""
|
||||
Met à jour un site de recherche.
|
||||
Permet uniquement de modifier is_active et priority.
|
||||
"""
|
||||
# Limiter les champs modifiables
|
||||
allowed_fields = {"is_active", "priority"}
|
||||
update_data = site.model_dump(exclude_unset=True)
|
||||
filtered_data = {k: v for k, v in update_data.items() if k in allowed_fields}
|
||||
|
||||
if not filtered_data:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Seuls les champs 'is_active' et 'priority' peuvent être modifiés"
|
||||
)
|
||||
|
||||
updated = search_service.update_site(db, site_id, filtered_data)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/{site_id}", status_code=204)
|
||||
async def delete_site(site_id: int, db: Session = Depends(get_db)):
|
||||
"""Supprime un site de recherche"""
|
||||
deleted = search_service.delete_site(db, site_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
|
||||
@router.post("/{site_id}/discover", response_model=dict)
|
||||
async def discover_site_search_url(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@router.post("/seed", response_model=dict)
|
||||
async def seed_default_sites(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Force la découverte de l'URL de recherche pour un site existant.
|
||||
Utile si la découverte automatique n'a pas fonctionné.
|
||||
Initialise les sites par défaut s'ils n'existent pas encore.
|
||||
Utile après une première installation.
|
||||
"""
|
||||
site = search_service.get_site_by_id(db, site_id)
|
||||
if not site:
|
||||
raise HTTPException(status_code=404, detail="Site not found")
|
||||
|
||||
result = await discover_search_url(site.domain)
|
||||
|
||||
if result.get("discovered"):
|
||||
# Mettre à jour le site
|
||||
update_data = {}
|
||||
if result.get("search_url"):
|
||||
update_data["search_url"] = result["search_url"]
|
||||
if result.get("product_link_selector"):
|
||||
update_data["product_link_selector"] = result["product_link_selector"]
|
||||
|
||||
if update_data:
|
||||
search_service.update_site(db, site_id, update_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"URL de recherche découverte: {result.get('search_url')}",
|
||||
"search_url": result.get("search_url"),
|
||||
"product_link_selector": result.get("product_link_selector"),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Impossible de découvrir l'URL de recherche automatiquement",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/discover-all", response_model=dict)
|
||||
async def discover_all_missing_search_urls(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Lance la découverte d'URL de recherche pour tous les sites qui n'en ont pas.
|
||||
La découverte se fait en arrière-plan.
|
||||
"""
|
||||
sites = search_service.get_all_sites(db)
|
||||
sites_to_discover = [s for s in sites if not s.search_url]
|
||||
|
||||
if not sites_to_discover:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Tous les sites ont déjà une URL de recherche configurée",
|
||||
"sites_queued": 0,
|
||||
}
|
||||
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
|
||||
for site in sites_to_discover:
|
||||
background_tasks.add_task(
|
||||
_discover_and_update_site,
|
||||
site.id,
|
||||
site.domain,
|
||||
db_url,
|
||||
)
|
||||
created = search_service.seed_default_sites(db)
|
||||
total = len(search_service.get_all_sites(db))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Découverte lancée pour {len(sites_to_discover)} sites",
|
||||
"sites_queued": len(sites_to_discover),
|
||||
"sites": [{"id": s.id, "domain": s.domain} for s in sites_to_discover],
|
||||
"message": f"{created} nouveau(x) site(s) créé(s)",
|
||||
"sites_created": created,
|
||||
"total_sites": total,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reset", response_model=dict)
|
||||
async def reset_sites_to_defaults(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Réinitialise tous les sites avec les valeurs par défaut.
|
||||
ATTENTION: Supprime tous les sites existants et recrée à partir de la config.
|
||||
"""
|
||||
created = search_service.reset_sites_to_defaults(db)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Sites réinitialisés. {created} site(s) créé(s)",
|
||||
"sites_created": created,
|
||||
}
|
||||
@@ -39,99 +39,265 @@ class SearchResult:
|
||||
|
||||
|
||||
# Configuration des sites avec leurs sélecteurs par défaut
|
||||
# Liste des sites français configurés en dur
|
||||
DEFAULT_SITE_CONFIGS = {
|
||||
"amazon.fr": {
|
||||
"search_url": "https://www.amazon.fr/s?k={query}",
|
||||
"product_selector": "div[data-component-type='s-search-result'] h2 a",
|
||||
"wait_selector": "div[data-component-type='s-search-result']",
|
||||
},
|
||||
"amazon.com": {
|
||||
"search_url": "https://www.amazon.com/s?k={query}",
|
||||
"product_selector": "div[data-component-type='s-search-result'] h2 a",
|
||||
"wait_selector": "div[data-component-type='s-search-result']",
|
||||
},
|
||||
"action.com": {
|
||||
"search_url": "https://www.action.com/fr-fr/search/?q={query}",
|
||||
"product_selector": "a.product-card__link, .product-card a, a[href*='/p/']",
|
||||
"wait_selector": ".product-card, .search-results",
|
||||
},
|
||||
"fnac.com": {
|
||||
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
|
||||
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
|
||||
"wait_selector": ".Article-item",
|
||||
},
|
||||
"cdiscount.com": {
|
||||
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
|
||||
"product_selector": "a.prdtBImg, .prdtBILDetails a",
|
||||
"wait_selector": ".prdtBILDetails",
|
||||
},
|
||||
"darty.com": {
|
||||
"search_url": "https://www.darty.com/nav/recherche?text={query}",
|
||||
"product_selector": ".product-card a, a[href*='/product']",
|
||||
"wait_selector": ".product-card",
|
||||
},
|
||||
"boulanger.com": {
|
||||
"search_url": "https://www.boulanger.com/resultats?tr={query}",
|
||||
"product_selector": ".product-list__item a, a[href*='/ref/']",
|
||||
"wait_selector": ".product-list",
|
||||
},
|
||||
# Magasins discount français
|
||||
"stokomani.fr": {
|
||||
"search_url": "https://www.stokomani.fr/recherche?q={query}",
|
||||
"product_selector": (
|
||||
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
|
||||
".products article a, a[href*='/products/'], a[href*='/produit/']"
|
||||
),
|
||||
"wait_selector": ".products, .product-miniature, #js-product-list, .collection-products",
|
||||
},
|
||||
# === MAGASINS DISCOUNT ===
|
||||
"gifi.fr": {
|
||||
"name": "Gifi",
|
||||
"search_url": "https://www.gifi.fr/catalogsearch/result/?q={query}",
|
||||
"product_selector": (
|
||||
".product-item a.product-item-link, .product-item-info a, "
|
||||
".product-item a, .products-grid a, a[href$='.html']"
|
||||
),
|
||||
"wait_selector": ".products-grid, .product-items, .search-results, .products",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 1,
|
||||
},
|
||||
"stokomani.fr": {
|
||||
"name": "Stokomani",
|
||||
"search_url": "https://www.stokomani.fr/recherche?q={query}",
|
||||
"product_selector": (
|
||||
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
|
||||
".products article a, a[href*='/products/'], a[href*='/produit/']"
|
||||
),
|
||||
"wait_selector": ".products, .product-miniature, #js-product-list, .collection-products",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 2,
|
||||
},
|
||||
"bmstores.fr": {
|
||||
"name": "B&M",
|
||||
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
|
||||
"product_selector": (
|
||||
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
|
||||
".products article a, a[href*='/produits/']"
|
||||
),
|
||||
"wait_selector": ".products, .product-miniature, #js-product-list, #search_results",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 3,
|
||||
},
|
||||
"centrakor.com": {
|
||||
"search_url": "https://www.centrakor.com/recherche?search={query}",
|
||||
"product_selector": "a.product-card, .product-item a",
|
||||
"wait_selector": ".product-card, .product-list",
|
||||
"name": "Centrakor",
|
||||
"search_url": "https://www.centrakor.com/catalogsearch/result/?q={query}",
|
||||
"product_selector": (
|
||||
".product-item a.product-item-link, .product-item-info a, "
|
||||
".products-grid a, a[href$='.html']"
|
||||
),
|
||||
"wait_selector": ".products-grid, .product-items, .search-results",
|
||||
"category": "Déco & Maison",
|
||||
"requires_js": True,
|
||||
"priority": 4,
|
||||
},
|
||||
"lincroyable.fr": {
|
||||
"name": "L'Incroyable",
|
||||
"search_url": "https://www.lincroyable.fr/recherche?q={query}",
|
||||
"product_selector": (
|
||||
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
|
||||
".products article a, a[href*='/produit']"
|
||||
),
|
||||
"wait_selector": ".products, .product-miniature, #js-product-list",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 5,
|
||||
},
|
||||
"action.com": {
|
||||
"name": "Action",
|
||||
"search_url": "https://www.action.com/fr-fr/search/?q={query}",
|
||||
"product_selector": "a.product-card__link, .product-card a, a[href*='/p/']",
|
||||
"wait_selector": ".product-card, .search-results",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 6,
|
||||
},
|
||||
"lafoirfouille.fr": {
|
||||
"search_url": "https://www.lafoirfouille.fr/recherche?q={query}",
|
||||
"product_selector": "a.product-item-link, .product-card a",
|
||||
"wait_selector": ".product-item, .products",
|
||||
"name": "La Foir'Fouille",
|
||||
"search_url": "https://www.lafoirfouille.fr/catalogsearch/result/?q={query}",
|
||||
"product_selector": (
|
||||
".product-item a.product-item-link, .product-item-info a, "
|
||||
".products-grid a, a[href$='.html']"
|
||||
),
|
||||
"wait_selector": ".products-grid, .product-items, .search-results",
|
||||
"category": "Discount",
|
||||
"requires_js": True,
|
||||
"priority": 7,
|
||||
},
|
||||
"tedi.fr": {
|
||||
"search_url": "https://www.tedi.fr/search?q={query}",
|
||||
"product_selector": "a.product-card, .product a",
|
||||
"wait_selector": ".product-card, .search-results",
|
||||
},
|
||||
"leclerc.fr": {
|
||||
# === GRANDES SURFACES ===
|
||||
"e.leclerc": {
|
||||
"name": "E.Leclerc",
|
||||
"search_url": "https://www.e.leclerc/recherche?q={query}",
|
||||
"product_selector": "a.product-card, .product-tile a",
|
||||
"wait_selector": ".product-card, .products-grid",
|
||||
},
|
||||
"carrefour.fr": {
|
||||
"search_url": "https://www.carrefour.fr/s?q={query}",
|
||||
"product_selector": "a.product-card, .product-item a",
|
||||
"wait_selector": ".product-card, .product-list",
|
||||
"product_selector": (
|
||||
"a[data-testid='product-card-link'], .product-card a, "
|
||||
".product-tile a, a[href*='/fp/']"
|
||||
),
|
||||
"wait_selector": ".product-card, .products-grid, [data-testid='product-card']",
|
||||
"category": "Grande Surface",
|
||||
"requires_js": True,
|
||||
"priority": 8,
|
||||
},
|
||||
"auchan.fr": {
|
||||
"name": "Auchan",
|
||||
"search_url": "https://www.auchan.fr/recherche?text={query}",
|
||||
"product_selector": "a.product-card, .product-tile a",
|
||||
"wait_selector": ".product-card, .products",
|
||||
"product_selector": (
|
||||
".product-card a, .product-tile a, "
|
||||
"a[href*='/p/'], .product-item a"
|
||||
),
|
||||
"wait_selector": ".product-card, .products, .product-list",
|
||||
"category": "Grande Surface",
|
||||
"requires_js": True,
|
||||
"priority": 9,
|
||||
},
|
||||
"carrefour.fr": {
|
||||
"name": "Carrefour",
|
||||
"search_url": "https://www.carrefour.fr/s?q={query}",
|
||||
"product_selector": (
|
||||
"a[data-testid='product-card-link'], .product-card a, "
|
||||
".product-item a, a[href*='/p/']"
|
||||
),
|
||||
"wait_selector": ".product-card, .product-list, [data-testid='product-card']",
|
||||
"category": "Grande Surface",
|
||||
"requires_js": True,
|
||||
"priority": 10,
|
||||
},
|
||||
# === E-COMMERCE GÉNÉRALISTE ===
|
||||
"amazon.fr": {
|
||||
"name": "Amazon France",
|
||||
"search_url": "https://www.amazon.fr/s?k={query}",
|
||||
"product_selector": "div[data-component-type='s-search-result'] h2 a",
|
||||
"wait_selector": "div[data-component-type='s-search-result']",
|
||||
"category": "E-commerce",
|
||||
"requires_js": True,
|
||||
"priority": 11,
|
||||
},
|
||||
"cdiscount.com": {
|
||||
"name": "Cdiscount",
|
||||
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
|
||||
"product_selector": (
|
||||
"a.prdtBImg, .prdtBILDetails a, .product-item a, "
|
||||
"a[href*='/f-']"
|
||||
),
|
||||
"wait_selector": ".prdtBILDetails, .product-list",
|
||||
"category": "E-commerce",
|
||||
"requires_js": True,
|
||||
"priority": 12,
|
||||
},
|
||||
# === ÉLECTRONIQUE / HIGH-TECH ===
|
||||
"darty.com": {
|
||||
"name": "Darty",
|
||||
"search_url": "https://www.darty.com/nav/recherche?text={query}",
|
||||
"product_selector": (
|
||||
".product-card a, a[href*='/product'], "
|
||||
".product-list-item a"
|
||||
),
|
||||
"wait_selector": ".product-card, .product-list",
|
||||
"category": "Électronique",
|
||||
"requires_js": True,
|
||||
"priority": 13,
|
||||
},
|
||||
"boulanger.com": {
|
||||
"name": "Boulanger",
|
||||
"search_url": "https://www.boulanger.com/resultats?tr={query}",
|
||||
"product_selector": (
|
||||
".product-list__item a, a[href*='/ref/'], "
|
||||
".product-card a"
|
||||
),
|
||||
"wait_selector": ".product-list, .product-card",
|
||||
"category": "Électronique",
|
||||
"requires_js": True,
|
||||
"priority": 14,
|
||||
},
|
||||
# === AUTRES (conservés pour compatibilité) ===
|
||||
"amazon.com": {
|
||||
"name": "Amazon US",
|
||||
"search_url": "https://www.amazon.com/s?k={query}",
|
||||
"product_selector": "div[data-component-type='s-search-result'] h2 a",
|
||||
"wait_selector": "div[data-component-type='s-search-result']",
|
||||
"category": "E-commerce",
|
||||
"requires_js": True,
|
||||
"priority": 99,
|
||||
},
|
||||
"fnac.com": {
|
||||
"name": "Fnac",
|
||||
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
|
||||
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
|
||||
"wait_selector": ".Article-item",
|
||||
"category": "Culture & Tech",
|
||||
"requires_js": True,
|
||||
"priority": 15,
|
||||
},
|
||||
}
|
||||
|
||||
# Sélecteurs communs pour les bannières de cookies
|
||||
COOKIE_ACCEPT_SELECTORS = [
|
||||
# Boutons d'acceptation génériques
|
||||
"button[id*='accept']",
|
||||
"button[id*='cookie']",
|
||||
"button[class*='accept']",
|
||||
"button[class*='cookie']",
|
||||
"button[data-testid*='accept']",
|
||||
"button[data-testid*='cookie']",
|
||||
# Texte français
|
||||
"button:has-text('Accepter')",
|
||||
"button:has-text('J\\'accepte')",
|
||||
"button:has-text('Tout accepter')",
|
||||
"button:has-text('Accepter tout')",
|
||||
"button:has-text('Accepter les cookies')",
|
||||
"button:has-text('OK')",
|
||||
"button:has-text('Continuer')",
|
||||
# Texte anglais (certains sites utilisent l'anglais)
|
||||
"button:has-text('Accept')",
|
||||
"button:has-text('Accept all')",
|
||||
"button:has-text('Accept cookies')",
|
||||
"button:has-text('I accept')",
|
||||
# Liens
|
||||
"a:has-text('Accepter')",
|
||||
"a:has-text('J\\'accepte')",
|
||||
"a:has-text('Tout accepter')",
|
||||
# Classes communes de CMP (Consent Management Platforms)
|
||||
".didomi-continue-without-agreeing",
|
||||
"#didomi-notice-agree-button",
|
||||
".onetrust-accept-btn-handler",
|
||||
"#onetrust-accept-btn-handler",
|
||||
".tarteaucitronAllow",
|
||||
"#tarteaucitronPersonalize",
|
||||
".cc-btn.cc-allow",
|
||||
".cky-btn-accept",
|
||||
"#CybsAccept",
|
||||
".qc-cmp2-summary-buttons button:first-child",
|
||||
"[data-accept-cookies]",
|
||||
"[data-gdpr-accept]",
|
||||
]
|
||||
|
||||
|
||||
async def _accept_cookies(page, domain: str) -> bool:
|
||||
"""
|
||||
Tente d'accepter les cookies sur une page.
|
||||
Retourne True si un bouton a été cliqué, False sinon.
|
||||
"""
|
||||
try:
|
||||
# Attendre un court moment pour que la bannière apparaisse
|
||||
await asyncio.sleep(1)
|
||||
|
||||
for selector in COOKIE_ACCEPT_SELECTORS:
|
||||
try:
|
||||
button = page.locator(selector).first
|
||||
if await button.is_visible(timeout=500):
|
||||
await button.click(timeout=2000)
|
||||
logger.info(f"{domain}: Cookies acceptés avec sélecteur {selector}")
|
||||
# Attendre que la bannière disparaisse
|
||||
await asyncio.sleep(0.5)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
logger.debug(f"{domain}: Aucune bannière de cookies trouvée")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"{domain}: Erreur lors de l'acceptation des cookies: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def search(
|
||||
query: str,
|
||||
@@ -220,6 +386,9 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
|
||||
# Naviguer vers la page de recherche
|
||||
await page.goto(final_url, wait_until="domcontentloaded", timeout=int(timeout * 1000))
|
||||
|
||||
# Accepter les cookies si nécessaire
|
||||
await _accept_cookies(page, domain)
|
||||
|
||||
# Attendre que les résultats se chargent
|
||||
if wait_selector:
|
||||
try:
|
||||
|
||||
@@ -330,3 +330,96 @@ def delete_site(db: Session, site_id: int) -> bool:
|
||||
db.delete(site)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def seed_default_sites(db: Session) -> int:
|
||||
"""
|
||||
Initialise la base de données avec les sites configurés en dur.
|
||||
Ne crée que les sites qui n'existent pas encore (basé sur le domaine).
|
||||
|
||||
Returns:
|
||||
Nombre de sites créés
|
||||
"""
|
||||
from app.services.direct_search_service import DEFAULT_SITE_CONFIGS
|
||||
|
||||
created_count = 0
|
||||
|
||||
# Récupérer les domaines existants
|
||||
existing_domains = {site.domain.lower().replace("www.", "")
|
||||
for site in db.query(SearchSite).all()}
|
||||
|
||||
for domain, config in DEFAULT_SITE_CONFIGS.items():
|
||||
# Normaliser le domaine pour comparaison
|
||||
clean_domain = domain.lower().replace("www.", "")
|
||||
|
||||
# Ne pas créer si déjà existant
|
||||
if clean_domain in existing_domains:
|
||||
logger.debug(f"Site {domain} déjà existant, ignoré")
|
||||
continue
|
||||
|
||||
# Créer le site
|
||||
site_data = {
|
||||
"name": config.get("name", domain),
|
||||
"domain": domain,
|
||||
"search_url": config.get("search_url"),
|
||||
"product_link_selector": config.get("product_selector"),
|
||||
"category": config.get("category"),
|
||||
"requires_js": config.get("requires_js", True),
|
||||
"priority": config.get("priority", 99),
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
try:
|
||||
site = SearchSite(**site_data)
|
||||
db.add(site)
|
||||
db.commit()
|
||||
created_count += 1
|
||||
logger.info(f"Site créé: {config.get('name', domain)} ({domain})")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Erreur création site {domain}: {e}")
|
||||
|
||||
return created_count
|
||||
|
||||
|
||||
def reset_sites_to_defaults(db: Session) -> int:
|
||||
"""
|
||||
Réinitialise tous les sites avec les valeurs par défaut.
|
||||
Supprime tous les sites existants et recrée à partir de la config.
|
||||
|
||||
Returns:
|
||||
Nombre de sites créés
|
||||
"""
|
||||
from app.services.direct_search_service import DEFAULT_SITE_CONFIGS
|
||||
|
||||
# Supprimer tous les sites existants
|
||||
db.query(SearchSite).delete()
|
||||
db.commit()
|
||||
logger.info("Tous les sites supprimés")
|
||||
|
||||
# Recréer avec les valeurs par défaut
|
||||
created_count = 0
|
||||
|
||||
for domain, config in DEFAULT_SITE_CONFIGS.items():
|
||||
site_data = {
|
||||
"name": config.get("name", domain),
|
||||
"domain": domain,
|
||||
"search_url": config.get("search_url"),
|
||||
"product_link_selector": config.get("product_selector"),
|
||||
"category": config.get("category"),
|
||||
"requires_js": config.get("requires_js", True),
|
||||
"priority": config.get("priority", 99),
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
try:
|
||||
site = SearchSite(**site_data)
|
||||
db.add(site)
|
||||
db.commit()
|
||||
created_count += 1
|
||||
logger.info(f"Site créé: {config.get('name', domain)} ({domain})")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Erreur création site {domain}: {e}")
|
||||
|
||||
return created_count
|
||||
+61
-260
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, Plus } from 'lucide-react';
|
||||
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, RotateCcw } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -9,7 +9,6 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const API_URL = '/api';
|
||||
@@ -57,10 +56,7 @@ export default function Settings() {
|
||||
|
||||
// Search Sites state
|
||||
const [searchSites, setSearchSites] = useState([]);
|
||||
const [newSite, setNewSite] = useState({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false, price_selector: '' });
|
||||
const [editingSiteId, setEditingSiteId] = useState(null);
|
||||
const [siteModalOpen, setSiteModalOpen] = useState(false);
|
||||
const [editSiteData, setEditSiteData] = useState(null);
|
||||
const [resettingSites, setResettingSites] = useState(false);
|
||||
|
||||
// OpenRouter state
|
||||
const [openrouterModels, setOpenrouterModels] = useState([]);
|
||||
@@ -244,72 +240,6 @@ export default function Settings() {
|
||||
};
|
||||
|
||||
// Search Sites functions
|
||||
const handleSiteSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingSiteId) {
|
||||
await axios.put(`${API_URL}/search-sites/${editingSiteId}`, newSite);
|
||||
toast.success('Site mis à jour');
|
||||
} else {
|
||||
await axios.post(`${API_URL}/search-sites`, newSite);
|
||||
toast.success('Site ajouté');
|
||||
}
|
||||
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false, price_selector: '' });
|
||||
setEditingSiteId(null);
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de l\'opération sur le site');
|
||||
}
|
||||
};
|
||||
|
||||
const editSite = (site) => {
|
||||
setEditSiteData({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
domain: site.domain,
|
||||
category: site.category || '',
|
||||
is_active: site.is_active,
|
||||
priority: site.priority,
|
||||
requires_js: site.requires_js,
|
||||
price_selector: site.price_selector || ''
|
||||
});
|
||||
setSiteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSiteSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!editSiteData) return;
|
||||
try {
|
||||
await axios.put(`${API_URL}/search-sites/${editSiteData.id}`, editSiteData);
|
||||
toast.success('Site mis à jour');
|
||||
setSiteModalOpen(false);
|
||||
setEditSiteData(null);
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la mise à jour');
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSiteEdit = () => {
|
||||
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false, price_selector: '' });
|
||||
setEditingSiteId(null);
|
||||
};
|
||||
|
||||
const deleteSite = async (id) => {
|
||||
if (confirm('Êtes-vous sûr de vouloir supprimer ce site ?')) {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/search-sites/${id}`);
|
||||
toast.success('Site supprimé');
|
||||
if (editingSiteId === id) {
|
||||
cancelSiteEdit();
|
||||
}
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la suppression');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSiteActive = async (site) => {
|
||||
try {
|
||||
await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active });
|
||||
@@ -319,6 +249,22 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetSitesToDefaults = async () => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir réinitialiser tous les sites ? Cette action va remettre les valeurs par défaut.')) {
|
||||
return;
|
||||
}
|
||||
setResettingSites(true);
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/search-sites/reset`);
|
||||
toast.success(response.data.message);
|
||||
fetchAll();
|
||||
} catch (error) {
|
||||
toast.error('Erreur lors de la réinitialisation');
|
||||
} finally {
|
||||
setResettingSites(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
|
||||
<div>
|
||||
@@ -622,118 +568,55 @@ export default function Settings() {
|
||||
{/* Search Sites Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
|
||||
<CardDescription>Configurez les sites sur lesquels rechercher des produits.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<form onSubmit={handleSiteSubmit} className="space-y-4 border-b pb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">{editingSiteId ? 'Modifier le site' : 'Ajouter un site'}</h4>
|
||||
{editingSiteId && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={cancelSiteEdit}>Annuler</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
|
||||
<CardDescription>Sites e-commerce français configurés pour la recherche de produits.</CardDescription>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du site</Label>
|
||||
<Input required value={newSite.name} onChange={(e) => setNewSite({ ...newSite, name: e.target.value })} placeholder="ex: Amazon France" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domaine</Label>
|
||||
<Input required value={newSite.domain} onChange={(e) => setNewSite({ ...newSite, domain: e.target.value })} placeholder="ex: amazon.fr" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Catégorie</Label>
|
||||
<Input value={newSite.category} onChange={(e) => setNewSite({ ...newSite, category: e.target.value })} placeholder="ex: Électronique, Déco, Généraliste" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priorité</Label>
|
||||
<Input type="number" min="0" value={newSite.priority} onChange={(e) => setNewSite({ ...newSite, priority: parseInt(e.target.value) || 0 })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sélecteur CSS (prix)</Label>
|
||||
<Input value={newSite.price_selector} onChange={(e) => setNewSite({ ...newSite, price_selector: e.target.value })} placeholder="ex: .price, #product-price, [data-price]" />
|
||||
<p className="text-xs text-muted-foreground">Sélecteur CSS pour extraire le prix sur ce site (optionnel)</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={newSite.is_active} onCheckedChange={(checked) => setNewSite({ ...newSite, is_active: checked })} />
|
||||
<Label>Actif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={newSite.requires_js} onCheckedChange={(checked) => setNewSite({ ...newSite, requires_js: checked })} />
|
||||
<Label>Nécessite JavaScript</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{editingSiteId ? 'Mettre à jour' : 'Ajouter le site'}
|
||||
<Button variant="outline" size="sm" onClick={resetSitesToDefaults} disabled={resettingSites}>
|
||||
<RotateCcw className={cn("h-4 w-4 mr-2", resettingSites && "animate-spin")} />
|
||||
Réinitialiser
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{searchSites.filter(s => s.is_active).length} site(s) actif(s) sur {searchSites.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">Sites configurés ({searchSites.length})</h4>
|
||||
{searchSites.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun site configuré.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{searchSites.map(site => (
|
||||
<div key={site.id} className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-card text-card-foreground shadow transition-all hover:shadow-md",
|
||||
editingSiteId === site.id && "ring-2 ring-primary",
|
||||
!site.is_active && "opacity-60"
|
||||
)}>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
|
||||
{site.name}
|
||||
{site.is_active ? (
|
||||
<span className="px-2 py-0.5 bg-green-500/10 text-green-700 dark:text-green-400 rounded text-xs">Actif</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-red-500/10 text-red-700 dark:text-red-400 rounded text-xs">Inactif</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{site.domain}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editSite(site)} title="Modifier">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => deleteSite(site.id)} title="Supprimer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{site.category && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{site.category}
|
||||
</span>
|
||||
)}
|
||||
{site.requires_js && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-blue-500/10 text-blue-700 dark:text-blue-400">
|
||||
JavaScript
|
||||
</span>
|
||||
)}
|
||||
{site.price_selector && (
|
||||
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-purple-500/10 text-purple-700 dark:text-purple-400" title={site.price_selector}>
|
||||
CSS: {site.price_selector.length > 15 ? site.price_selector.substring(0, 15) + '...' : site.price_selector}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} />
|
||||
{searchSites.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Aucun site configuré. Cliquez sur "Réinitialiser" pour charger les sites par défaut.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{searchSites.map(site => (
|
||||
<div key={site.id} className={cn(
|
||||
"relative overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-all",
|
||||
!site.is_active && "opacity-50"
|
||||
)}>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{site.name}</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">{site.domain}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={site.is_active}
|
||||
onCheckedChange={() => toggleSiteActive(site)}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{site.category && (
|
||||
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-secondary text-secondary-foreground">
|
||||
{site.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -855,88 +738,6 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal d'édition de site */}
|
||||
<Dialog open={siteModalOpen} onOpenChange={setSiteModalOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le site</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editSiteData && (
|
||||
<form onSubmit={handleEditSiteSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nom du site</Label>
|
||||
<Input
|
||||
required
|
||||
value={editSiteData.name}
|
||||
onChange={(e) => setEditSiteData({ ...editSiteData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domaine</Label>
|
||||
<Input
|
||||
required
|
||||
value={editSiteData.domain}
|
||||
onChange={(e) => setEditSiteData({ ...editSiteData, domain: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Catégorie</Label>
|
||||
<Input
|
||||
value={editSiteData.category}
|
||||
onChange={(e) => setEditSiteData({ ...editSiteData, category: e.target.value })}
|
||||
placeholder="ex: Électronique"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Priorité</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editSiteData.priority}
|
||||
onChange={(e) => setEditSiteData({ ...editSiteData, priority: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sélecteur CSS (prix)</Label>
|
||||
<Input
|
||||
value={editSiteData.price_selector}
|
||||
onChange={(e) => setEditSiteData({ ...editSiteData, price_selector: e.target.value })}
|
||||
placeholder="ex: .price, #product-price"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Sélecteur CSS pour extraire le prix</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={editSiteData.is_active}
|
||||
onCheckedChange={(checked) => setEditSiteData({ ...editSiteData, is_active: checked })}
|
||||
/>
|
||||
<Label>Actif</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={editSiteData.requires_js}
|
||||
onCheckedChange={(checked) => setEditSiteData({ ...editSiteData, requires_js: checked })}
|
||||
/>
|
||||
<Label>Nécessite JavaScript</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setSiteModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Enregistrer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user