Initial commit - MealPlanner

This commit is contained in:
katsuo committed 2026-11-02 00:01:33 +00:00
commit 3ea2c3fd82
19 files changed
+1241

No files matched your search

Executable
+7
View File
@@ -0,0 +1,7 @@
# Copier en .env et définir un mot de passe fort.
# Éviter le caractère $ dans le mot de passe (interpolation Docker Compose).
POSTGRES_PASSWORD=replace_with_a_long_random_password
# Circulaires régionales Flipp
FLIPP_POSTAL_CODE=J7R6S4
FLIPP_LOCALE=fr
Executable
+30
View File
@@ -0,0 +1,30 @@
cd /mnt/user/appdata/mealplanner-project
cat > .gitignore <<'EOF'
# Secrets
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
# Node / Next.js
node_modules/
.next/
out/
dist/
# Logs
*.log
# Éditeurs
.DS_Store
Thumbs.db
.vscode/
.idea/
EOF
Executable
+31
View File
@@ -0,0 +1,31 @@
# MealPlanner V5 — unités de prix et coupes de viande
Cette version corrige deux problèmes du générateur V4 :
- les prix au poids ne sont plus traités comme des prix par article;
- les coupes de poulet sont distinguées (poitrine ≠ cuisse).
## Changements
- L'importateur conserve une unité de prix explicite (`lb`, `kg`, `100g` ou `each`) lorsqu'elle est indiquée dans les champs reçus de Flipp.
- Les prix à la livre sont convertis en prix par gramme pour calculer le coût de la quantité de recette.
- Les recettes de poulet indiquent maintenant explicitement « poitrine de poulet ».
- Une offre de cuisses ou de pilons ne peut plus être associée à une recette demandant des poitrines.
- Les recettes V4 déjà présentes dans la base sont migrées vers « poitrine de poulet » au démarrage.
- Si l'unité du prix n'est pas connue ou incompatible, le calcul est laissé partiel plutôt que de présenter un total trompeur.
## Limite importante
Flipp ne fournit pas toujours l'unité de prix dans le même champ ou pour chaque offre. Dans ce cas, l'application affiche la promotion, mais ne l'utilise pas dans le calcul du coût. Vérifie les exemples affichés après la mise à jour. Le prix `2,99 $/lb` n'est traité comme un prix à la livre que si l'unité `/lb` est présente dans les données reçues.
## Mise à jour Unraid
1. Fais une copie de sauvegarde du dossier du projet.
2. Décompresse le ZIP V5 dans `/mnt/user/appdata/mealplanner-project`, en remplaçant les fichiers.
3. Garde ton `.env` existant.
4. Reconstruis :
```bash
cd /mnt/user/appdata/mealplanner-project
docker compose up -d --build
```
Ne supprime pas les volumes Docker et ne lance pas `docker compose down -v`.
Après le démarrage, déclenche une nouvelle importation Flipp depuis l'interface afin de remplir les unités et coupes sur les nouvelles offres.
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+515
View File
@@ -0,0 +1,515 @@
import os
import requests
import psycopg
from psycopg.rows import dict_row
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from decimal import Decimal
app = FastAPI(title="MealPlanner API", version="0.3.0")
def connect():
return psycopg.connect(
host=os.getenv("DB_HOST", "db"),
port=int(os.getenv("DB_PORT", "5432")),
dbname=os.getenv("DB_NAME", "mealplanner"),
user=os.getenv("DB_USER", "mealplanner"),
password=os.environ["DB_PASSWORD"],
row_factory=dict_row,
connect_timeout=5,
)
def ensure_schema():
# Migration idempotente pour les bases existantes de la V1.
with connect() as conn:
conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS address TEXT")
conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS postal_code TEXT")
conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_code TEXT")
conn.execute("ALTER TABLE stores ADD COLUMN IF NOT EXISTS flyer_page_url TEXT")
conn.execute('''
CREATE TABLE IF NOT EXISTS flyer_sources (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
source_url TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
last_checked_at TIMESTAMPTZ,
last_success_at TIMESTAMPTZ,
last_error TEXT,
UNIQUE(store_id, source_url)
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS flyers (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
source_id INTEGER REFERENCES flyer_sources(id) ON DELETE SET NULL,
title TEXT NOT NULL,
source_url TEXT NOT NULL,
local_path TEXT NOT NULL,
checksum TEXT NOT NULL UNIQUE,
file_size BIGINT NOT NULL DEFAULT 0,
page_count INTEGER NOT NULL DEFAULT 0,
extracted_text TEXT NOT NULL DEFAULT '',
extraction_status TEXT NOT NULL DEFAULT 'pending',
valid_from DATE,
valid_to DATE,
downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
''')
conn.execute('''
CREATE TABLE IF NOT EXISTS flyer_import_runs (
id SERIAL PRIMARY KEY,
store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL,
source_url TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running',
message TEXT
)
''')
conn.execute('''
INSERT INTO stores (name, address, postal_code, store_code, flyer_page_url, enabled)
VALUES
('IGA', %s, 'J7P 2B1', 'IGA EXTRA Lamoureux', %s, TRUE),
('Metro', %s, 'J7R 4K3', 'Marché Arthur-Sauvé inc. (magasin 125)', %s, TRUE)
ON CONFLICT (name) DO UPDATE SET
address = EXCLUDED.address,
postal_code = EXCLUDED.postal_code,
store_code = EXCLUDED.store_code,
flyer_page_url = EXCLUDED.flyer_page_url,
enabled = TRUE
''', (
"379, boulevard Arthur-Sauvé, Saint-Eustache",
os.getenv("IGA_FLYER_PAGE", "https://www.iga.ca/fr/circulaire"),
"753, boulevard Arthur-Sauvé, Saint-Eustache",
os.getenv("METRO_FLYER_PAGE", "https://www.metro.ca/trouver-une-epicerie/125"),
))
conn.execute('''
INSERT INTO flyer_sources (store_id, source_url)
SELECT id, flyer_page_url FROM stores
WHERE name IN ('IGA', 'Metro') AND flyer_page_url IS NOT NULL
ON CONFLICT (store_id, source_url) DO NOTHING
''')
@app.on_event("startup")
def startup():
ensure_schema()
class Settings(BaseModel):
household_size: int = Field(ge=1, le=30)
weekly_budget: Decimal = Field(ge=0, le=100000)
dinners_per_week: int = Field(ge=1, le=7)
@app.get("/health")
def health():
try:
with connect() as conn:
conn.execute("SELECT 1")
return {"status": "ok", "database": "connected"}
except Exception as exc:
raise HTTPException(status_code=503, detail="Database unavailable") from exc
@app.get("/api/settings")
def get_settings():
with connect() as conn:
row = conn.execute(
"SELECT household_size, weekly_budget, dinners_per_week, currency "
"FROM household_settings WHERE id = 1"
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Settings not initialized")
return dict(row)
@app.put("/api/settings")
def update_settings(data: Settings):
with connect() as conn:
row = conn.execute(
"UPDATE household_settings SET household_size=%s, weekly_budget=%s, "
"dinners_per_week=%s WHERE id=1 RETURNING household_size, weekly_budget, "
"dinners_per_week, currency",
(data.household_size, data.weekly_budget, data.dinners_per_week),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Settings not initialized")
return dict(row)
@app.get("/api/stores")
def get_stores():
with connect() as conn:
rows = conn.execute(
"SELECT id, name, enabled, address, postal_code, store_code, flyer_page_url "
"FROM stores ORDER BY name"
).fetchall()
return [dict(row) for row in rows]
@app.get("/api/flyers")
def get_flyers():
with connect() as conn:
rows=conn.execute("""
SELECT f.id,f.title,f.valid_from,f.valid_to,f.imported_at AS downloaded_at,
0 AS page_count,'flipp_imported' AS extraction_status,
(SELECT COUNT(*) FROM flipp_offers o WHERE o.flyer_id=f.id) AS offer_count,
f.thumbnail_url,f.external_flyer_id,f.merchant,f.postal_code,
s.name AS store_name,s.address,'https://www.flipp.com/' AS source_url
FROM flipp_flyers f JOIN stores s ON s.id=f.store_id
WHERE f.valid_to IS NULL OR f.valid_to>=CURRENT_DATE
ORDER BY f.valid_from DESC NULLS LAST,s.name,f.title LIMIT 30
""").fetchall()
return [dict(x) for x in rows]
@app.get("/api/offers")
def get_offers():
with connect() as conn:
rows=conn.execute("""
SELECT o.id,o.item_name,o.brand,o.price,o.price_text,o.image_url,o.valid_from,o.valid_to,
f.merchant,f.title AS flyer_title,s.name AS store_name
FROM flipp_offers o JOIN flipp_flyers f ON f.id=o.flyer_id
JOIN stores s ON s.id=o.store_id
WHERE o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE
ORDER BY s.name,o.price,o.item_name LIMIT 250
""").fetchall()
return [dict(x) for x in rows]
@app.get("/api/flyer-import/status")
def flyer_import_status():
with connect() as conn:
rows=conn.execute("""
SELECT s.name AS store_name,s.address,fs.source_url,fs.last_checked_at,fs.last_success_at,
fs.last_error,
(SELECT COUNT(*) FROM flipp_flyers f WHERE f.store_id=s.id
AND (f.valid_to IS NULL OR f.valid_to>=CURRENT_DATE)) AS flyer_count,
(SELECT COUNT(*) FROM flipp_offers o WHERE o.store_id=s.id
AND (o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE)) AS offer_count
FROM flyer_sources fs JOIN stores s ON s.id=fs.store_id
WHERE s.name IN ('IGA','Metro') AND fs.source_url='https://www.flipp.com/'
ORDER BY s.name
""").fetchall()
runs=conn.execute("""
SELECT r.id,s.name AS store_name,r.started_at,r.finished_at,r.status,r.message
FROM flyer_import_runs r LEFT JOIN stores s ON s.id=r.store_id
ORDER BY r.started_at DESC LIMIT 6
""").fetchall()
return {"sources":[dict(x) for x in rows],"recent_runs":[dict(x) for x in runs]}
@app.post("/api/flyer-import/run")
def trigger_flyer_import():
try:
response = requests.post(
os.getenv("IMPORTER_URL", "http://importer:8001") + "/run", timeout=8
)
response.raise_for_status()
return response.json()
except Exception as exc:
raise HTTPException(status_code=502, detail="Importateur indisponible") from exc
# Catalogue de recettes de base. Les coûts sont des estimations pour 4 portions,
# non des prix garantis. Les promotions Flipp servent au classement des recettes.
RECIPE_CATALOG = [
{
"name": "Poulet citron et pommes de terre au four",
"description": "Poulet rôti avec pommes de terre, carottes et citron.",
"servings": 4, "estimated_cost": 17.00,
"instructions": "Chauffer le four à 200 °C. Couper les pommes de terre et les carottes. Mélanger avec huile, citron et assaisonnements. Ajouter le poulet et cuire jusqu'à ce qu'il soit bien cuit (74 °C au centre).",
"ingredients": [("poitrine de poulet", 700, "g"), ("pommes de terre", 700, "g"), ("carottes", 3, "unités"), ("citron", 1, "unité")]
},
{
"name": "Boulettes de bœuf et riz",
"description": "Boulettes maison dans une sauce tomate, servies avec du riz.",
"servings": 4, "estimated_cost": 16.00,
"instructions": "Former des boulettes avec le bœuf, l'œuf et la chapelure. Les faire dorer, ajouter la sauce tomate et laisser mijoter jusqu'à cuisson complète. Servir sur le riz cuit.",
"ingredients": [("bœuf haché", 500, "g"), ("riz", 250, "g"), ("sauce tomate", 1, "pot"), ("œufs", 1, "unité")]
},
{
"name": "Pâtes crémeuses au poulet et brocoli",
"description": "Un souper rapide avec poulet, brocoli et sauce crémeuse.",
"servings": 4, "estimated_cost": 15.00,
"instructions": "Cuire les pâtes. Faire cuire le poulet en morceaux, ajouter le brocoli puis la crème. Mélanger avec les pâtes et le fromage.",
"ingredients": [("poitrine de poulet", 500, "g"), ("pâtes", 350, "g"), ("brocoli", 1, "tête"), ("crème", 250, "ml"), ("fromage râpé", 100, "g")]
},
{
"name": "Tacos de porc et salade croquante",
"description": "Tacos garnis de porc assaisonné, laitue et tomates.",
"servings": 4, "estimated_cost": 16.00,
"instructions": "Cuire le porc haché avec les épices. Réchauffer les tortillas et garnir de porc, laitue, tomates et fromage.",
"ingredients": [("porc haché", 500, "g"), ("tortillas", 8, "unités"), ("laitue", 1, "petite"), ("tomates", 2, "unités"), ("fromage râpé", 100, "g")]
},
{
"name": "Saumon au miel et légumes",
"description": "Filets de saumon glacés au miel avec légumes rôtis.",
"servings": 4, "estimated_cost": 24.00,
"instructions": "Déposer le saumon et les légumes sur une plaque. Badigeonner le saumon de miel, citron et moutarde. Cuire au four à 200 °C jusqu'à cuisson désirée et sécuritaire.",
"ingredients": [("saumon", 600, "g"), ("brocoli", 1, "tête"), ("carottes", 3, "unités"), ("citron", 1, "unité")]
},
{
"name": "Chili rapide au bœuf",
"description": "Chili nourrissant aux haricots, tomates et bœuf haché.",
"servings": 4, "estimated_cost": 14.00,
"instructions": "Faire revenir le bœuf et l'oignon. Ajouter les haricots, les tomates et les épices. Laisser mijoter 25 à 30 minutes.",
"ingredients": [("bœuf haché", 400, "g"), ("haricots", 1, "boîte"), ("tomates en dés", 1, "boîte"), ("oignon", 1, "unité")]
},
{
"name": "Saucisses et légumes sur plaque",
"description": "Un repas simple avec saucisses, pommes de terre et poivrons.",
"servings": 4, "estimated_cost": 15.00,
"instructions": "Couper les légumes, mélanger avec huile et épices. Ajouter les saucisses et cuire sur une plaque à 200 °C jusqu'à ce que tout soit bien cuit.",
"ingredients": [("saucisses", 500, "g"), ("pommes de terre", 600, "g"), ("poivrons", 2, "unités"), ("oignon", 1, "unité")]
},
{
"name": "Poulet teriyaki et riz",
"description": "Poulet sauté dans une sauce teriyaki, accompagné de riz.",
"servings": 4, "estimated_cost": 16.00,
"instructions": "Cuire le riz. Faire revenir le poulet en lanières, ajouter la sauce teriyaki et laisser épaissir. Servir avec des légumes et le riz.",
"ingredients": [("poitrine de poulet", 600, "g"), ("riz", 250, "g"), ("brocoli", 1, "tête"), ("sauce teriyaki", 100, "ml")]
},
{
"name": "Frittata aux légumes et fromage",
"description": "Omelette épaisse au four, idéale pour un souper économique.",
"servings": 4, "estimated_cost": 11.00,
"instructions": "Faire revenir les légumes. Battre les œufs avec un peu de lait et le fromage. Verser dans une poêle allant au four et cuire à 180 °C jusqu'à ce que le centre soit pris.",
"ingredients": [("œufs", 8, "unités"), ("poivrons", 1, "unité"), ("épinards", 100, "g"), ("fromage râpé", 100, "g")]
},
{
"name": "Porc sauté aux légumes et nouilles",
"description": "Lanières de porc sautées avec légumes et nouilles.",
"servings": 4, "estimated_cost": 17.00,
"instructions": "Cuire les nouilles. Saisir le porc en lanières, ajouter les légumes et la sauce soya. Servir avec les nouilles.",
"ingredients": [("porc", 500, "g"), ("nouilles", 300, "g"), ("poivrons", 1, "unité"), ("brocoli", 1, "tête")]
},
{
"name": "Pizza maison jambon et légumes",
"description": "Pizza facile avec sauce tomate, fromage, jambon et légumes.",
"servings": 4, "estimated_cost": 13.00,
"instructions": "Étendre la sauce tomate sur la pâte, ajouter le fromage, le jambon et les légumes. Cuire selon les indications de la pâte jusqu'à ce que la croûte soit dorée.",
"ingredients": [("pâte à pizza", 1, "unité"), ("sauce tomate", 150, "ml"), ("fromage râpé", 200, "g"), ("jambon", 150, "g"), ("poivrons", 1, "unité")]
},
{
"name": "Bols burrito au poulet",
"description": "Bols de riz avec poulet, haricots, maïs et tomates.",
"servings": 4, "estimated_cost": 16.00,
"instructions": "Cuire le riz et le poulet avec les épices. Répartir dans des bols avec les haricots, le maïs et les tomates.",
"ingredients": [("poitrine de poulet", 500, "g"), ("riz", 250, "g"), ("haricots", 1, "boîte"), ("maïs", 1, "boîte"), ("tomates", 2, "unités")]
}
]
def seed_recipes():
with connect() as conn:
# Migration des recettes V4 : la mention générique « poulet » devenait
# une correspondance trop large pouvant confondre poitrine et cuisse.
conn.execute("""
UPDATE recipe_ingredients ri SET ingredient_name='poitrine de poulet'
FROM recipes r
WHERE ri.recipe_id=r.id AND ri.ingredient_name='poulet'
AND r.name IN ('Poulet citron et pommes de terre au four',
'Pâtes crémeuses au poulet et brocoli',
'Poulet teriyaki et riz',
'Bols burrito au poulet')
""")
for recipe in RECIPE_CATALOG:
row = conn.execute("SELECT id FROM recipes WHERE name=%s ORDER BY id LIMIT 1", (recipe["name"],)).fetchone()
if row is None:
row = conn.execute(
"INSERT INTO recipes (name,description,servings,instructions,estimated_cost) "
"VALUES (%s,%s,%s,%s,%s) RETURNING id",
(recipe["name"],recipe["description"],recipe["servings"],recipe["instructions"],recipe["estimated_cost"])
).fetchone()
recipe_id = row["id"]
count = conn.execute("SELECT COUNT(*) AS n FROM recipe_ingredients WHERE recipe_id=%s", (recipe_id,)).fetchone()["n"]
if count == 0:
for name, quantity, unit in recipe["ingredients"]:
conn.execute(
"INSERT INTO recipe_ingredients (recipe_id,ingredient_name,quantity,unit) VALUES (%s,%s,%s,%s)",
(recipe_id,name,quantity,unit)
)
def normalize_text(value):
import unicodedata, re
value = unicodedata.normalize("NFKD", str(value or "").lower())
value = "".join(ch for ch in value if not unicodedata.combining(ch))
return re.sub(r"[^a-z0-9 ]+", " ", value).strip()
INGREDIENT_TERMS = {
"poitrine de poulet": ["poitrine de poulet", "poulet poitrine", "chicken breast", "breast"],
"cuisse de poulet": ["cuisse de poulet", "haut de cuisse", "chicken thigh", "thigh", "drumstick"],
"bœuf haché": ["boeuf hache", "ground beef"],
"porc haché": ["porc hache", "ground pork"],
"porc": ["porc", "pork"],
"saumon": ["saumon", "salmon"], "saucisses": ["saucisse", "sausage"],
"pommes de terre": ["pomme de terre", "patate", "potato"],
"carottes": ["carotte", "carrot"], "brocoli": ["brocoli", "broccoli"],
"tomates": ["tomate", "tomato"], "tomates en dés": ["tomate", "diced tomato"],
"sauce tomate": ["sauce tomate", "tomato sauce"], "fromage râpé": ["fromage", "cheddar", "mozzarella"],
"œufs": ["oeuf", "egg"], "poivrons": ["poivron", "pepper"],
"laitue": ["laitue", "lettuce"], "épinards": ["epinard", "spinach"],
"haricots": ["haricot", "bean"], "maïs": ["mais", "corn"],
"jambon": ["jambon", "ham"], "crème": ["creme", "cream"],
"riz": ["riz", "rice"], "pâtes": ["pate", "pasta"], "nouilles": ["nouille", "noodle"],
"tortillas": ["tortilla"], "citron": ["citron", "lemon"],
}
def match_offer(ingredient, offer_name):
text = normalize_text(offer_name)
ingredient_norm = normalize_text(ingredient)
# Les coupes de poulet ne sont jamais interchangeables.
if "poulet" in ingredient_norm or "chicken" in ingredient_norm:
wants_breast = any(x in ingredient_norm for x in ["poitrine", "breast"])
wants_thigh = any(x in ingredient_norm for x in ["cuisse", "thigh"])
has_breast = any(x in text for x in ["poitrine", "breast", "supreme"])
has_thigh = any(x in text for x in ["cuisse", "thigh", "drumstick", "pilons"])
if wants_breast and (has_thigh or not has_breast):
return False
if wants_thigh and (has_breast or not has_thigh):
return False
if not wants_breast and not wants_thigh:
return False
terms = INGREDIENT_TERMS.get(ingredient, [normalize_text(ingredient)])
return any(term in text for term in terms)
def ingredient_cost(ingredient, offer, household_size, servings):
"""Retourne un coût calculable seulement si l'unité de vente correspond."""
qty = float(ingredient.get("quantity") or 0) * household_size / max(int(servings or 4), 1)
unit = normalize_text(ingredient.get("unit") or "")
basis = offer.get("price_unit") or "each"
price = float(offer["price"])
if basis == "lb" and unit in ("g", "gramme", "grammes"):
return price * qty / 453.59237
if basis == "kg" and unit in ("g", "gramme", "grammes"):
return price * qty / 1000
if basis == "100g" and unit in ("g", "gramme", "grammes"):
return price * qty / 100
if basis == "each" and unit in ("unite", "unites", "unité", "unités"):
return price * qty
return None
@app.on_event("startup")
def seed_recipe_catalog():
seed_recipes()
@app.get("/api/meal-suggestions")
def meal_suggestions():
with connect() as conn:
recipes = conn.execute("""
SELECT r.id,r.name,r.description,r.servings,r.instructions,r.estimated_cost,
COALESCE(json_agg(json_build_object('name',ri.ingredient_name,'quantity',ri.quantity,'unit',ri.unit))
FILTER (WHERE ri.id IS NOT NULL),'[]') AS ingredients
FROM recipes r LEFT JOIN recipe_ingredients ri ON ri.recipe_id=r.id
GROUP BY r.id ORDER BY r.name
""").fetchall()
offers = conn.execute("""
SELECT o.item_name,o.brand,o.price,o.price_text,o.price_unit,o.product_cut,o.valid_to,s.name AS store_name
FROM flipp_offers o JOIN stores s ON s.id=o.store_id
WHERE o.valid_to IS NULL OR o.valid_to>=CURRENT_DATE
ORDER BY o.price LIMIT 500
""").fetchall()
settings = conn.execute("SELECT household_size,dinners_per_week,weekly_budget FROM household_settings WHERE id=1").fetchone()
output=[]
household_size = settings["household_size"] if settings else 4
for recipe in recipes:
matches=[]
seen=set()
calculable_cost=0.0
calculated_ingredients=0
for ingredient in recipe["ingredients"]:
for offer in offers:
key=(ingredient["name"],offer["item_name"],offer["store_name"])
if key not in seen and match_offer(ingredient["name"],offer["item_name"]):
seen.add(key)
cost=ingredient_cost(ingredient,offer,household_size,recipe["servings"])
matches.append({
"ingredient":ingredient["name"],"item_name":offer["item_name"],
"price":float(offer["price"]),"price_text":offer["price_text"],
"price_unit":offer["price_unit"],"calculated_cost":round(cost,2) if cost is not None else None,
"store_name":offer["store_name"],"valid_to":str(offer["valid_to"]) if offer["valid_to"] else None
})
if cost is not None:
calculable_cost += cost
calculated_ingredients += 1
break
# Les autres ingrédients ne sont pas chiffrés arbitrairement à partir d'un prix/unité.
# On indique le coût des ingrédients effectivement calculables.
output.append({
**dict(recipe),
"estimated_cost":round(calculable_cost,2) if calculated_ingredients else None,
"cost_is_partial":calculated_ingredients < len(recipe["ingredients"]),
"promotion_matches":matches,"promotion_count":len(matches)
})
output.sort(key=lambda x:(-x["promotion_count"],x["estimated_cost"],x["name"]))
return {"suggestions":output,"household_size":household_size,
"dinners_per_week":settings["dinners_per_week"] if settings else 7,
"weekly_budget":float(settings["weekly_budget"]) if settings else 150.0}
class MealPlanInput(BaseModel):
week_start: str
recipe_ids: list[int] = Field(min_length=1, max_length=7)
@app.post("/api/meal-plan")
def save_meal_plan(data: MealPlanInput):
from datetime import date, timedelta
try:
start=date.fromisoformat(data.week_start)
except ValueError:
raise HTTPException(status_code=400, detail="Date de début invalide")
with connect() as conn:
plan=conn.execute(
"INSERT INTO meal_plans (week_start) VALUES (%s) ON CONFLICT (week_start) DO UPDATE SET week_start=EXCLUDED.week_start RETURNING id",
(start,)
).fetchone()
plan_id=plan["id"]
conn.execute("DELETE FROM planned_meals WHERE meal_plan_id=%s",(plan_id,))
for index,recipe_id in enumerate(data.recipe_ids):
exists=conn.execute("SELECT id FROM recipes WHERE id=%s",(recipe_id,)).fetchone()
if not exists:
raise HTTPException(status_code=400,detail="Recette introuvable")
conn.execute(
"INSERT INTO planned_meals (meal_plan_id,meal_date,recipe_id) VALUES (%s,%s,%s)",
(plan_id,start+timedelta(days=index),recipe_id)
)
return {"saved":True,"week_start":start.isoformat(),"meals_saved":len(data.recipe_ids)}
@app.get("/api/meal-plan")
def get_meal_plan(week_start: str):
from datetime import date
try:
start=date.fromisoformat(week_start)
except ValueError:
raise HTTPException(status_code=400,detail="Date de début invalide")
with connect() as conn:
rows=conn.execute("""
SELECT pm.id,pm.meal_date,r.id AS recipe_id,r.name,r.description,r.estimated_cost
FROM meal_plans mp JOIN planned_meals pm ON pm.meal_plan_id=mp.id
LEFT JOIN recipes r ON r.id=pm.recipe_id
WHERE mp.week_start=%s ORDER BY pm.meal_date
""",(start,)).fetchall()
return [dict(x) for x in rows]
@app.get("/api/shopping-list")
def get_shopping_list(week_start: str):
from datetime import date
try:
start=date.fromisoformat(week_start)
except ValueError:
raise HTTPException(status_code=400,detail="Date de début invalide")
with connect() as conn:
rows=conn.execute("""
SELECT ri.ingredient_name,ri.unit,SUM(ri.quantity) AS quantity,
COUNT(DISTINCT pm.id) AS recipe_count
FROM meal_plans mp JOIN planned_meals pm ON pm.meal_plan_id=mp.id
JOIN recipe_ingredients ri ON ri.recipe_id=pm.recipe_id
WHERE mp.week_start=%s
GROUP BY ri.ingredient_name,ri.unit
ORDER BY ri.ingredient_name
""",(start,)).fetchall()
return [dict(x) for x in rows]
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.12
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.6
requests==2.32.3
+66
View File
@@ -0,0 +1,66 @@
CREATE TABLE IF NOT EXISTS stores (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
ALTER TABLE stores ADD COLUMN IF NOT EXISTS address TEXT;
ALTER TABLE stores ADD COLUMN IF NOT EXISTS postal_code TEXT;
ALTER TABLE stores ADD COLUMN IF NOT EXISTS store_code TEXT;
ALTER TABLE stores ADD COLUMN IF NOT EXISTS flyer_page_url TEXT;
CREATE TABLE IF NOT EXISTS household_settings (
id INTEGER PRIMARY KEY CHECK (id=1),
household_size INTEGER NOT NULL DEFAULT 4,
weekly_budget NUMERIC(10,2) NOT NULL DEFAULT 150.00,
dinners_per_week INTEGER NOT NULL DEFAULT 7,
currency TEXT NOT NULL DEFAULT 'CAD'
);
INSERT INTO household_settings (id,household_size,weekly_budget,dinners_per_week,currency)
VALUES (1,4,150.00,7,'CAD') ON CONFLICT (id) DO NOTHING;
INSERT INTO stores (name) VALUES ('IGA'),('Metro'),('Costco'),('Maxi'),('Super C'),('Walmart')
ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY, name TEXT NOT NULL, brand TEXT, unit TEXT
);
CREATE TABLE IF NOT EXISTS flyer_offers (
id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
price NUMERIC(10,2), regular_price NUMERIC(10,2), valid_from DATE, valid_to DATE, source_url TEXT
);
CREATE TABLE IF NOT EXISTS recipes (
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
servings INTEGER NOT NULL DEFAULT 4, instructions TEXT, estimated_cost NUMERIC(10,2)
);
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id SERIAL PRIMARY KEY, recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL, ingredient_name TEXT NOT NULL,
quantity NUMERIC(10,2), unit TEXT
);
CREATE TABLE IF NOT EXISTS meal_plans (
id SERIAL PRIMARY KEY, week_start DATE NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS planned_meals (
id SERIAL PRIMARY KEY, meal_plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
meal_date DATE NOT NULL, recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL, custom_name TEXT
);
CREATE TABLE IF NOT EXISTS flyer_sources (
id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
source_url TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE,
last_checked_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ, last_error TEXT,
UNIQUE(store_id,source_url)
);
CREATE TABLE IF NOT EXISTS flyers (
id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
source_id INTEGER REFERENCES flyer_sources(id) ON DELETE SET NULL,
title TEXT NOT NULL, source_url TEXT NOT NULL, local_path TEXT NOT NULL,
checksum TEXT NOT NULL UNIQUE, file_size BIGINT NOT NULL DEFAULT 0,
page_count INTEGER NOT NULL DEFAULT 0, extracted_text TEXT NOT NULL DEFAULT '',
extraction_status TEXT NOT NULL DEFAULT 'pending', valid_from DATE, valid_to DATE,
downloaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS flyer_import_runs (
id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL,
source_url TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'running', message TEXT
);
+69
View File
@@ -0,0 +1,69 @@
services:
db:
image: postgres:16-alpine
container_name: mealplanner-db
restart: unless-stopped
environment:
POSTGRES_DB: mealplanner
POSTGRES_USER: mealplanner
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/001-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mealplanner -d mealplanner"]
interval: 10s
timeout: 5s
retries: 10
api:
build:
context: ./backend
container_name: mealplanner-api
restart: unless-stopped
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: mealplanner
DB_USER: mealplanner
DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
IMPORTER_URL: http://importer:8001
depends_on:
db:
condition: service_healthy
importer:
build:
context: ./importer
container_name: mealplanner-importer
restart: unless-stopped
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: mealplanner
DB_USER: mealplanner
DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
FLYER_STORAGE: /data/flyers
TZ: America/Toronto
RUN_ON_START: "true"
FLIPP_POSTAL_CODE: J7R6S4
FLIPP_LOCALE: fr
volumes:
- flyer_data:/data/flyers
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
container_name: mealplanner-frontend
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
- api
volumes:
postgres_data:
flyer_data:
+8
View File
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
+68
View File
@@ -0,0 +1,68 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Manrope:wght@600;700;800&display=swap');
*{box-sizing:border-box}body{margin:0;background:#f7f8f3;color:#203329;font-family:'DM Sans',sans-serif}main{min-height:100vh}aside{position:fixed;inset:0 auto 0 0;width:220px;background:white;border-right:1px solid #e8ece5;padding:30px 22px;display:flex;flex-direction:column;gap:22px}aside h2{font-family:Manrope;color:#183c2d}aside p,small{font-size:10px;letter-spacing:1.4px;color:#819087;font-weight:800}aside a{color:#26734d;text-decoration:none;padding:12px;border-radius:10px}section{margin-left:220px;padding:38px 5vw;max-width:1500px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}header h1{font:800 30px Manrope;color:#183c2d;margin:8px 0}header p{color:#758078}.hero{border-radius:22px;background:linear-gradient(110deg,#204e38,#397d51);color:white;padding:35px 42px;display:flex;justify-content:space-between;align-items:center}.hero h1{font:800 38px Manrope;line-height:1.15}.hero p{max-width:420px;color:#dcebe0}.hero a{display:inline-block;background:white;color:#24563c;text-decoration:none;padding:12px 17px;border-radius:10px;font-weight:800;font-size:13px}.hero span{font-size:115px}.stats,.stores,.fields{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin:22px 0 35px}.stats article,article,.empty,#settings{background:white;border:1px solid #e8ece5;border-radius:15px;padding:18px}.stats h2{font:800 25px Manrope;color:#183c2d;margin:8px 0}section>div:not(.hero){margin-top:35px}section h2{font:800 21px Manrope;color:#183c2d}.empty{padding:25px;color:#758078}.stores article{font-weight:700}#settings{margin-top:35px}.fields{margin:18px 0}.fields label{display:grid;gap:8px;font-size:12px;color:#69776e;font-weight:700}.fields input{padding:12px;border:1px solid #dfe6df;border-radius:9px;font:600 14px 'DM Sans' }button{background:#26734d;border:0;color:white;padding:12px 18px;border-radius:9px;font-weight:700;cursor:pointer}footer{text-align:center;color:#9aa49c;font-size:11px;padding:30px}@media(max-width:750px){aside{width:65px;padding:20px 8px}aside h2{font-size:0}aside a{font-size:0}section{margin-left:65px;padding:20px 14px}.hero{padding:24px}.hero span{font-size:60px}.hero h1{font-size:28px}.stats,.fields{grid-template-columns:1fr}.stores{grid-template-columns:repeat(2,1fr)}header{align-items:flex-start;gap:12px;flex-direction:column}}
/* Module Circulaires — ajout à la présentation V1 */
#flyers{margin-top:35px}
.flyer-control{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}
.flyer-control p{max-width:650px;color:#758078;font-size:13px;line-height:1.6}
.import-message{width:100%;margin:0}
.flyer-sources{grid-template-columns:repeat(2,1fr)}
.flyer-sources article{display:flex;flex-direction:column;gap:8px}
.store-address{font-size:12px;color:#758078;margin:5px 0}
.source-error{font-size:12px;color:#a34b35;line-height:1.5}
.flyer-list{display:grid;grid-template-columns:repeat(2,1fr);gap:15px;margin-top:16px}
.flyer-list article{display:flex;flex-direction:column;gap:8px}
.flyer-list h3{font:700 16px Manrope;color:#183c2d;margin:0}
.flyer-list p{font-size:12px;color:#758078;margin:0}
.flyer-list a{font-size:12px;color:#26734d;font-weight:700;text-decoration:none}
@media(max-width:750px){.flyer-sources,.flyer-list{grid-template-columns:1fr}.flyer-control{align-items:flex-start}}
/* Promotions Flipp — ajout au module Circulaires V1 */
.flyer-thumb{width:100%;max-height:170px;object-fit:cover;border-radius:10px;margin-bottom:5px}
.offers-intro{font-size:13px;color:#758078;line-height:1.6}
.offer-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-top:16px}
.offer-card{display:grid;grid-template-columns:92px minmax(0,1fr);gap:12px;align-items:center;padding:12px;min-width:0}
.offer-card>img,.offer-placeholder{width:92px;height:92px;object-fit:contain;border-radius:10px;background:#f4f6f1}
.offer-placeholder{display:grid;place-items:center;font-size:34px}
.offer-detail{display:flex;flex-direction:column;gap:5px;min-width:0}
.offer-detail small{font-size:9px;letter-spacing:.7px}
.offer-detail strong{font-size:12px;line-height:1.35;color:#203329}
.offer-detail span{font-size:11px;color:#758078}
.offer-detail b{font:800 17px Manrope;color:#26734d}
@media(max-width:1100px){.offer-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:750px){.offer-grid{grid-template-columns:1fr}.offer-card{grid-template-columns:78px minmax(0,1fr)}.offer-card>img,.offer-placeholder{width:78px;height:78px}}
/* Générateur de soupers — extension de l'interface V1 */
.meal-generator{padding:22px;margin-top:14px}
.meal-generator-head{display:flex;justify-content:space-between;align-items:center;gap:18px;flex-wrap:wrap}
.meal-generator-head strong{font:800 16px Manrope;color:#183c2d}
.meal-generator-head p,.estimate-note{font-size:12px;color:#758078;line-height:1.6;max-width:700px}
.selection-summary{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:22px 0 14px;padding:13px;background:#f4f7f1;border-radius:10px;color:#315640;font-size:12px}
.selection-summary span{color:#758078}
.suggestion-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin:14px 0}
.suggestion-card{padding:16px;min-width:0;transition:border-color .15s,box-shadow .15s}
.suggestion-card.selected{border-color:#7db58d;box-shadow:0 0 0 2px #e5f1e7}
.suggestion-top{display:flex;justify-content:space-between;align-items:center;gap:10px}
.suggestion-top label{display:flex;align-items:center;gap:6px;color:#64736a;font-size:11px;font-weight:700}
.suggestion-top input,.shopping-list input{accent-color:#26734d}
.meal-icon{font-size:25px}
.suggestion-card h3,.saved-plan h3{font:800 15px Manrope;color:#183c2d;margin:12px 0 6px}
.suggestion-card>p{font-size:12px;color:#758078;line-height:1.5;margin:0}
.recipe-meta{display:flex;justify-content:space-between;align-items:center;margin:12px 0;font-size:11px;color:#758078}
.recipe-meta b{font:800 16px Manrope;color:#26734d}
.promotion-tags{display:flex;flex-direction:column;gap:5px;padding:10px;background:#f1f7ef;border-radius:9px}
.promotion-tags strong{font-size:11px;color:#26734d}
.promotion-tags small{font-size:10px;letter-spacing:0;color:#63766a}
.no-promo{font-size:10px;letter-spacing:0;color:#89948d}
.suggestion-card details{margin-top:12px;border-top:1px solid #edf0eb;padding-top:10px;font-size:11px;color:#637168}
.suggestion-card summary{cursor:pointer;font-weight:700;color:#26734d}
.suggestion-card details ul{padding-left:18px;line-height:1.7}
.suggestion-card details p{line-height:1.6}
.estimate-note{margin:14px 0}
.saved-plan{margin-top:16px}
.saved-plan>p{font-size:13px;color:#637168}
.shopping-list{display:grid;gap:10px;margin-top:15px;padding-top:14px;border-top:1px solid #edf0eb}
.shopping-list h3{margin:0}
.shopping-list label{display:flex;align-items:center;gap:8px;font-size:12px;color:#405448}
@media(max-width:750px){.suggestion-grid{grid-template-columns:1fr}.meal-generator{padding:16px}.meal-generator-head{align-items:flex-start}.selection-summary{flex-direction:column}}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "MealPlanner",
description: "Planificateur de repas hebdomadaire"
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="fr"><body>{children}</body></html>;
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import {useEffect,useState} from "react";
type Flyer = {
id:number; title:string; source_url:string; valid_from:string|null; valid_to:string|null;
downloaded_at:string; page_count:number; extraction_status:string; store_name:string; address:string|null;
offer_count:number; thumbnail_url:string|null; external_flyer_id:number|null; merchant:string|null; postal_code:string|null;
};
type Offer = {id:number;item_name:string;brand:string|null;price:number|string;price_text:string|null;image_url:string|null;valid_from:string|null;valid_to:string|null;merchant:string|null;flyer_title:string|null;store_name:string;};
type Ingredient = {name:string;quantity:number;unit:string};
type Suggestion = {id:number;name:string;description:string;servings:number;instructions:string;estimated_cost:number;ingredients:Ingredient[];promotion_matches:{ingredient:string;item_name:string;price:number;price_text:string|null;price_unit:string;calculated_cost:number|null;store_name:string;valid_to:string|null}[];promotion_count:number;cost_is_partial:boolean};
type ShoppingItem = {ingredient_name:string;unit:string;quantity:number;recipe_count:number};
type SourceStatus = {
store_name:string; address:string; source_url:string; last_checked_at:string|null;
last_success_at:string|null; last_error:string|null; flyer_count:number; offer_count:number;
};
export default function Home(){
const [s,setS]=useState({household_size:4,weekly_budget:150,dinners_per_week:7,currency:"CAD"});
const [stores,setStores]=useState<any[]>([]);
const [flyers,setFlyers]=useState<Flyer[]>([]);
const [offers,setOffers]=useState<Offer[]>([]);
const [sources,setSources]=useState<SourceStatus[]>([]);
const [msg,setMsg]=useState("Connexion…");
const [importMsg,setImportMsg]=useState("");
const [importing,setImporting]=useState(false);
const [suggestions,setSuggestions]=useState<Suggestion[]>([]);
const [selectedIds,setSelectedIds]=useState<number[]>([]);
const [generating,setGenerating]=useState(false);
const [savingPlan,setSavingPlan]=useState(false);
const [planMsg,setPlanMsg]=useState("");
const [shoppingList,setShoppingList]=useState<ShoppingItem[]>([]);
const [savedMeals,setSavedMeals]=useState<any[]>([]);
const [showShopping,setShowShopping]=useState(false);
const today=new Date();
const monday=new Date(today);
monday.setDate(today.getDate()-((today.getDay()+6)%7));
const weekStart=`${monday.getFullYear()}-${String(monday.getMonth()+1).padStart(2,"0")}-${String(monday.getDate()).padStart(2,"0")}`;
const selectedRecipes=suggestions.filter(x=>selectedIds.includes(x.id));
const selectedCost=selectedRecipes.reduce((sum,x)=>sum+(x.estimated_cost===null?0:Number(x.estimated_cost)),0);
const selectedCostsComplete=selectedRecipes.every(x=>!x.cost_is_partial&&x.estimated_cost!==null);
async function loadFlyers(){
try{
const [f,st,o]=await Promise.all([
fetch("/api/flyers").then(r=>r.ok?r.json():[]),
fetch("/api/flyer-import/status").then(r=>r.ok?r.json():{sources:[]}),
fetch("/api/offers").then(r=>r.ok?r.json():[])
]);
setFlyers(f); setSources(st.sources||[]); setOffers(o);
}catch{}
}
useEffect(()=>{
Promise.all([fetch("/api/settings").then(r=>r.json()),fetch("/api/stores").then(r=>r.json())])
.then(([a,b])=>{setS(a);setStores(b);setMsg("Application connectée")})
.catch(()=>setMsg("API non disponible"));
loadFlyers();
},[]);
async function save(){
const r=await fetch("/api/settings",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});
setMsg(r.ok?"Paramètres enregistrés":"Erreur d'enregistrement");
}
async function generateSuggestions(){
setGenerating(true); setPlanMsg("Analyse des promotions et préparation des idées de soupers…");
try{
const r=await fetch("/api/meal-suggestions");
if(!r.ok) throw new Error("Impossible de générer les suggestions");
const data=await r.json();
setSuggestions(data.suggestions||[]);
setSelectedIds((data.suggestions||[]).slice(0,Number(s.dinners_per_week)||7).map((x:Suggestion)=>x.id));
setPlanMsg(`${(data.suggestions||[]).length} idées analysées selon les promotions disponibles.`);
}catch(e:any){setPlanMsg(e.message||"Erreur lors de la génération");}
finally{setGenerating(false);}
}
async function saveMealPlan(){
if(!selectedIds.length){setPlanMsg("Sélectionne au moins un souper.");return;}
setSavingPlan(true);setPlanMsg("Enregistrement du menu…");
try{
const r=await fetch("/api/meal-plan",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({week_start:weekStart,recipe_ids:selectedIds})});
const data=await r.json();
if(!r.ok) throw new Error(data.detail||"Impossible d'enregistrer le menu");
const [meals,list]=await Promise.all([
fetch(`/api/meal-plan?week_start=${weekStart}`).then(x=>x.json()),
fetch(`/api/shopping-list?week_start=${weekStart}`).then(x=>x.json())
]);
setSavedMeals(meals);setShoppingList(list);setShowShopping(true);
setPlanMsg(`Menu enregistré : ${data.meals_saved} soupers planifiés.`);
}catch(e:any){setPlanMsg(e.message||"Erreur d'enregistrement");}
finally{setSavingPlan(false);}
}
function toggleRecipe(id:number){
setSelectedIds(current=>current.includes(id)?current.filter(x=>x!==id):current.length>=Number(s.dinners_per_week)?current:[...current,id]);
}
async function checkFlyers(){
setImporting(true); setImportMsg("Vérification des circulaires en cours…");
try{
const r=await fetch("/api/flyer-import/run",{method:"POST"});
const data=await r.json();
if(!r.ok) throw new Error(data.detail||"Impossible de lancer l'importation");
setImportMsg("Vérification lancée. Actualise la page dans quelques instants pour voir le résultat.");
window.setTimeout(loadFlyers,15000);
}catch(e:any){setImportMsg(e.message||"Erreur");}
finally{setImporting(false);}
}
const dateLabel=(v:string|null)=>v?new Date(v).toLocaleString("fr-CA"):"—";
return <main><aside><h2>🥑 MealPlanner</h2><p>TON ASSISTANT CULINAIRE</p><a href="#menu">Mon menu</a><a href="#stores">Épiceries</a><a href="#flyers">Circulaires</a><a href="#settings">Paramètres</a></aside><section><header><div><small>TON ASSISTANT CULINAIRE</small><h1>Bonjour ! 👋</h1><p>Préparons une semaine de soupers savoureux sans dépasser ton budget.</p></div><label>{msg}</label></header><div className="hero"><div><small>CETTE SEMAINE</small><h1>Les bons repas<br/>commencent ici.</h1><p>Des menus personnalisés selon les rabais de tes épiceries préférées.</p><a href="#settings">Configurer mon budget →</a></div><span>🥗</span></div><div className="stats"><article><small>Budget hebdomadaire</small><h2>{Number(s.weekly_budget).toFixed(2)} $</h2></article><article><small>Soupers à planifier</small><h2>{s.dinners_per_week}</h2></article><article><small>Personnes</small><h2>{s.household_size}</h2></article></div><div id="menu"><small>PLANIFICATION</small><h2>Ton menu de la semaine</h2>
<article className="meal-generator">
<div className="meal-generator-head"><div><strong>Des idées selon les rabais</strong><p>MealPlanner compare les ingrédients des recettes aux promotions IGA et Metro actuellement importées.</p></div><button onClick={generateSuggestions} disabled={generating}>{generating?"Analyse…":"Générer mes soupers"}</button></div>
{planMsg&&<p className="import-message">{planMsg}</p>}
{suggestions.length>0&&<><div className="selection-summary"><strong>{selectedIds.length} / {Number(s.dinners_per_week)} soupers sélectionnés</strong><span>{selectedCostsComplete?`Coût calculé : ${selectedCost.toFixed(2)} $`:`Coût partiel : ${selectedCost.toFixed(2)} $`} · Budget : {Number(s.weekly_budget).toFixed(2)} $</span></div>
<div className="suggestion-grid">{suggestions.map(recipe=><article className={`suggestion-card ${selectedIds.includes(recipe.id)?"selected":""}`} key={recipe.id}>
<div className="suggestion-top"><span className="meal-icon">🍽️</span><label><input type="checkbox" checked={selectedIds.includes(recipe.id)} onChange={()=>toggleRecipe(recipe.id)} disabled={!selectedIds.includes(recipe.id)&&selectedIds.length>=Number(s.dinners_per_week)}/> Ajouter au menu</label></div>
<h3>{recipe.name}</h3><p>{recipe.description}</p>
<div className="recipe-meta"><b>{recipe.estimated_cost===null?"Coût à compléter":`≈ ${Number(recipe.estimated_cost).toFixed(2)} $`}</b><span>{recipe.ingredients.length} ingrédients</span></div>
{recipe.promotion_count>0?<div className="promotion-tags"><strong>🏷️ {recipe.promotion_count} rabais repéré(s)</strong>{recipe.promotion_matches.slice(0,3).map((m,i)=><small key={i}>{m.ingredient} : {m.price_text||`${Number(m.price).toFixed(2)} $`}{m.price_unit==="lb"?" / lb":m.price_unit==="kg"?" / kg":m.price_unit==="100g"?" / 100 g":""} ({m.store_name})</small>)}</div>:<small className="no-promo">Aucun rabais correspondant repéré</small>}
<details><summary>Voir ingrédients et préparation</summary><ul>{recipe.ingredients.map((x,i)=><li key={i}>{x.quantity} {x.unit} — {x.name}</li>)}</ul><p>{recipe.instructions}</p></details>
</article>)}</div>
<p className="estimate-note">Le coût affiché est calculé seulement lorsque l'unité du prix promotionnel et la quantité de recette sont compatibles. Les autres ingrédients restent à chiffrer; aucun prix à la livre n'est supposé sans indication de l'unité dans les données Flipp.</p>
<button onClick={saveMealPlan} disabled={savingPlan||selectedIds.length===0}>{savingPlan?"Enregistrement…":"Enregistrer mon menu et créer la liste d'épicerie"}</button></>}
</article>
{savedMeals.length>0&&<article className="saved-plan"><h3>Menu enregistré</h3>{savedMeals.map((m,i)=><p key={m.id}> <strong>{new Date(`${m.meal_date}T12:00:00`).toLocaleDateString("fr-CA",{weekday:"long",day:"numeric",month:"long"})} :</strong> {m.name}</p>)}
<button onClick={()=>setShowShopping(!showShopping)}>{showShopping?"Masquer":"Afficher"} la liste d'épicerie</button>
{showShopping&&<div className="shopping-list"><h3>Liste d'épicerie regroupée</h3>{shoppingList.map((x,i)=><label key={i}><input type="checkbox"/> {x.ingredient_name} — {Number(x.quantity).toLocaleString("fr-CA")} {x.unit}</label>)}</div>}</article>}
</div><div id="stores"><small>TES COMMERCES</small><h2>Épiceries suivies</h2><div className="stores">{stores.map(x=><article key={x.id}>✓ {x.name}{x.address&&<p className="store-address">{x.address}</p>}</article>)}</div></div>
<div id="flyers"><small>RABAIS DE TES MAGASINS</small><h2>Circulaires IGA et Metro</h2>
<article className="flyer-control">
<p>Importation automatique quotidienne à 6 h (heure de Montréal) des circulaires régionales Flipp pour J7R 6S4. Les articles avec nom et prix sont enregistrés dans ta base locale.</p>
<button onClick={checkFlyers} disabled={importing}>{importing?"Vérification…":"Vérifier les circulaires maintenant"}</button>
{importMsg&&<p className="import-message">{importMsg}</p>}
</article>
<div className="stores flyer-sources">{sources.map(x=><article key={x.store_name}>
<strong>{x.store_name}</strong><p className="store-address">{x.address}</p>
<small>Dernière vérification : {dateLabel(x.last_checked_at)}</small>
<small>Circulaires actives : {x.flyer_count}</small><small>Promotions disponibles : {x.offer_count}</small>
{x.last_error&&<p className="source-error">{x.last_error}</p>}
</article>)}</div>
<div className="flyer-list">{flyers.map(f=><article key={f.id}>
{f.thumbnail_url&&<img className="flyer-thumb" src={f.thumbnail_url} alt="" loading="lazy"/>}
<strong>{f.store_name}</strong><h3>{f.title}</h3>
<p>{f.offer_count} promotions · Valide du {f.valid_from||"—"} au {f.valid_to||"—"}</p>
<small>Circulaire régionale · Code postal {f.postal_code||"J7R6S4"}</small>
<small>Importée : {dateLabel(f.downloaded_at)}</small>
<a href={f.source_url} target="_blank" rel="noreferrer">Source Flipp ↗</a>
</article>)}
{flyers.length===0&&<article className="empty">Aucune circulaire régionale importée pour le moment. Utilise le bouton pour lancer une vérification.</article>}
</div>
<h2>Promotions en cours</h2>
<p className="offers-intro">Prix des circulaires régionales; ils peuvent varier selon la succursale et la disponibilité.</p>
<div className="offer-grid">{offers.slice(0,60).map(o=><article className="offer-card" key={o.id}>
{o.image_url?<img src={o.image_url} alt="" loading="lazy"/>:<div className="offer-placeholder">🛒</div>}
<div className="offer-detail"><small>{o.store_name}</small><strong>{o.item_name}</strong>
{o.brand&&<span>{o.brand}</span>}<b>{o.price_text||`${Number(o.price).toFixed(2)} $`}</b>
<small>Jusqu'au {o.valid_to||"—"}</small></div>
</article>)}</div>
{offers.length===0&&<article className="empty">Les promotions apparaîtront ici après la première importation Flipp.</article>}
</div>
<div id="settings"><small>PERSONNALISATION</small><h2>Ton budget et ton foyer</h2><div className="fields"><label>Budget par semaine ($)<input type="number" value={s.weekly_budget} onChange={e=>setS({...s,weekly_budget:Number(e.target.value)})}/></label><label>Nombre de personnes<input type="number" value={s.household_size} onChange={e=>setS({...s,household_size:Number(e.target.value)})}/></label><label>Soupers par semaine<input type="number" value={s.dinners_per_week} onChange={e=>setS({...s,dinners_per_week:Number(e.target.value)})}/></label></div><button onClick={save}>Enregistrer mes paramètres</button></div><footer>MealPlanner · Mieux manger et économiser.</footer></section></main>
}
Vendored Executable
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
+12
View File
@@ -0,0 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [{
source: "/api/:path*",
destination: "http://api:8000/api/:path*"
}];
}
};
export default nextConfig;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "mealplanner-frontend",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "15.2.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "22.13.10",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"typescript": "5.8.2"
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=America/Toronto
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY importer.py .
EXPOSE 8001
CMD ["uvicorn", "importer:app", "--host", "0.0.0.0", "--port", "8001"]
+200
View File
@@ -0,0 +1,200 @@
import os, re, json, random, hashlib, logging, threading
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from zoneinfo import ZoneInfo
import psycopg
from psycopg.rows import dict_row
import requests
from fastapi import FastAPI, BackgroundTasks
from apscheduler.schedulers.background import BackgroundScheduler
logging.basicConfig(level=os.getenv('LOG_LEVEL', 'INFO'))
log = logging.getLogger('mealplanner-importer')
DB = dict(host=os.getenv('DB_HOST','db'), port=int(os.getenv('DB_PORT','5432')), dbname=os.getenv('DB_NAME','mealplanner'), user=os.getenv('DB_USER','mealplanner'), password=os.environ['DB_PASSWORD'], connect_timeout=8)
POSTAL_CODE = re.sub(r'\s+','',os.getenv('FLIPP_POSTAL_CODE','J7R6S4')).upper()
LOCALE = os.getenv('FLIPP_LOCALE','fr')
FLIPP_FLYERS_URL = 'https://flyers-ng.flippback.com/api/flipp/data'
FLIPP_ITEMS_URL = 'https://flyers-ng.flippback.com/api/flipp/flyers/{}/flyer_items'
FLIPP_HOME = 'https://www.flipp.com/'
app = FastAPI(title='MealPlanner Flipp Importer')
scheduler = BackgroundScheduler(timezone=ZoneInfo('America/Toronto'))
session = requests.Session()
session.headers.update({'User-Agent':'Mozilla/5.0 (compatible; MealPlanner-personal/1.0)','Accept':'application/json'})
run_lock = threading.Lock()
def db():
return psycopg.connect(**DB, row_factory=dict_row)
def ensure_tables():
# Tables Flipp distinctes : les anciennes tables PDF et les autres données restent intactes.
with db() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS flipp_flyers (id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, external_flyer_id BIGINT NOT NULL, title TEXT NOT NULL, merchant TEXT NOT NULL, postal_code TEXT NOT NULL, valid_from DATE, valid_to DATE, thumbnail_url TEXT, categories JSONB, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(store_id, external_flyer_id))")
conn.execute("CREATE TABLE IF NOT EXISTS flipp_offers (id SERIAL PRIMARY KEY, flyer_id INTEGER NOT NULL REFERENCES flipp_flyers(id) ON DELETE CASCADE, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, external_item_id TEXT, item_name TEXT NOT NULL, brand TEXT, price NUMERIC(10,2) NOT NULL, price_text TEXT, valid_from DATE, valid_to DATE, image_url TEXT, offer_key TEXT NOT NULL UNIQUE, imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW())")
conn.execute("ALTER TABLE flipp_offers ADD COLUMN IF NOT EXISTS price_unit TEXT NOT NULL DEFAULT 'unknown'")
conn.execute("ALTER TABLE flipp_offers ADD COLUMN IF NOT EXISTS product_cut TEXT")
conn.execute('CREATE INDEX IF NOT EXISTS flipp_offers_valid_to_idx ON flipp_offers(valid_to)')
conn.execute('CREATE INDEX IF NOT EXISTS flipp_flyers_valid_to_idx ON flipp_flyers(valid_to)')
conn.execute("CREATE TABLE IF NOT EXISTS flyer_sources (id SERIAL PRIMARY KEY, store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE, source_url TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, last_checked_at TIMESTAMPTZ, last_success_at TIMESTAMPTZ, last_error TEXT, UNIQUE(store_id, source_url))")
conn.execute("CREATE TABLE IF NOT EXISTS flyer_import_runs (id SERIAL PRIMARY KEY, store_id INTEGER REFERENCES stores(id) ON DELETE SET NULL, source_url TEXT NOT NULL, started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), finished_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'running', message TEXT)")
def sid():
return ''.join(str(random.randint(0,9)) for _ in range(16))
def parsedate(value):
if not value: return None
try: return datetime.fromisoformat(str(value).replace('Z','+00:00')).date()
except (ValueError,TypeError):
try: return date.fromisoformat(str(value)[:10])
except (ValueError,TypeError): return None
def parsedprice(value):
if value is None or str(value).strip()=='': return None
try:
if isinstance(value,(int,float,Decimal)): return Decimal(str(value))
nums=re.findall(r'\d+(?:[.,]\d{1,2})?',str(value))
return Decimal(nums[-1].replace(',','.')) if nums else None
except InvalidOperation: return None
def pricebasis(item, price_text):
"""Interprète les unités explicites de Flipp; ne devine pas le prix au poids."""
candidates = [
item.get('unit'), item.get('price_unit'), item.get('measurement'),
item.get('size'), item.get('description'), item.get('display_name'),
price_text
]
raw = ' '.join(str(x) for x in candidates if x is not None).lower()
raw = raw.replace(' ', '').replace(',', '.')
if any(x in raw for x in ['/lb', 'perlb', 'lb.', '/livre', 'parlivre', 'pound', 'lb']):
return 'lb'
if any(x in raw for x in ['/kg', 'perkg', '/kilo', 'parkg', 'kg']):
return 'kg'
if any(x in raw for x in ['/100g', 'per100g', '/100 g']):
return '100g'
if any(x in raw for x in ['/each', 'each', 'chacun', '/unité', '/unite']):
return 'each'
# Si Flipp n'indique pas l'unité, on conserve « unité » et on ne calcule
# jamais un prix au poids supposé.
return 'unknown'
def product_cut(name):
n = str(name or '').lower()
if any(x in n for x in ['thigh', 'cuisse', 'haut de cuisse', 'pilons', 'drumstick']):
return 'cuisse'
if any(x in n for x in ['breast', 'poitrine', 'suprême', 'supreme']):
return 'poitrine'
if any(x in n for x in ['whole chicken', 'poulet entier']):
return 'entier'
return None
def getstores():
with db() as conn:
rows=conn.execute("SELECT id,name FROM stores WHERE name IN ('IGA','Metro') AND enabled=TRUE").fetchall()
return {x['name']:x['id'] for x in rows}
def sourceid(store_id):
with db() as conn:
row=conn.execute('SELECT id FROM flyer_sources WHERE store_id=%s AND source_url=%s',(store_id,FLIPP_HOME)).fetchone()
if row: return row['id']
return conn.execute('INSERT INTO flyer_sources(store_id,source_url) VALUES(%s,%s) RETURNING id',(store_id,FLIPP_HOME)).fetchone()['id']
def fetchflyers():
session_id=sid()
r=session.get(FLIPP_FLYERS_URL,params={'locale':LOCALE,'postal_code':POSTAL_CODE,'sid':session_id},timeout=30)
r.raise_for_status()
data=r.json(); flyers=data.get('flyers',[]) if isinstance(data,dict) else []
if not isinstance(flyers,list): raise RuntimeError('Réponse Flipp invalide.')
return session_id,flyers
def storefor(flyer):
merchant=str(flyer.get('merchant','')).strip().casefold()
return 'IGA' if merchant=='iga' else ('Metro' if merchant=='metro' else None)
def isgrocery(flyer):
cats=flyer.get('categories',[])
if isinstance(cats,str): cats=[x.strip() for x in cats.split(',')]
return 'Groceries' in cats
def saveflyer(store_id,flyer):
cats=flyer.get('categories',[])
if isinstance(cats,str): cats=[x.strip() for x in cats.split(',')]
title=str(flyer.get('name') or f"Circulaire {flyer.get('merchant','')}").strip()
thumb=flyer.get('thumbnail_url') or flyer.get('premium_thumbnail_url') or flyer.get('mobile_thumbnail_url')
with db() as conn:
conn.execute("INSERT INTO flipp_flyers (store_id,external_flyer_id,title,merchant,postal_code,valid_from,valid_to,thumbnail_url,categories) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb) ON CONFLICT(store_id,external_flyer_id) DO UPDATE SET title=EXCLUDED.title,valid_from=EXCLUDED.valid_from,valid_to=EXCLUDED.valid_to,thumbnail_url=EXCLUDED.thumbnail_url,categories=EXCLUDED.categories",(store_id,int(flyer['id']),title,flyer.get('merchant'),POSTAL_CODE,parsedate(flyer.get('valid_from')),parsedate(flyer.get('valid_to')),thumb,json.dumps(cats,ensure_ascii=False)))
return conn.execute('SELECT id FROM flipp_flyers WHERE store_id=%s AND external_flyer_id=%s',(store_id,int(flyer['id']))).fetchone()['id']
def saveitems(store_id,flyer_id,flyer,items):
kept=skipped=0
with db() as conn:
for item in items:
if not isinstance(item,dict): skipped+=1; continue
name=' '.join(str(item.get('name') or '').split()).strip()
price_text=str(item.get('price') or '').strip(); price=parsedprice(item.get('price'))
if not name or not price_text or price is None: skipped+=1; continue
itemid=item.get('id') or item.get('item_id')
start=parsedate(item.get('valid_from')) or parsedate(flyer.get('valid_from'))
end=parsedate(item.get('valid_to')) or parsedate(flyer.get('valid_to'))
brand=item.get('brand') or item.get('manufacturer')
unit=pricebasis(item, price_text)
cut=product_cut(name)
img=item.get('clean_image_url') or item.get('image_url') or item.get('thumbnail_url') or item.get('image')
if not isinstance(img,str): img=None
identity='|'.join([str(flyer.get('id')),str(itemid or ''),name.casefold(),price_text,str(start or '')])
key=hashlib.sha256(identity.encode('utf-8')).hexdigest()
conn.execute("INSERT INTO flipp_offers (flyer_id,store_id,external_item_id,item_name,brand,price,price_text,valid_from,valid_to,image_url,offer_key,price_unit,product_cut) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(offer_key) DO UPDATE SET price=EXCLUDED.price,price_text=EXCLUDED.price_text,item_name=EXCLUDED.item_name,brand=EXCLUDED.brand,valid_from=EXCLUDED.valid_from,valid_to=EXCLUDED.valid_to,image_url=EXCLUDED.image_url,flyer_id=EXCLUDED.flyer_id,price_unit=EXCLUDED.price_unit,product_cut=EXCLUDED.product_cut",(flyer_id,store_id,str(itemid) if itemid is not None else None,name,brand,price,price_text,start,end,img,key,unit,cut))
kept+=1
return kept,skipped
def run_imports():
if not run_lock.acquire(blocking=False): log.info('Import Flipp déjà en cours; exécution ignorée.'); return
try:
ensure_tables(); stores=getstores()
if not stores: raise RuntimeError('Magasins IGA et Metro absents ou désactivés.')
session_id,allflyers=fetchflyers(); selected={'IGA':[],'Metro':[]}
for f in allflyers:
target=storefor(f)
if target and isgrocery(f) and f.get('id'): selected[target].append(f)
for name in ('IGA','Metro'):
if name not in stores: log.warning('%s absent ou désactivé.',name); continue
store_id=stores[name]; src=sourceid(store_id)
with db() as conn:
run_id=conn.execute("INSERT INTO flyer_import_runs(store_id,source_url,status,message) VALUES(%s,%s,'running','Importation Flipp en cours') RETURNING id",(store_id,FLIPP_HOME)).fetchone()['id']
conn.execute('UPDATE flyer_sources SET last_checked_at=NOW(),last_error=NULL WHERE id=%s',(src,))
total=skipped=processed=0; errors=[]
for flyer in selected[name]:
try:
record=saveflyer(store_id,flyer)
r=session.get(FLIPP_ITEMS_URL.format(flyer['id']),params={'locale':LOCALE,'sid':session_id},timeout=30)
r.raise_for_status(); items=r.json()
if not isinstance(items,list): raise RuntimeError('Format articles inattendu.')
good,bad=saveitems(store_id,record,flyer,items); total+=good; skipped+=bad; processed+=1
log.info('%s — %s (ID %s): %s offres conservées, %s ignorées.',name,flyer.get('name'),flyer.get('id'),good,bad)
except Exception as exc:
errors.append(f"{flyer.get('name') or flyer.get('id')}: {exc}"); log.exception('Erreur circulaire %s pour %s',flyer.get('id'),name)
msg=f'{processed}/{len(selected[name])} circulaires traitées; {total} offres conservées; {skipped} entrées sans nom/prix ignorées.'
if errors: msg+=' Erreurs: '+' | '.join(errors[:3])
with db() as conn:
conn.execute('UPDATE flyer_sources SET last_success_at=NOW(),last_error=%s WHERE id=%s',('; '.join(errors)[:1000] if errors else None,src))
conn.execute('UPDATE flyer_import_runs SET finished_at=NOW(),status=%s,message=%s WHERE id=%s',('imported' if processed else 'error',msg,run_id))
log.info('%s — %s',name,msg)
except Exception: log.exception('Importation Flipp échouée')
finally: run_lock.release()
@app.on_event('startup')
def startup():
ensure_tables()
scheduler.add_job(run_imports,'cron',hour=6,minute=0,id='daily-flipp',replace_existing=True)
scheduler.start()
if os.getenv('RUN_ON_START','true').lower()=='true': scheduler.add_job(run_imports,'date',run_date=datetime.now(ZoneInfo('America/Toronto')),id='initial-flipp',replace_existing=True)
@app.on_event('shutdown')
def shutdown(): scheduler.shutdown(wait=False)
@app.get('/health')
def health(): return {'status':'ok','service':'flipp-importer','postal_code':POSTAL_CODE}
@app.post('/run')
def run_now(background_tasks:BackgroundTasks):
background_tasks.add_task(run_imports)
return {'status':'queued','message':f'Importation régionale IGA et Metro pour {POSTAL_CODE} lancée.'}
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.12
uvicorn[standard]==0.34.0
psycopg[binary]==3.2.6
requests==2.32.3
APScheduler==3.11.0