release nouri 0.6.0 polish backup and pwa
This commit is contained in:
+266
-59
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user