release nouri 0.6.0 polish backup and pwa

This commit is contained in:
2026-04-12 17:46:18 +02:00
parent 9ff7a6d57c
commit 555fddab80
31 changed files with 1257 additions and 164 deletions
+266 -59
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
import uuid
from collections import defaultdict
from datetime import date, datetime, timedelta
from itertools import product
from pathlib import Path
from flask import (
Blueprint,
after_this_request,
current_app,
flash,
g,
@@ -14,11 +15,12 @@ from flask import (
redirect,
render_template,
request,
send_file,
url_for,
)
from werkzeug.utils import secure_filename
from .auth import login_required
from .auth import admin_required, login_required
from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive
from .constants import (
AVAILABILITY_LABELS,
BUILDER_LABELS,
@@ -35,12 +37,15 @@ from .constants import (
WEEK_TEMPLATE_NAME_SUGGESTIONS,
)
from .db import get_db
from .images import (
allowed_image_file,
save_photo_with_variants,
upload_file_size_ok,
)
from .push import push_is_configured, push_public_key, send_push_message
main_bp = Blueprint("main", __name__)
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
ACTIVE_STATE_OPTIONS = [
("", "Alle aktiven"),
("home", "Zuhause"),
@@ -260,29 +265,14 @@ def normalize_target_user_id(raw: str | None) -> int | None:
return target_id if target_id in allowed else None
def allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def save_photo(upload, current_filename: str | None = None) -> str | None:
if not upload or not upload.filename:
return current_filename
if not allowed_file(upload.filename):
if not allowed_image_file(upload.filename):
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
original_name = secure_filename(upload.filename)
extension = original_name.rsplit(".", 1)[1].lower()
filename = f"{uuid.uuid4().hex}.{extension}"
destination = Path(current_app.config["UPLOAD_FOLDER"]) / filename
upload.save(destination)
if current_filename:
old_path = Path(current_app.config["UPLOAD_FOLDER"]) / current_filename
if old_path.exists():
old_path.unlink()
return filename
if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]):
raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.")
return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename)
def user_display_name(display_name: str | None, username: str | None) -> str:
@@ -1156,18 +1146,98 @@ def format_item_names(items: list[dict], limit: int = 3) -> str:
return ", ".join(item["name"] for item in items[:limit])
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id)
home_food_ids = {item["id"] for item in home_foods}
def item_matches_daypart(item: dict, daypart_id: int | None) -> bool:
if daypart_id is None:
return True
dayparts_meta = item.get("dayparts_meta") or []
if not dayparts_meta:
return True
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
return tuple(sorted({int(component_id) for component_id in component_ids}))
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
names = [item["name"] for item in combo]
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
return f"{names[0]} mit {', '.join(names[1:])}"
if len(names) >= 2:
return f"{names[0]} mit {', '.join(names[1:])}"
return names[0]
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
builder_groups: dict[str, list[dict]] = defaultdict(list)
for food in home_foods:
for builder_key in food.get("builder_keys", ["neutral"]):
builder_groups[builder_key].append(food)
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
target_patterns = [
("carb", "dairy", "fruit"),
("carb", "dairy", "nuts"),
("carb", "fruit", "dairy"),
]
reasons = {
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
}
else:
target_patterns = [
("protein", "carb", "veg"),
("protein", "carb"),
]
reasons = {
("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich",
("protein", "carb"): "Lässt sich leicht ergänzen",
}
suggestions: list[dict] = []
meals = fetch_items(kind="meal", daypart_id=daypart_id)
seen_signatures: set[tuple[int, ...]] = set()
for pattern in target_patterns:
groups = [builder_groups.get(builder_key, []) for builder_key in pattern]
if any(not group for group in groups):
continue
for combo in product(*groups):
signature = normalized_component_signature([item["id"] for item in combo])
if len(signature) != len(pattern) or signature in seen_signatures:
continue
seen_signatures.add(signature)
combo_items = list(combo)
suggestions.append(
{
"title": build_generated_meal_name(combo_items, daypart_slug),
"reason": reasons.get(pattern, "Zuhause gut kombinierbar"),
"component_ids": [item["id"] for item in combo_items],
"existing_item_id": None,
}
)
if len(suggestions) >= limit:
return suggestions
return suggestions
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
home_foods = [
item
for item in fetch_items(kind="food", availability="home")
if item_matches_daypart(item, daypart_id)
]
home_food_ids = {item["id"] for item in home_foods}
suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set()
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
signature = normalized_component_signature(meal["component_ids"])
if signature in seen_signatures:
continue
seen_signatures.add(signature)
suggestions.append(
{
"title": meal["name"],
@@ -1178,34 +1248,12 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
)
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
if builder_groups["carb"] and builder_groups["dairy"]:
combo = [builder_groups["carb"][0], builder_groups["dairy"][0]]
if builder_groups["fruit"]:
combo.append(builder_groups["fruit"][0])
if builder_groups["nuts"]:
combo.append(builder_groups["nuts"][0])
suggestions.append(
{
"title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}",
"reason": "Lässt sich gut ergänzen",
"component_ids": [item["id"] for item in combo],
"existing_item_id": None,
}
)
else:
if builder_groups["protein"] and builder_groups["carb"]:
combo = [builder_groups["protein"][0], builder_groups["carb"][0]]
if builder_groups["veg"]:
combo.append(builder_groups["veg"][0])
suggestions.append(
{
"title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}",
"reason": "Aus Zuhause zusammengesetzt",
"component_ids": [item["id"] for item in combo],
"existing_item_id": None,
}
)
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
signature = normalized_component_signature(suggestion["component_ids"])
if signature in seen_signatures:
continue
seen_signatures.add(signature)
suggestions.append(suggestion)
deduped: list[dict] = []
seen = set()
@@ -1336,6 +1384,77 @@ def build_dashboard_hints(today: date) -> list[str]:
return hints[:4]
def build_setup_checklist(today: date) -> list[dict]:
total_items = int(
get_db().execute(
f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}",
visible_params(),
).fetchone()["count"]
)
meal_count = int(
get_db().execute(
f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}",
visible_params(),
).fetchone()["count"]
)
week_end = today + timedelta(days=6)
plan_count = int(
get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM plan_entries
WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')}
""",
[today.isoformat(), week_end.isoformat(), *visible_params()],
).fetchone()["count"]
)
template_count = int(
get_db().execute(
f"SELECT COUNT(*) AS count FROM day_templates WHERE {visible_clause('day_templates')}",
visible_params(),
).fetchone()["count"]
)
checklist = []
if total_items == 0:
checklist.append(
{
"title": "Fang mit einem ersten Lebensmittel an",
"text": "Ein kleines Frühstück, ein Snack oder etwas für zuhause reicht völlig für den Start.",
"url": url_for("main.item_create", kind="food"),
"label": "Lebensmittel anlegen",
}
)
if total_items > 0 and meal_count == 0:
checklist.append(
{
"title": "Lege eine erste Mahlzeitenidee an",
"text": "Einfach zwei oder drei vertraute Dinge zusammenklicken und für später merken.",
"url": url_for("main.item_create", kind="meal"),
"label": "Mahlzeit anlegen",
}
)
if plan_count == 0:
checklist.append(
{
"title": "Plane einen ruhigen ersten Tag",
"text": "Mit einem kleinen Eintrag für Frühstück oder Abendessen fühlt sich die Woche sofort greifbarer an.",
"url": url_for("main.planner_day", date=today.isoformat()),
"label": "Tag öffnen",
}
)
if total_items > 0 and template_count == 0:
checklist.append(
{
"title": "Merke dir einen gelungenen Tag als Vorlage",
"text": "So wird Wiederverwendung später noch leichter.",
"url": url_for("main.template_library"),
"label": "Vorlagen ansehen",
}
)
return checklist[:3]
def build_day_hints(selected_date: date) -> list[str]:
settings = get_user_settings()
if not settings.get("reminders_enabled"):
@@ -1975,6 +2094,28 @@ def create_or_get_generated_meal(
daypart_id: int,
visibility: str,
) -> int:
normalized_ids = normalized_component_signature(component_ids)
existing_meals = [
item
for item in fetch_items(kind="meal")
if normalized_component_signature(item.get("component_ids", [])) == normalized_ids
]
if existing_meals:
meal_id = int(existing_meals[0]["id"])
current_dayparts = get_item_daypart_ids(meal_id)
if daypart_id not in current_dayparts:
sync_item_dayparts(meal_id, current_dayparts + [daypart_id])
get_db().execute(
"""
UPDATE items
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(g.user["id"], meal_id),
)
get_db().commit()
return meal_id
existing = get_db().execute(
f"""
SELECT items.id
@@ -1986,7 +2127,21 @@ def create_or_get_generated_meal(
[name, *visible_params()],
).fetchone()
if existing:
return int(existing["id"])
meal_id = int(existing["id"])
sync_meal_components(meal_id, list(normalized_ids))
current_dayparts = get_item_daypart_ids(meal_id)
if daypart_id not in current_dayparts:
sync_item_dayparts(meal_id, current_dayparts + [daypart_id])
get_db().execute(
"""
UPDATE items
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(g.user["id"], meal_id),
)
get_db().commit()
return meal_id
cursor = get_db().execute(
"""
@@ -2000,14 +2155,14 @@ def create_or_get_generated_meal(
g.user["id"],
visibility,
name,
"Kleines Essen",
"Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes",
g.user["id"],
g.user["id"],
),
)
meal_id = int(cursor.lastrowid)
sync_item_dayparts(meal_id, [daypart_id])
sync_meal_components(meal_id, component_ids)
sync_meal_components(meal_id, list(normalized_ids))
get_db().commit()
return meal_id
@@ -2074,6 +2229,7 @@ def dashboard():
upcoming_entries=fetch_upcoming_shopping_needs(limit=4),
day_templates=fetch_day_templates()[:3],
week_templates=fetch_week_templates()[:3],
setup_checklist=build_setup_checklist(today),
)
@@ -2525,9 +2681,60 @@ def settings_view():
push_subscription_count=int(push_subscription["count"]),
push_ready=push_is_configured(),
push_public_key_value=push_public_key(),
restore_confirmation_text=RESTORE_CONFIRMATION_TEXT,
)
@main_bp.get("/settings/backup/export")
@login_required
@admin_required
def backup_export():
archive_path, download_name = export_backup_archive(
get_db(),
current_app.config["UPLOAD_FOLDER"],
current_app.config["APP_VERSION"],
)
@after_this_request
def cleanup_backup(response):
Path(archive_path).unlink(missing_ok=True)
return response
return send_file(
archive_path,
as_attachment=True,
download_name=download_name,
mimetype="application/zip",
max_age=0,
)
@main_bp.post("/settings/backup/restore")
@login_required
@admin_required
def backup_restore():
confirmation = request.form.get("restore_confirmation", "").strip().upper()
backup_file = request.files.get("backup_file")
if confirmation != RESTORE_CONFIRMATION_TEXT:
flash("Bitte die Bestätigung genau eintragen, bevor das Backup wiederhergestellt wird.", "error")
return redirect(url_for("main.settings_view"))
if not backup_file or not backup_file.filename:
flash("Bitte zuerst eine Backup-Datei auswählen.", "error")
return redirect(url_for("main.settings_view"))
try:
metadata = restore_backup_archive(get_db(), current_app.config["UPLOAD_FOLDER"], backup_file)
get_db().commit()
except Exception as exc:
get_db().rollback()
flash(str(exc) or "Das Backup konnte gerade nicht wiederhergestellt werden.", "error")
return redirect(url_for("main.settings_view"))
version_label = metadata.get("app_version") or "einer älteren Version"
flash(f"Das Backup aus {version_label} wurde wiederhergestellt.", "success")
return redirect(url_for("main.settings_view"))
@main_bp.post("/push/subscribe")
@login_required
def push_subscribe():