Files
recipe-importer/app/main.py
T
admin 19cbd505d4 feat: recipe management page — browse, search, edit, delete recipes
New /recipes page with backend switching (Mealie/Tandoor), full-text
search, tag filtering, multi-select bulk delete, per-recipe edit/delete
buttons, and a full recipe editor reusing the import preview form.

New API client methods: list_recipes, get_recipe, update_recipe,
delete_recipe for both MealieClient and TandoorClient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 08:30:26 +01:00

456 lines
18 KiB
Python

"""Flask application — recipe importer web UI."""
import hashlib
import os
import secrets
import traceback
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, Response
from app import config
from app.scraper import scrape, supported_sites
from app.mealie import MealieClient
from app.tandoor import TandoorClient
app = Flask(
__name__,
template_folder=os.path.join(os.path.dirname(__file__), "templates"),
static_folder=os.path.join(os.path.dirname(__file__), "static"),
)
app.secret_key = os.environ.get("SECRET_KEY", "recipe-importer-dev-key")
VERSION = os.environ.get("VERSION", "dev")
# ---------------------------------------------------------------------------
# Basic auth middleware
# ---------------------------------------------------------------------------
_AUTH_EXEMPT = {"/health", "/assets/logo.svg", "/assets/logo_notext.svg"}
@app.before_request
def _check_basic_auth():
"""Enforce HTTP Basic Auth if configured."""
if request.path in _AUTH_EXEMPT or request.path.startswith("/assets/"):
return None
cfg = config.load()
auth_user = cfg.get("auth_username", "").strip()
auth_hash = cfg.get("auth_password_hash", "").strip()
if not auth_user or not auth_hash:
return None # auth not configured — allow all
auth = request.authorization
if (auth
and auth.type == "basic"
and auth.username == auth_user
and _hash_password(auth.password) == auth_hash):
return None # credentials match
return Response(
"Hitelesítés szükséges.", 401,
{"WWW-Authenticate": 'Basic realm="Recept Importáló"'},
)
def _hash_password(password: str) -> str:
"""SHA-256 hex digest of password."""
return hashlib.sha256(password.encode("utf-8")).hexdigest()
_REDIRECT_PATTERNS = ["ide kattintva", "oldalán találod", "teljes recept itt",
"kattints ide", "eredeti recept"]
def _is_redirect_instructions(instructions: list[str]) -> bool:
"""Check if instructions are just a redirect to another site."""
if len(instructions) > 2:
return False
text = " ".join(instructions).lower()
return any(p in text for p in _REDIRECT_PATTERNS)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
"""Redirect to the import page (or settings if not configured)."""
cfg = config.load()
has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key"))
has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key"))
if not has_mealie and not has_tandoor:
return redirect(url_for("settings"))
return redirect(url_for("import_page"))
@app.route("/settings", methods=["GET", "POST"])
def settings():
"""Configure Mealie connection."""
cfg = config.load()
if request.method == "POST":
cfg["mealie_url"] = request.form.get("mealie_url", "").strip().rstrip("/")
cfg["mealie_api_key"] = request.form.get("mealie_api_key", "").strip()
cfg["tandoor_url"] = request.form.get("tandoor_url", "").strip().rstrip("/")
cfg["tandoor_api_key"] = request.form.get("tandoor_api_key", "").strip()
# Auth settings
new_user = request.form.get("auth_username", "").strip()
new_pass = request.form.get("auth_password", "").strip()
if new_user:
cfg["auth_username"] = new_user
if new_pass:
# Only update password hash if a new password was provided
cfg["auth_password_hash"] = _hash_password(new_pass)
else:
# Clear auth if username removed
cfg.pop("auth_username", None)
cfg.pop("auth_password_hash", None)
config.save(cfg)
flash("Beállítások mentve.", "success")
return redirect(url_for("settings"))
return render_template("settings.html", cfg=cfg, version=VERSION)
@app.route("/settings/test", methods=["POST"])
def settings_test():
"""AJAX endpoint — test Mealie connection using form values."""
url = (request.form.get("mealie_url") or "").strip().rstrip("/")
key = (request.form.get("mealie_api_key") or "").strip()
if not url or not key:
return jsonify({"ok": False, "error": "Nincs megadva Mealie URL vagy API kulcs."})
try:
client = MealieClient(url, key, api_url=config.MEALIE_INTERNAL_URL)
info = client.test_connection()
return jsonify({"ok": True, "data": info})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/settings/test-tandoor", methods=["POST"])
def settings_test_tandoor():
"""AJAX endpoint — test Tandoor connection using form values."""
url = (request.form.get("tandoor_url") or "").strip().rstrip("/")
key = (request.form.get("tandoor_api_key") or "").strip()
if not url or not key:
return jsonify({"ok": False, "error": "Nincs megadva Tandoor URL vagy API kulcs."})
try:
client = TandoorClient(url, key, api_url=config.TANDOOR_INTERNAL_URL)
info = client.test_connection()
return jsonify({"ok": True, "data": info})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/import", methods=["GET"])
def import_page():
"""Show the import form."""
cfg = config.load()
has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key"))
has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key"))
if not has_mealie and not has_tandoor:
flash("Először állíts be legalább egy szolgáltatást (Mealie vagy Tandoor).", "warning")
return redirect(url_for("settings"))
return render_template("import.html", cfg=cfg, version=VERSION,
has_mealie=has_mealie, has_tandoor=has_tandoor,
supported_sites=supported_sites())
@app.route("/scrape", methods=["POST"])
def scrape_url():
"""AJAX — scrape a recipe URL and return structured data."""
url = request.form.get("url", "").strip()
if not url:
return jsonify({"ok": False, "error": "Nincs URL megadva."})
try:
data = scrape(url)
# Check for duplicates in Mealie and Tandoor
duplicate = None
tandoor_duplicate = None
cfg = config.load()
if cfg.get("mealie_url") and cfg.get("mealie_api_key"):
try:
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
duplicate = client.find_duplicate(url, data.get("title", ""))
except Exception:
pass # non-fatal
if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"):
try:
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
tandoor_duplicate = client.find_duplicate(url, data.get("title", ""))
except Exception:
pass # non-fatal
# Validate completeness
warnings = []
real_ingredients = [i for i in data.get("ingredients", []) if "group" not in i]
if not real_ingredients:
warnings.append("A recept nem tartalmaz hozzávalókat.")
instructions = data.get("instructions", [])
if not instructions:
warnings.append("A recept nem tartalmaz elkészítési lépéseket.")
elif _is_redirect_instructions(instructions):
warnings.append("Az elkészítés egy másik oldalra mutat. "
"A recept valószínűleg nem tartalmaz valódi lépéseket.")
return jsonify({"ok": True, "data": data, "duplicate": duplicate,
"tandoor_duplicate": tandoor_duplicate,
"warnings": warnings})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()})
@app.route("/send", methods=["POST"])
def send_to_mealie():
"""AJAX — send edited recipe data to Mealie."""
cfg = config.load()
if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"):
return jsonify({"ok": False, "error": "Mealie nincs beállítva."})
payload = request.get_json(silent=True)
if not payload:
return jsonify({"ok": False, "error": "Érvénytelen kérés."})
try:
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
slug = client.create_recipe(payload)
recipe_url = f"{cfg['mealie_url']}/g/home/r/{slug}"
return jsonify({"ok": True, "slug": slug, "url": recipe_url})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()})
@app.route("/send-tandoor", methods=["POST"])
def send_to_tandoor():
"""AJAX — send edited recipe data to Tandoor."""
cfg = config.load()
if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"):
return jsonify({"ok": False, "error": "Tandoor nincs beállítva."})
payload = request.get_json(silent=True)
if not payload:
return jsonify({"ok": False, "error": "Érvénytelen kérés."})
try:
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
result = client.create_recipe(payload)
return jsonify({"ok": True, "id": result["id"], "url": result["url"]})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()})
@app.route("/recipes")
def recipes_page():
"""Show the recipes management page."""
cfg = config.load()
has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key"))
has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key"))
if not has_mealie and not has_tandoor:
flash("Először állíts be legalább egy szolgáltatást.", "warning")
return redirect(url_for("settings"))
return render_template("recipes.html", cfg=cfg, version=VERSION,
has_mealie=has_mealie, has_tandoor=has_tandoor)
@app.route("/recipes/<backend>/<path:recipe_id>/edit")
def recipe_edit_page(backend, recipe_id):
"""Show the recipe edit page."""
cfg = config.load()
has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key"))
has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key"))
return render_template("recipe_edit.html", cfg=cfg, version=VERSION,
backend=backend, recipe_id=recipe_id,
has_mealie=has_mealie, has_tandoor=has_tandoor)
@app.route("/api/recipes/<backend>")
def api_list_recipes(backend):
"""AJAX — list recipes from the given backend."""
cfg = config.load()
search = request.args.get("search", "").strip()
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 50))
tag_ids_raw = request.args.get("tag_ids", "").strip()
try:
if backend == "mealie":
if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"):
return jsonify({"ok": False, "error": "Mealie nincs beállítva."})
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
tag_ids = [t for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None
result = client.list_recipes(search=search, tag_ids=tag_ids,
page=page, per_page=per_page)
elif backend == "tandoor":
if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"):
return jsonify({"ok": False, "error": "Tandoor nincs beállítva."})
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
tag_ids = [int(t) for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None
result = client.list_recipes(search=search, keyword_ids=tag_ids,
page=page, per_page=per_page)
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
return jsonify({"ok": True, **result})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/api/recipes/<backend>/<path:recipe_id>")
def api_get_recipe(backend, recipe_id):
"""AJAX — get a single recipe in common format."""
cfg = config.load()
try:
if backend == "mealie":
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
recipe = client.get_recipe(recipe_id)
elif backend == "tandoor":
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
recipe = client.get_recipe(int(recipe_id))
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
return jsonify({"ok": True, "recipe": recipe})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/api/recipes/<backend>/<path:recipe_id>", methods=["PUT"])
def api_update_recipe(backend, recipe_id):
"""AJAX — update a single recipe."""
cfg = config.load()
payload = request.get_json(silent=True)
if not payload:
return jsonify({"ok": False, "error": "Érvénytelen kérés."})
try:
if backend == "mealie":
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
client.update_recipe(recipe_id, payload)
elif backend == "tandoor":
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
client.update_recipe(int(recipe_id), payload)
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
return jsonify({"ok": True, "message": "Recept sikeresen mentve."})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/api/recipes/<backend>/delete", methods=["POST"])
def api_delete_recipes(backend):
"""AJAX — delete one or more recipes."""
cfg = config.load()
payload = request.get_json(silent=True)
if not payload or not payload.get("ids"):
return jsonify({"ok": False, "error": "Nincs kiválasztott recept."})
ids = payload["ids"]
errors = []
try:
if backend == "mealie":
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
for slug in ids:
try:
client.delete_recipe(slug)
except Exception as e:
errors.append(f"{slug}: {e}")
elif backend == "tandoor":
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
for rid in ids:
try:
client.delete_recipe(int(rid))
except Exception as e:
errors.append(f"{rid}: {e}")
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
deleted = len(ids) - len(errors)
if errors:
return jsonify({"ok": True, "deleted": deleted, "errors": errors,
"message": f"{deleted} recept törölve, {len(errors)} hiba."})
return jsonify({"ok": True, "deleted": deleted,
"message": f"{deleted} recept sikeresen törölve."})
@app.route("/api/tags/<backend>")
def api_list_tags(backend):
"""Return tags/keywords with IDs for a specific backend."""
cfg = config.load()
try:
if backend == "mealie":
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
tags = client.list_tags()
elif backend == "tandoor":
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
tags = client.list_keywords()
else:
return jsonify({"ok": False, "error": "Ismeretlen backend."})
return jsonify({"ok": True, "tags": tags})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)})
@app.route("/tags", methods=["GET"])
def list_all_tags():
"""Return existing tags from Mealie and Tandoor for autocomplete."""
cfg = config.load()
mealie_tags = []
tandoor_tags = []
if cfg.get("mealie_url") and cfg.get("mealie_api_key"):
try:
client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"],
api_url=config.MEALIE_INTERNAL_URL)
mealie_tags = [t["name"] for t in client.list_tags()]
except Exception:
pass
if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"):
try:
client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"],
api_url=config.TANDOOR_INTERNAL_URL)
tandoor_tags = [t["name"] for t in client.list_keywords()]
except Exception:
pass
return jsonify({"mealie": mealie_tags, "tandoor": tandoor_tags})
# ---------------------------------------------------------------------------
# Assets
# ---------------------------------------------------------------------------
ASSETS_DIR = os.path.join(os.path.dirname(__file__), "assets")
@app.route("/assets/<path:filename>")
def serve_asset(filename):
"""Serve static assets (logo, etc.) from app/assets/."""
return send_from_directory(ASSETS_DIR, filename)
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.route("/health")
def health():
return jsonify({"status": "ok", "version": VERSION})