Files
nouri-App/nouri/main.py
T

2453 lines
90 KiB
Python

from __future__ import annotations
import uuid
from collections import defaultdict
from datetime import date, datetime, timedelta
from pathlib import Path
from flask import (
Blueprint,
current_app,
flash,
g,
jsonify,
redirect,
render_template,
request,
url_for,
)
from werkzeug.utils import secure_filename
from .auth import login_required
from .constants import (
AVAILABILITY_LABELS,
DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEK_TEMPLATE_NAME_SUGGESTIONS,
)
from .db import get_db
main_bp = Blueprint("main", __name__)
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
ACTIVE_STATE_OPTIONS = [
("", "Alle aktiven"),
("home", "Zuhause"),
("idea", "Merkliste"),
]
KIND_FILTER_OPTIONS = [
("", "Alles"),
("food", "Lebensmittel"),
("meal", "Mahlzeitenideen"),
]
VISIBILITY_FILTER_OPTIONS = [
("", "Alles Sichtbare"),
("shared", "Gemeinsam"),
("personal", "Persönlich"),
]
VISIBILITY_FORM_OPTIONS = [
("shared", "Gemeinsam"),
("personal", "Persönlich"),
]
TARGET_USER_OPTIONS_DEFAULT = "__all__"
WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
def current_household_id() -> int:
return int(g.user["household_id"])
def get_dayparts() -> list:
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
def get_household_users(active_only: bool = True):
query = """
SELECT id, username, display_name, role
FROM users
WHERE household_id = ?
"""
params: list[object] = [current_household_id()]
if active_only:
query += " AND is_active = 1"
query += " ORDER BY LOWER(COALESCE(display_name, username))"
return get_db().execute(query, params).fetchall()
def get_target_user_options() -> list[dict]:
options = [{"value": TARGET_USER_OPTIONS_DEFAULT, "label": "Für alle"}]
for user in get_household_users():
options.append(
{
"value": str(user["id"]),
"label": user["display_name"] or user["username"],
}
)
return options
def get_category_options(include_inactive_selected: str | None = None) -> list[str]:
rows = get_db().execute(
"""
SELECT name
FROM household_categories
WHERE household_id = ? AND is_active = 1
ORDER BY sort_order, LOWER(name)
""",
(current_household_id(),),
).fetchall()
categories = [row["name"] for row in rows]
if not categories:
categories = DEFAULT_CATEGORIES[:]
if include_inactive_selected and include_inactive_selected not in categories:
categories.append(include_inactive_selected)
return categories
def visible_clause(table_alias: str) -> str:
return (
f"{table_alias}.household_id = ? "
f"AND ({table_alias}.visibility = 'shared' OR {table_alias}.owner_user_id = ?)"
)
def visible_params() -> list[int]:
return [current_household_id(), int(g.user["id"])]
def parse_week_start(raw: str | None) -> date:
if raw:
try:
parsed = datetime.strptime(raw, "%Y-%m-%d").date()
return parsed - timedelta(days=parsed.weekday())
except ValueError:
pass
today = date.today()
return today - timedelta(days=today.weekday())
def parse_plan_date(raw: str | None, fallback: date | None = None) -> date:
if raw:
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
pass
return fallback or date.today()
def normalize_visibility(raw: str | None, default: str = "shared") -> str:
return raw if raw in VISIBILITY_LABELS else default
def normalize_target_user_id(raw: str | None) -> int | None:
if not raw or raw == TARGET_USER_OPTIONS_DEFAULT:
return None
if not raw.isdigit():
return None
target_id = int(raw)
allowed = {int(user["id"]) for user in get_household_users()}
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):
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
def user_display_name(display_name: str | None, username: str | None) -> str:
return display_name or username or "Haushalt"
def describe_record(entry: dict) -> dict:
owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username"))
target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None
entry["owner_name"] = owner_name
entry["target_name"] = target_name
entry["is_personal"] = entry.get("visibility") == "personal"
entry["is_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle"
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
return entry
def describe_records(rows) -> list[dict]:
return [describe_record(dict(row)) for row in rows]
def describe_template_record(entry: dict) -> dict:
owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username"))
entry["owner_name"] = owner_name
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
entry["can_edit"] = entry.get("visibility") == "shared" or entry["is_mine"] or g.user["role"] == "admin"
return entry
def ensure_can_edit(entry: dict, error_message: str = "Diesen Eintrag kannst du gerade nicht bearbeiten.") -> None:
if not (entry.get("can_edit") or g.user["role"] == "admin"):
raise PermissionError(error_message)
def get_item(item_id: int) -> dict:
item = get_db().execute(
f"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
EXISTS(
SELECT 1
FROM shopping_entries
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
) AS is_on_shopping_list
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE items.id = ? AND {visible_clause('items')}
""",
[item_id, *visible_params()],
).fetchone()
if item is None:
raise ValueError("Der Eintrag wurde nicht gefunden.")
return describe_record(dict(item))
def get_item_daypart_ids(item_id: int) -> list[int]:
rows = get_db().execute(
"SELECT daypart_id FROM item_dayparts WHERE item_id = ?",
(item_id,),
).fetchall()
return [int(row["daypart_id"]) for row in rows]
def get_meal_component_ids(meal_id: int) -> list[int]:
rows = get_db().execute(
"""
SELECT meal_components.food_item_id
FROM meal_components
JOIN items ON items.id = meal_components.food_item_id
WHERE meal_components.meal_item_id = ?
AND items.household_id = ?
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
""",
(meal_id, current_household_id(), g.user["id"]),
).fetchall()
return [int(row["food_item_id"]) for row in rows]
def attach_dayparts(items: list[dict]) -> list[dict]:
if not items:
return []
item_ids = [item["id"] for item in items]
placeholders = ",".join("?" for _ in item_ids)
rows = get_db().execute(
f"""
SELECT item_dayparts.item_id, dayparts.id, dayparts.slug, dayparts.name
FROM item_dayparts
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
WHERE item_dayparts.item_id IN ({placeholders})
ORDER BY dayparts.sort_order
""",
item_ids,
).fetchall()
grouped = defaultdict(list)
for row in rows:
grouped[row["item_id"]].append(
{"id": row["id"], "slug": row["slug"], "name": row["name"]}
)
for item in items:
item["dayparts_meta"] = grouped.get(item["id"], [])
item["dayparts"] = [daypart["name"] for daypart in item["dayparts_meta"]]
item["primary_daypart_id"] = item["dayparts_meta"][0]["id"] if item["dayparts_meta"] else None
return items
def attach_components(items: list[dict]) -> list[dict]:
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
if not meal_ids:
for item in items:
item["components"] = []
item["component_ids"] = []
return items
placeholders = ",".join("?" for _ in meal_ids)
rows = get_db().execute(
f"""
SELECT meal_components.meal_item_id,
meal_components.food_item_id,
items.name
FROM meal_components
JOIN items ON items.id = meal_components.food_item_id
WHERE meal_components.meal_item_id IN ({placeholders})
AND items.household_id = ?
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
ORDER BY LOWER(items.name)
""",
[*meal_ids, current_household_id(), g.user["id"]],
).fetchall()
grouped_names: dict[int, list[str]] = defaultdict(list)
grouped_ids: dict[int, list[int]] = defaultdict(list)
for row in rows:
grouped_names[row["meal_item_id"]].append(row["name"])
grouped_ids[row["meal_item_id"]].append(int(row["food_item_id"]))
for item in items:
item["components"] = grouped_names.get(item["id"], [])
item["component_ids"] = grouped_ids.get(item["id"], [])
return items
def fetch_items(
*,
kind: str | None = None,
availability: str | None = None,
include_archived: bool = False,
query: str | None = None,
daypart_id: int | None = None,
visibility: str | None = None,
):
conditions = [visible_clause("items")]
params = visible_params()
if kind:
conditions.append("items.kind = ?")
params.append(kind)
if availability:
conditions.append("items.availability_state = ?")
params.append(availability)
elif not include_archived:
conditions.append("items.availability_state != 'archived'")
if query:
conditions.append("LOWER(items.name) LIKE ?")
params.append(f"%{query.lower()}%")
if daypart_id:
conditions.append(
"""
EXISTS (
SELECT 1
FROM item_dayparts
WHERE item_dayparts.item_id = items.id
AND item_dayparts.daypart_id = ?
)
"""
)
params.append(daypart_id)
if visibility:
conditions.append("items.visibility = ?")
params.append(visibility)
rows = get_db().execute(
f"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
EXISTS(
SELECT 1
FROM shopping_entries
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
) AS is_on_shopping_list
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE {' AND '.join(conditions)}
ORDER BY
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END,
LOWER(items.name)
""",
params,
).fetchall()
return attach_components(attach_dayparts(describe_records(rows)))
def fetch_food_options(query: str | None = None):
return fetch_items(kind="food", include_archived=True, query=query)
def group_items_by_availability(items: list[dict]) -> list[dict]:
grouped = defaultdict(list)
for item in items:
grouped[item["availability_state"]].append(item)
result = []
for state in ("home", "idea", "archived"):
entries = grouped.get(state, [])
if entries:
result.append({"state": state, "title": AVAILABILITY_LABELS[state], "items": entries})
return result
def extract_item_form_data(existing: dict | None = None) -> dict:
form_data = existing or {}
form_data.update(
{
"name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").strip(),
"note": request.form.get("note", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
"food_search": request.form.get("food_search", "").strip(),
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()],
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
"quick_food_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").strip(),
"quick_food_note": request.form.get("quick_food_note", "").strip(),
}
)
return form_data
def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, created_by, updated_by
)
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
form_data["target_user_id"],
form_data["visibility"],
form_data["quick_food_name"],
form_data["quick_food_category"],
form_data["quick_food_note"],
g.user["id"],
g.user["id"],
),
)
food_id = int(cursor.lastrowid)
sync_item_dayparts(food_id, form_data["daypart_ids"])
get_db().commit()
return food_id
def add_to_shopping_list(
item_id: int,
user_id: int,
*,
visibility_override: str | None = None,
needed_for_date: str | None = None,
needed_for_daypart_id: int | None = None,
) -> bool:
item = get_item(item_id)
existing = get_db().execute(
"""
SELECT id FROM shopping_entries
WHERE item_id = ? AND is_checked = 0
""",
(item_id,),
).fetchone()
if existing:
return False
visibility = normalize_visibility(visibility_override, item["visibility"])
owner_user_id = user_id if visibility == "personal" else item["owner_user_id"]
get_db().execute(
"""
INSERT INTO shopping_entries (
household_id, owner_user_id, visibility, item_id, added_by, needed_for_date, needed_for_daypart_id
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
owner_user_id,
visibility,
item_id,
user_id,
needed_for_date,
needed_for_daypart_id,
),
)
get_db().commit()
return True
def fetch_meal_missing_components(meal_id: int) -> list[dict]:
rows = get_db().execute(
"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
0 AS is_on_shopping_list
FROM meal_components
JOIN items ON items.id = meal_components.food_item_id
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE meal_components.meal_item_id = ?
AND items.household_id = ?
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
AND items.availability_state != 'home'
ORDER BY LOWER(items.name)
""",
(meal_id, current_household_id(), g.user["id"]),
).fetchall()
return attach_dayparts(describe_records(rows))
def ensure_item_or_missing_components_are_shopped(
item_id: int,
user_id: int,
visibility: str,
*,
needed_for_date: str | None = None,
needed_for_daypart_id: int | None = None,
) -> dict:
item = get_item(item_id)
if item["kind"] == "meal":
missing_components = fetch_meal_missing_components(item_id)
if missing_components:
added_names = []
for component in missing_components:
added = add_to_shopping_list(
component["id"],
user_id,
visibility_override=visibility,
needed_for_date=needed_for_date,
needed_for_daypart_id=needed_for_daypart_id,
)
if added:
added_names.append(component["name"])
return {
"added": bool(added_names),
"count": len(added_names),
"names": added_names,
"used_components": True,
}
return {"added": False, "count": 0, "names": [], "used_components": True}
if item["availability_state"] == "home":
return {"added": False, "count": 0, "names": [], "used_components": False}
added = add_to_shopping_list(
item_id,
user_id,
visibility_override=visibility,
needed_for_date=needed_for_date,
needed_for_daypart_id=needed_for_daypart_id,
)
return {
"added": added,
"count": 1 if added else 0,
"names": [item["name"]] if added else [],
"used_components": False,
}
def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None:
get_db().execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,))
for daypart_id in daypart_ids:
get_db().execute(
"INSERT INTO item_dayparts (item_id, daypart_id) VALUES (?, ?)",
(item_id, daypart_id),
)
def sync_meal_components(meal_id: int, food_ids: list[int]) -> None:
get_db().execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,))
visible_foods = {
row["id"]
for row in get_db().execute(
f"""
SELECT items.id
FROM items
WHERE items.kind = 'food' AND {visible_clause('items')}
""",
visible_params(),
).fetchall()
}
for food_id in food_ids:
if food_id in visible_foods:
get_db().execute(
"INSERT INTO meal_components (meal_item_id, food_item_id) VALUES (?, ?)",
(meal_id, food_id),
)
def fetch_shopping_entries():
rows = get_db().execute(
f"""
SELECT shopping_entries.*,
items.name AS item_name,
items.kind AS item_kind,
items.photo_filename,
items.availability_state,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
dayparts.name AS needed_daypart_name
FROM shopping_entries
JOIN items ON items.id = shopping_entries.item_id
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
LEFT JOIN dayparts ON dayparts.id = shopping_entries.needed_for_daypart_id
WHERE shopping_entries.is_checked = 0 AND {visible_clause('shopping_entries')}
ORDER BY
CASE shopping_entries.visibility WHEN 'shared' THEN 0 ELSE 1 END,
shopping_entries.added_at DESC
""",
visible_params(),
).fetchall()
entries = describe_records(rows)
for entry in entries:
if entry.get("needed_for_date"):
try:
parsed = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date()
entry["needed_for_label"] = parsed.strftime("%d.%m.%Y")
except ValueError:
entry["needed_for_label"] = entry["needed_for_date"]
else:
entry["needed_for_label"] = None
return entries
def fetch_plan_entries_for_range(start_date: date, end_date: date):
rows = get_db().execute(
f"""
SELECT plan_entries.*,
items.name AS item_name,
items.kind AS item_kind,
items.photo_filename,
items.availability_state,
dayparts.name AS daypart_name,
dayparts.slug AS daypart_slug,
dayparts.sort_order,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username
FROM plan_entries
JOIN items ON items.id = plan_entries.item_id
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')}
ORDER BY plan_date, dayparts.sort_order, items.name
""",
[start_date.isoformat(), end_date.isoformat(), *visible_params()],
).fetchall()
grouped = defaultdict(list)
for row in describe_records(rows):
grouped[(row["plan_date"], row["daypart_id"])].append(row)
return grouped
def fetch_day_plan_entries(selected_date: date):
return fetch_plan_entries_for_range(selected_date, selected_date)
def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
rows = get_db().execute(
f"""
SELECT DISTINCT items.id,
items.household_id,
items.owner_user_id,
items.target_user_id,
items.visibility,
items.name,
items.kind,
items.category,
items.note,
items.photo_filename,
items.availability_state,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username
FROM plan_entries
JOIN items ON items.id = plan_entries.item_id
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE plan_entries.daypart_id = ? AND {visible_clause('items')}
ORDER BY plan_entries.created_at DESC
LIMIT ?
""",
[daypart_id, *visible_params(), limit * 3],
).fetchall()
return attach_components(attach_dayparts(describe_records(rows)))
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
params = [daypart_id, *visible_params()]
conditions = [visible_clause("items"), "items.availability_state != 'archived'"]
if query:
conditions.append("LOWER(items.name) LIKE ?")
params.append(f"%{query.lower()}%")
rows = get_db().execute(
f"""
SELECT items.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username,
EXISTS(
SELECT 1
FROM item_dayparts
WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ?
) AS matches_daypart
FROM items
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE {' AND '.join(conditions)}
ORDER BY
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
matches_daypart DESC,
CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END,
LOWER(items.name)
""",
params,
).fetchall()
return attach_components(attach_dayparts(describe_records(rows)))
def fetch_home_food_ids() -> set[int]:
rows = get_db().execute(
f"""
SELECT id
FROM items
WHERE kind = 'food' AND availability_state = 'home' AND {visible_clause('items')}
""",
visible_params(),
).fetchall()
return {int(row["id"]) for row in rows}
def build_daypart_suggestions(daypart_id: int) -> list[dict]:
home_food_ids = fetch_home_food_ids()
suggestions: list[dict] = []
meals = fetch_items(kind="meal", daypart_id=daypart_id)
for meal in meals:
if not meal["component_ids"]:
continue
if all(component_id in home_food_ids for component_id in meal["component_ids"]):
suggestions.append(
{
"title": meal["name"],
"reason": "Zuhause vorhanden",
"note": "Die Bestandteile sind gerade da und passen gut dazu.",
}
)
archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id)
for item in archived_items[:2]:
suggestions.append(
{
"title": item["name"],
"reason": "Für später merken",
"note": "War schon einmal dabei und könnte heute wieder passen.",
}
)
often_used = get_db().execute(
f"""
SELECT items.name, COUNT(*) AS usage_count
FROM plan_entries
JOIN items ON items.id = plan_entries.item_id
WHERE plan_entries.daypart_id = ? AND {visible_clause('items')}
GROUP BY items.id, items.name
HAVING COUNT(*) > 1
ORDER BY usage_count DESC, LOWER(items.name)
LIMIT 2
""",
[daypart_id, *visible_params()],
).fetchall()
for row in often_used:
suggestions.append(
{
"title": row["name"],
"reason": "Oft gemeinsam genutzt",
"note": "Ist in diesem Zeitfenster schon öfter aufgetaucht.",
}
)
deduped = []
seen = set()
for suggestion in suggestions:
key = suggestion["title"]
if key in seen:
continue
seen.add(key)
deduped.append(suggestion)
if len(deduped) >= 4:
break
return deduped
def build_dashboard_hints(today: date) -> list[str]:
hints: list[str] = []
tomorrow = today + timedelta(days=1)
breakfast = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM plan_entries
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')}
""",
[tomorrow.isoformat(), *visible_params()],
).fetchone()
if int(breakfast["count"]) == 0:
hints.append("Für morgen ist noch kein Frühstück eingeplant.")
dinner_home = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM items
JOIN item_dayparts ON item_dayparts.item_id = items.id
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
WHERE items.availability_state = 'home'
AND dayparts.slug = 'dinner'
AND {visible_clause('items')}
""",
visible_params(),
).fetchone()
if int(dinner_home["count"]) > 0:
hints.append("Zuhause ist bereits etwas für Abendessen da.")
old_template = get_db().execute(
f"""
SELECT name
FROM day_templates
WHERE {visible_clause('day_templates')}
AND last_used_at IS NOT NULL
AND DATE(last_used_at) <= DATE('now', '-21 day')
ORDER BY last_used_at ASC
LIMIT 1
""",
visible_params(),
).fetchone()
if old_template:
hints.append(f"Die Tagesvorlage „{old_template['name']}“ wurde länger nicht genutzt.")
return hints[:3]
def build_day_hints(selected_date: date) -> list[str]:
hints: list[str] = []
breakfast_count = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM plan_entries
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')}
""",
[selected_date.isoformat(), *visible_params()],
).fetchone()
if int(breakfast_count["count"]) == 0:
hints.append("Für diesen Tag ist noch kein Frühstück eingeplant.")
afternoon_options = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM items
JOIN item_dayparts ON item_dayparts.item_id = items.id
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
WHERE items.availability_state != 'archived'
AND dayparts.slug = 'afternoon-snack'
AND {visible_clause('items')}
""",
visible_params(),
).fetchone()
if int(afternoon_options["count"]) < 2:
hints.append("Für den Nachmittag gibt es gerade wenig eingeplante Optionen.")
return hints[:3]
def build_week_hints(week_start: date) -> list[str]:
hints: list[str] = []
week_end = week_start + timedelta(days=6)
breakfast_days = get_db().execute(
f"""
SELECT COUNT(DISTINCT plan_entries.plan_date) AS count
FROM plan_entries
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
WHERE plan_entries.plan_date BETWEEN ? AND ?
AND dayparts.slug = 'breakfast'
AND {visible_clause('plan_entries')}
""",
[week_start.isoformat(), week_end.isoformat(), *visible_params()],
).fetchone()
missing_breakfasts = 7 - int(breakfast_days["count"])
if missing_breakfasts > 0:
hints.append(f"In dieser Woche sind noch {missing_breakfasts} Tage ohne Frühstücksplan.")
home_dinners = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM items
JOIN item_dayparts ON item_dayparts.item_id = items.id
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
WHERE items.availability_state = 'home'
AND dayparts.slug = 'dinner'
AND {visible_clause('items')}
""",
visible_params(),
).fetchone()
if int(home_dinners["count"]) > 0:
hints.append("Zuhause sind bereits Dinge da, die gut zu Abendessen passen.")
return hints[:3]
def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None):
sections = []
if selected_daypart_id:
selected_daypart = next((daypart for daypart in dayparts if daypart["id"] == selected_daypart_id), None)
matching_items = [item for item in items if any(dp["id"] == selected_daypart_id for dp in item["dayparts_meta"])]
sections.append(
{
"title": selected_daypart["name"] if selected_daypart else "Ausgewählte Tageszeit",
"items": matching_items,
}
)
return sections
for daypart in dayparts:
matching_items = [item for item in items if any(dp["id"] == daypart["id"] for dp in item["dayparts_meta"])]
sections.append({"title": daypart["name"], "items": matching_items})
anytime_items = [item for item in items if not item["dayparts_meta"]]
if anytime_items:
sections.append({"title": "Ohne feste Tageszeit", "items": anytime_items})
return sections
def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
result = []
seen_ids = set()
for item in items:
if item["id"] in seen_ids:
continue
seen_ids.add(item["id"])
result.append(item)
if len(result) >= limit:
break
return result
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
sections = []
day_entries = fetch_day_plan_entries(selected_date)
for daypart in get_dayparts():
candidates = fetch_plan_candidates(daypart["id"])
home_candidates = [item for item in candidates if item["availability_state"] == "home"]
matching_candidates = [item for item in candidates if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"])]
recent_candidates = fetch_recent_plan_items(daypart["id"])
quick_items = dedupe_items(home_candidates + recent_candidates + matching_candidates, limit=6)
entries = day_entries.get((selected_date.isoformat(), daypart["id"]), [])
sections.append(
{
"daypart": daypart,
"entries": entries,
"candidates": candidates,
"quick_items": quick_items,
"suggestions": build_daypart_suggestions(daypart["id"]),
"selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None,
"is_open": selected_daypart_id == daypart["id"],
"summary_items": [entry["item_name"] for entry in entries][:2],
"default_visibility": "shared",
}
)
return sections
def build_template_day_sections(selected_map: dict[int, list[int]] | None = None):
selected_map = selected_map or {}
sections = []
for daypart in get_dayparts():
candidates = fetch_plan_candidates(int(daypart["id"]))
quick_items = dedupe_items(
[item for item in candidates if item["availability_state"] == "home"] + candidates,
limit=10,
)
quick_ids = {item["id"] for item in quick_items}
sections.append(
{
"daypart": daypart,
"candidates": candidates,
"quick_items": quick_items,
"list_items": [item for item in candidates if item["id"] not in quick_ids],
"selected_ids": selected_map.get(int(daypart["id"]), []),
}
)
return sections
def fetch_week_cards(week_start: date):
days = [week_start + timedelta(days=index) for index in range(7)]
grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6))
cards = []
for current_day in days:
filled_dayparts = []
planned_count = 0
preview_items = []
slots = []
for daypart in get_dayparts():
slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
slots.append({"daypart": dict(daypart), "entries": slot_entries})
if slot_entries:
filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)})
planned_count += len(slot_entries)
preview_items.extend(entry["item_name"] for entry in slot_entries[:2])
cards.append(
{
"date": current_day,
"filled_dayparts": filled_dayparts,
"planned_count": planned_count,
"preview_items": preview_items[:4],
"slots": slots,
}
)
return cards
def count_visible_items(availability_state: str) -> int:
row = get_db().execute(
f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}",
[availability_state, *visible_params()],
).fetchone()
return int(row["count"])
def fetch_day_templates(query: str | None = None, visibility: str | None = None) -> list[dict]:
conditions = [visible_clause("day_templates")]
params = visible_params()
if query:
conditions.append("LOWER(day_templates.name) LIKE ?")
params.append(f"%{query.lower()}%")
if visibility:
conditions.append("day_templates.visibility = ?")
params.append(visibility)
rows = get_db().execute(
f"""
SELECT day_templates.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
(
SELECT COUNT(*)
FROM day_template_entries
WHERE day_template_entries.day_template_id = day_templates.id
) AS entry_count
FROM day_templates
LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id
WHERE {' AND '.join(conditions)}
ORDER BY LOWER(day_templates.name)
""",
params,
).fetchall()
return [describe_template_record(dict(row)) for row in rows]
def get_day_template(template_id: int) -> dict:
row = get_db().execute(
f"""
SELECT day_templates.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM day_templates
LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id
WHERE day_templates.id = ? AND {visible_clause('day_templates')}
""",
[template_id, *visible_params()],
).fetchone()
if row is None:
raise ValueError("Die Tagesvorlage wurde nicht gefunden.")
return describe_template_record(dict(row))
def get_day_template_selected_map(template_id: int) -> dict[int, list[int]]:
rows = get_db().execute(
"""
SELECT daypart_id, item_id
FROM day_template_entries
WHERE day_template_id = ?
ORDER BY sort_order, id
""",
(template_id,),
).fetchall()
grouped: dict[int, list[int]] = defaultdict(list)
for row in rows:
grouped[int(row["daypart_id"])].append(int(row["item_id"]))
return grouped
def sync_day_template_entries(template_id: int, selected_map: dict[int, list[int]]) -> None:
get_db().execute("DELETE FROM day_template_entries WHERE day_template_id = ?", (template_id,))
for daypart in get_dayparts():
for sort_order, item_id in enumerate(selected_map.get(int(daypart["id"]), []), start=10):
get_db().execute(
"""
INSERT INTO day_template_entries (day_template_id, daypart_id, item_id, sort_order)
VALUES (?, ?, ?, ?)
""",
(template_id, daypart["id"], item_id, sort_order),
)
def day_template_form_data(template: dict | None = None, source_date: date | None = None) -> dict:
selected_map: dict[int, list[int]] = defaultdict(list)
if template:
selected_map.update(get_day_template_selected_map(template["id"]))
elif source_date:
for (plan_date_key, daypart_id), entries in fetch_day_plan_entries(source_date).items():
if plan_date_key == source_date.isoformat():
selected_map[int(daypart_id)] = [int(entry["item_id"]) for entry in entries]
form_data: dict[str, object] = {
"name": template["name"] if template else "",
"description": template["description"] if template else "",
"visibility": template["visibility"] if template else "shared",
"selected_map": {key: value[:] for key, value in selected_map.items()},
}
return form_data
def extract_day_template_form_data(existing: dict | None = None) -> dict:
form_data = existing or {}
selected_map: dict[int, list[int]] = {}
for daypart in get_dayparts():
selected_map[int(daypart["id"])] = [
int(value)
for value in request.form.getlist(f"daypart_{daypart['id']}_item_ids")
if value.isdigit()
]
form_data.update(
{
"name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"selected_map": selected_map,
}
)
return form_data
def apply_day_template(template_id: int, selected_date: date) -> int:
template = get_day_template(template_id)
entries_map = get_day_template_selected_map(template_id)
inserted = 0
for daypart_id, item_ids in entries_map.items():
for item_id in item_ids:
existing = get_db().execute(
"""
SELECT id
FROM plan_entries
WHERE plan_date = ? AND daypart_id = ? AND item_id = ? AND visibility = ?
""",
(selected_date.isoformat(), daypart_id, item_id, template["visibility"]),
).fetchone()
if existing:
continue
get_db().execute(
"""
INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
template["visibility"],
selected_date.isoformat(),
daypart_id,
item_id,
g.user["id"],
),
)
insert_result = ensure_item_or_missing_components_are_shopped(
item_id,
g.user["id"],
template["visibility"],
needed_for_date=selected_date.isoformat(),
needed_for_daypart_id=daypart_id,
)
inserted += 1
get_db().commit()
get_db().execute(
"UPDATE day_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(template_id,),
)
get_db().commit()
return inserted
def create_day_template_snapshot(name: str, description: str, visibility: str, source_date: date) -> int | None:
selected_map = day_template_form_data(source_date=source_date)["selected_map"]
if not any(selected_map.values()):
return None
cursor = get_db().execute(
"""
INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description)
VALUES (?, ?, ?, ?, ?)
""",
(current_household_id(), g.user["id"], visibility, name, description),
)
template_id = int(cursor.lastrowid)
sync_day_template_entries(template_id, selected_map)
return template_id
def fetch_week_templates(query: str | None = None, visibility: str | None = None) -> list[dict]:
conditions = [visible_clause("week_templates")]
params = visible_params()
if query:
conditions.append("LOWER(week_templates.name) LIKE ?")
params.append(f"%{query.lower()}%")
if visibility:
conditions.append("week_templates.visibility = ?")
params.append(visibility)
rows = get_db().execute(
f"""
SELECT week_templates.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
(
SELECT COUNT(*)
FROM week_template_days
WHERE week_template_days.week_template_id = week_templates.id
) AS day_count
FROM week_templates
LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id
WHERE {' AND '.join(conditions)}
ORDER BY LOWER(week_templates.name)
""",
params,
).fetchall()
return [describe_template_record(dict(row)) for row in rows]
def get_week_template(template_id: int) -> dict:
row = get_db().execute(
f"""
SELECT week_templates.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM week_templates
LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id
WHERE week_templates.id = ? AND {visible_clause('week_templates')}
""",
[template_id, *visible_params()],
).fetchone()
if row is None:
raise ValueError("Die Wochenvorlage wurde nicht gefunden.")
return describe_template_record(dict(row))
def get_week_template_selected_map(template_id: int) -> dict[int, int]:
rows = get_db().execute(
"""
SELECT weekday_index, day_template_id
FROM week_template_days
WHERE week_template_id = ?
ORDER BY weekday_index
""",
(template_id,),
).fetchall()
return {int(row["weekday_index"]): int(row["day_template_id"]) for row in rows}
def week_template_form_data(template: dict | None = None, source_week: date | None = None) -> dict:
selected_map = get_week_template_selected_map(template["id"]) if template else {}
source_days = {}
if source_week:
for weekday_index in range(7):
source_days[weekday_index] = fetch_day_plan_entries(source_week + timedelta(days=weekday_index))
return {
"name": template["name"] if template else "",
"description": template["description"] if template else "",
"visibility": template["visibility"] if template else "shared",
"selected_map": selected_map,
"source_week": source_week.isoformat() if source_week else "",
"copy_from_source": {
index: bool(source_days.get(index))
for index in range(7)
},
}
def extract_week_template_form_data(existing: dict | None = None) -> dict:
form_data = existing or {}
selected_map: dict[int, int | None] = {}
copy_from_source: dict[int, bool] = {}
for weekday_index in range(7):
raw_value = request.form.get(f"weekday_{weekday_index}_day_template_id", "").strip()
selected_map[weekday_index] = int(raw_value) if raw_value.isdigit() else None
copy_from_source[weekday_index] = request.form.get(f"weekday_{weekday_index}_copy_source") == "1"
form_data.update(
{
"name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"selected_map": selected_map,
"source_week": request.form.get("source_week", "").strip(),
"copy_from_source": copy_from_source,
}
)
return form_data
def sync_week_template_days(template_id: int, selected_map: dict[int, int | None]) -> None:
get_db().execute("DELETE FROM week_template_days WHERE week_template_id = ?", (template_id,))
for weekday_index, day_template_id in selected_map.items():
if day_template_id:
get_db().execute(
"""
INSERT INTO week_template_days (week_template_id, weekday_index, day_template_id)
VALUES (?, ?, ?)
""",
(template_id, weekday_index, day_template_id),
)
def apply_week_template(template_id: int, week_start: date) -> int:
selected_map = get_week_template_selected_map(template_id)
inserted = 0
for weekday_index, day_template_id in selected_map.items():
inserted += apply_day_template(day_template_id, week_start + timedelta(days=weekday_index))
get_db().execute(
"UPDATE week_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(template_id,),
)
get_db().commit()
return inserted
def fetch_item_sets(query: str | None = None, visibility: str | None = None) -> list[dict]:
conditions = [visible_clause("item_sets")]
params = visible_params()
if query:
conditions.append("LOWER(item_sets.name) LIKE ?")
params.append(f"%{query.lower()}%")
if visibility:
conditions.append("item_sets.visibility = ?")
params.append(visibility)
rows = get_db().execute(
f"""
SELECT item_sets.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
(
SELECT COUNT(*)
FROM item_set_items
WHERE item_set_items.item_set_id = item_sets.id
) AS item_count
FROM item_sets
LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id
WHERE {' AND '.join(conditions)}
ORDER BY LOWER(item_sets.name)
""",
params,
).fetchall()
return [describe_template_record(dict(row)) for row in rows]
def get_item_set(set_id: int) -> dict:
row = get_db().execute(
f"""
SELECT item_sets.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM item_sets
LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id
WHERE item_sets.id = ? AND {visible_clause('item_sets')}
""",
[set_id, *visible_params()],
).fetchone()
if row is None:
raise ValueError("Das Paket wurde nicht gefunden.")
return describe_template_record(dict(row))
def get_item_set_selected_ids(set_id: int) -> list[int]:
rows = get_db().execute(
"""
SELECT item_id
FROM item_set_items
WHERE item_set_id = ?
ORDER BY sort_order, id
""",
(set_id,),
).fetchall()
return [int(row["item_id"]) for row in rows]
def sync_item_set_items(set_id: int, item_ids: list[int]) -> None:
get_db().execute("DELETE FROM item_set_items WHERE item_set_id = ?", (set_id,))
for sort_order, item_id in enumerate(item_ids, start=10):
get_db().execute(
"""
INSERT OR IGNORE INTO item_set_items (item_set_id, item_id, sort_order)
VALUES (?, ?, ?)
""",
(set_id, item_id, sort_order),
)
def extract_item_set_form_data(existing: dict | None = None) -> dict:
form_data = existing or {}
form_data.update(
{
"name": request.form.get("name", "").strip(),
"description": request.form.get("description", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"item_ids": [int(value) for value in request.form.getlist("item_ids") if value.isdigit()],
"item_search": request.form.get("item_search", "").strip(),
}
)
return form_data
def apply_item_set_to_shopping(set_id: int) -> dict:
item_set = get_item_set(set_id)
selected_ids = get_item_set_selected_ids(set_id)
added_names: list[str] = []
for item_id in selected_ids:
result = ensure_item_or_missing_components_are_shopped(
item_id,
g.user["id"],
item_set["visibility"],
)
added_names.extend(result["names"])
get_db().execute(
"UPDATE item_sets SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
(set_id,),
)
get_db().commit()
return {"count": len(added_names), "names": added_names}
def render_item_form(kind: str, *, item: dict | None, form_data: dict):
food_search = form_data.get("food_search") or None
foods = fetch_food_options(query=food_search if kind == "meal" else None)
return render_template(
"items/form.html",
kind=kind,
item=item,
dayparts=get_dayparts(),
food_groups=group_items_by_availability(foods),
categories=get_category_options(
form_data.get("category") or form_data.get("quick_food_category")
),
form_data=form_data,
visibility_options=VISIBILITY_FORM_OPTIONS,
target_user_options=get_target_user_options(),
)
def planner_template_options():
return fetch_day_templates()
@main_bp.get("/")
@login_required
def dashboard():
today = date.today()
home_count = count_visible_items("home")
archive_count = count_visible_items("archived")
shopping_count = get_db().execute(
f"SELECT COUNT(*) AS count FROM shopping_entries WHERE is_checked = 0 AND {visible_clause('shopping_entries')}",
visible_params(),
).fetchone()["count"]
today_entries = []
for entries in fetch_day_plan_entries(today).values():
today_entries.extend(entries)
today_entries.sort(key=lambda entry: (entry["sort_order"], entry["item_name"].lower()))
week_cards = fetch_week_cards(today - timedelta(days=today.weekday()))
home_items = fetch_items(availability="home")
return render_template(
"dashboard.html",
home_count=home_count,
shopping_count=shopping_count,
archive_count=archive_count,
today_entries=today_entries,
home_items=home_items[:8],
today=today,
week_cards=week_cards[:3],
dashboard_hints=build_dashboard_hints(today),
day_templates=fetch_day_templates()[:3],
week_templates=fetch_week_templates()[:3],
)
@main_bp.get("/templates")
@login_required
def template_library():
query = request.args.get("q", "").strip()
selected_visibility = request.args.get("visibility", "").strip()
visibility = selected_visibility or None
day_templates = fetch_day_templates(query=query or None, visibility=visibility)
week_templates = fetch_week_templates(query=query or None, visibility=visibility)
item_sets = fetch_item_sets(query=query or None, visibility=visibility)
template_hints = build_dashboard_hints(date.today())
return render_template(
"library/index.html",
query=query,
selected_visibility=selected_visibility,
visibility_options=VISIBILITY_FILTER_OPTIONS,
day_templates=day_templates,
week_templates=week_templates,
item_sets=item_sets,
template_hints=template_hints,
)
@main_bp.route("/templates/day/new", methods=("GET", "POST"))
@login_required
def day_template_create():
source_date = parse_plan_date(request.values.get("source_date"), fallback=None) if request.values.get("source_date") else None
form_data = day_template_form_data(source_date=source_date)
if request.method == "POST":
form_data = extract_day_template_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error")
else:
cursor = get_db().execute(
"""
INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description)
VALUES (?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
form_data["visibility"],
form_data["name"],
form_data["description"],
),
)
template_id = int(cursor.lastrowid)
sync_day_template_entries(template_id, form_data["selected_map"])
get_db().commit()
flash("Die Tagesvorlage wurde gespeichert.", "success")
return redirect(url_for("main.template_library"))
return render_template(
"library/day_form.html",
template=None,
form_data=form_data,
dayparts=get_dayparts(),
daypart_sections=build_template_day_sections(form_data["selected_map"]),
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS,
source_date=source_date,
)
@main_bp.route("/templates/day/<int:template_id>/edit", methods=("GET", "POST"))
@login_required
def day_template_edit(template_id: int):
try:
template = get_day_template(template_id)
ensure_can_edit(template, "Diese Tagesvorlage kannst du gerade nicht bearbeiten.")
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_for("main.template_library"))
form_data = day_template_form_data(template=template)
if request.method == "POST":
form_data = extract_day_template_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error")
else:
get_db().execute(
"""
UPDATE day_templates
SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
form_data["name"],
form_data["description"],
form_data["visibility"],
template_id,
),
)
sync_day_template_entries(template_id, form_data["selected_map"])
get_db().commit()
flash("Die Tagesvorlage wurde aktualisiert.", "success")
return redirect(url_for("main.template_library"))
return render_template(
"library/day_form.html",
template=template,
form_data=form_data,
dayparts=get_dayparts(),
daypart_sections=build_template_day_sections(form_data["selected_map"]),
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS,
source_date=None,
)
@main_bp.post("/templates/day/<int:template_id>/apply")
@login_required
def day_template_apply(template_id: int):
selected_date = parse_plan_date(request.form.get("target_date"))
inserted = apply_day_template(template_id, selected_date)
flash(f"Die Tagesvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success")
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
@main_bp.route("/templates/week/new", methods=("GET", "POST"))
@login_required
def week_template_create():
source_week_raw = request.values.get("source_week", "").strip()
source_week = parse_week_start(source_week_raw) if source_week_raw else None
form_data = week_template_form_data(source_week=source_week)
if request.method == "POST":
form_data = extract_week_template_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error")
else:
cursor = get_db().execute(
"""
INSERT INTO week_templates (household_id, owner_user_id, visibility, name, description)
VALUES (?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
form_data["visibility"],
form_data["name"],
form_data["description"],
),
)
template_id = int(cursor.lastrowid)
selected_map = dict(form_data["selected_map"])
if form_data["source_week"]:
source_start = parse_week_start(form_data["source_week"])
for weekday_index in range(7):
if selected_map.get(weekday_index):
continue
if form_data["copy_from_source"].get(weekday_index):
snapshot_date = source_start + timedelta(days=weekday_index)
snapshot_name = f"{form_data['name']} · {WEEKDAY_LABELS[weekday_index]}"
snapshot_id = create_day_template_snapshot(
snapshot_name,
f"Automatisch aus der Woche {source_start.strftime('%d.%m.%Y')} übernommen.",
form_data["visibility"],
snapshot_date,
)
if snapshot_id:
selected_map[weekday_index] = snapshot_id
sync_week_template_days(template_id, selected_map)
get_db().commit()
flash("Die Wochenvorlage wurde gespeichert.", "success")
return redirect(url_for("main.template_library"))
return render_template(
"library/week_form.html",
template=None,
form_data=form_data,
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS,
day_templates=fetch_day_templates(),
weekday_labels=WEEKDAY_LABELS,
source_week=source_week,
)
@main_bp.route("/templates/week/<int:template_id>/edit", methods=("GET", "POST"))
@login_required
def week_template_edit(template_id: int):
try:
template = get_week_template(template_id)
ensure_can_edit(template, "Diese Wochenvorlage kannst du gerade nicht bearbeiten.")
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_for("main.template_library"))
form_data = week_template_form_data(template=template)
if request.method == "POST":
form_data = extract_week_template_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error")
else:
get_db().execute(
"""
UPDATE week_templates
SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
form_data["name"],
form_data["description"],
form_data["visibility"],
template_id,
),
)
sync_week_template_days(template_id, form_data["selected_map"])
get_db().commit()
flash("Die Wochenvorlage wurde aktualisiert.", "success")
return redirect(url_for("main.template_library"))
return render_template(
"library/week_form.html",
template=template,
form_data=form_data,
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS,
day_templates=fetch_day_templates(),
weekday_labels=WEEKDAY_LABELS,
source_week=None,
)
@main_bp.post("/templates/week/<int:template_id>/apply")
@login_required
def week_template_apply(template_id: int):
week_start = parse_week_start(request.form.get("target_week"))
inserted = apply_week_template(template_id, week_start)
flash(f"Die Wochenvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success")
return redirect(url_for("main.planner", week=week_start.isoformat()))
@main_bp.route("/templates/set/new", methods=("GET", "POST"))
@login_required
def item_set_create():
form_data = {
"name": "",
"description": "",
"visibility": "shared",
"item_ids": [],
"item_search": "",
}
if request.method == "POST":
form_data = extract_item_set_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für das Paket eintragen.", "error")
else:
cursor = get_db().execute(
"""
INSERT INTO item_sets (household_id, owner_user_id, visibility, name, description)
VALUES (?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
form_data["visibility"],
form_data["name"],
form_data["description"],
),
)
set_id = int(cursor.lastrowid)
sync_item_set_items(set_id, form_data["item_ids"])
get_db().commit()
flash("Das Paket wurde gespeichert.", "success")
return redirect(url_for("main.template_library"))
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
return render_template(
"library/set_form.html",
item_set=None,
form_data=form_data,
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
item_groups=group_items_by_availability(items),
)
@main_bp.route("/templates/set/<int:set_id>/edit", methods=("GET", "POST"))
@login_required
def item_set_edit(set_id: int):
try:
item_set = get_item_set(set_id)
ensure_can_edit(item_set, "Dieses Paket kannst du gerade nicht bearbeiten.")
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_for("main.template_library"))
form_data = {
"name": item_set["name"],
"description": item_set["description"] or "",
"visibility": item_set["visibility"],
"item_ids": get_item_set_selected_ids(set_id),
"item_search": "",
}
if request.method == "POST":
form_data = extract_item_set_form_data(form_data)
if not form_data["name"]:
flash("Bitte einen Namen für das Paket eintragen.", "error")
else:
get_db().execute(
"""
UPDATE item_sets
SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
form_data["name"],
form_data["description"],
form_data["visibility"],
set_id,
),
)
sync_item_set_items(set_id, form_data["item_ids"])
get_db().commit()
flash("Das Paket wurde aktualisiert.", "success")
return redirect(url_for("main.template_library"))
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
return render_template(
"library/set_form.html",
item_set=item_set,
form_data=form_data,
visibility_options=VISIBILITY_FORM_OPTIONS,
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
item_groups=group_items_by_availability(items),
)
@main_bp.post("/templates/set/<int:set_id>/apply")
@login_required
def item_set_apply(set_id: int):
result = apply_item_set_to_shopping(set_id)
if result["count"]:
flash(f"Das Paket wurde auf die Einkaufsliste übernommen: {', '.join(result['names'][:4])}.", "success")
else:
flash("Das Paket ist bereits vollständig auf der Einkaufsliste oder zuhause vorhanden.", "info")
return redirect(url_for("main.shopping_list"))
@main_bp.route("/items/<kind>")
@login_required
def item_list(kind: str):
if kind not in ITEM_KIND_LABELS:
return redirect(url_for("main.dashboard"))
query = request.args.get("q", "").strip()
state = request.args.get("state", "").strip()
scope = request.args.get("visibility", "").strip()
raw_daypart_id = request.args.get("daypart_id", "").strip()
daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None
items = fetch_items(
kind=kind,
availability=state or None,
query=query or None,
daypart_id=daypart_id,
visibility=scope or None,
)
return render_template(
"items/list.html",
kind=kind,
items=items,
availability_labels=AVAILABILITY_LABELS,
query=query,
selected_state=state,
selected_visibility=scope,
selected_daypart_id=daypart_id,
dayparts=get_dayparts(),
state_options=ACTIVE_STATE_OPTIONS,
visibility_options=VISIBILITY_FILTER_OPTIONS,
today=date.today(),
)
@main_bp.route("/items/<kind>/new", methods=("GET", "POST"))
@login_required
def item_create(kind: str):
if kind not in ITEM_KIND_LABELS:
return redirect(url_for("main.dashboard"))
form_data = {
"name": "",
"category": "",
"note": "",
"visibility": "shared",
"target_user_id": None,
"target_user_raw": TARGET_USER_OPTIONS_DEFAULT,
"food_search": "",
"daypart_ids": [],
"component_ids": [],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_note": "",
}
if request.method == "POST":
form_action = request.form.get("form_action", "save_item")
form_data = extract_item_form_data(form_data)
if kind == "meal" and form_action == "filter_foods":
return render_item_form(kind, item=None, form_data=form_data)
if kind == "meal" and form_action == "quick_add_food":
if not form_data["quick_food_name"]:
flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error")
else:
new_food_id = create_quick_food_from_form(form_data)
if new_food_id not in form_data["component_ids"]:
form_data["component_ids"].append(new_food_id)
form_data["quick_food_name"] = ""
form_data["quick_food_category"] = ""
form_data["quick_food_note"] = ""
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
return render_item_form(kind, item=None, form_data=form_data)
error = None
if not form_data["name"]:
error = "Bitte einen Namen eintragen."
photo_filename = None
if error is None:
try:
photo_filename = save_photo(request.files.get("photo"))
except ValueError as exc:
error = str(exc)
if error is None:
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
form_data["target_user_id"],
form_data["visibility"],
kind,
form_data["name"],
form_data["category"],
form_data["note"],
photo_filename,
g.user["id"],
g.user["id"],
),
)
item_id = int(cursor.lastrowid)
sync_item_dayparts(item_id, form_data["daypart_ids"])
if kind == "meal":
sync_meal_components(item_id, form_data["component_ids"])
get_db().commit()
flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success")
return redirect(url_for("main.item_list", kind=kind))
flash(error, "error")
return render_item_form(kind, item=None, form_data=form_data)
@main_bp.route("/items/<int:item_id>/edit", methods=("GET", "POST"))
@login_required
def item_edit(item_id: int):
try:
item = get_item(item_id)
ensure_can_edit(item)
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_for("main.dashboard"))
form_data = {
"name": item["name"],
"category": item["category"] or "",
"note": item["note"] or "",
"visibility": item["visibility"],
"target_user_id": item["target_user_id"],
"target_user_raw": str(item["target_user_id"]) if item["target_user_id"] else TARGET_USER_OPTIONS_DEFAULT,
"food_search": "",
"daypart_ids": get_item_daypart_ids(item_id),
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_note": "",
}
if request.method == "POST":
form_action = request.form.get("form_action", "save_item")
form_data = extract_item_form_data(form_data)
if item["kind"] == "meal" and form_action == "filter_foods":
return render_item_form(item["kind"], item=item, form_data=form_data)
if item["kind"] == "meal" and form_action == "quick_add_food":
if not form_data["quick_food_name"]:
flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error")
else:
new_food_id = create_quick_food_from_form(form_data)
if new_food_id not in form_data["component_ids"]:
form_data["component_ids"].append(new_food_id)
form_data["quick_food_name"] = ""
form_data["quick_food_category"] = ""
form_data["quick_food_note"] = ""
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
return render_item_form(item["kind"], item=item, form_data=form_data)
error = None
if not form_data["name"]:
error = "Bitte einen Namen eintragen."
photo_filename = item["photo_filename"]
if error is None:
try:
photo_filename = save_photo(request.files.get("photo"), current_filename=item["photo_filename"])
except ValueError as exc:
error = str(exc)
if error is None:
get_db().execute(
"""
UPDATE items
SET name = ?,
category = ?,
note = ?,
visibility = ?,
target_user_id = ?,
photo_filename = ?,
updated_by = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
form_data["name"],
form_data["category"],
form_data["note"],
form_data["visibility"],
form_data["target_user_id"],
photo_filename,
g.user["id"],
item_id,
),
)
sync_item_dayparts(item_id, form_data["daypart_ids"])
if item["kind"] == "meal":
sync_meal_components(item_id, form_data["component_ids"])
get_db().commit()
flash("Der Eintrag wurde aktualisiert.", "success")
return redirect(url_for("main.item_list", kind=item["kind"]))
flash(error, "error")
return render_item_form(item["kind"], item=item, form_data=form_data)
@main_bp.post("/items/<int:item_id>/shopping")
@login_required
def item_add_to_shopping(item_id: int):
try:
item = get_item(item_id)
except ValueError as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.shopping_list"))
result = ensure_item_or_missing_components_are_shopped(
item_id,
g.user["id"],
item["visibility"],
)
if result["count"]:
if result["used_components"]:
flash(f"Für „{item['name']}“ wurden fehlende Lebensmittel auf die Einkaufsliste gesetzt.", "success")
else:
flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success")
else:
flash(f"Für „{item['name']}“ ist gerade nichts zusätzlich nötig.", "info")
return redirect(request.referrer or url_for("main.shopping_list"))
@main_bp.post("/items/<int:item_id>/set-home")
@login_required
def item_set_home(item_id: int):
try:
item = get_item(item_id)
ensure_can_edit(item)
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.home_view"))
get_db().execute(
"UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(g.user["id"], item_id),
)
get_db().commit()
flash(f"{item['name']} ist jetzt unter Zuhause sichtbar.", "success")
return redirect(request.referrer or url_for("main.home_view"))
@main_bp.post("/items/<int:item_id>/archive")
@login_required
def item_archive(item_id: int):
try:
item = get_item(item_id)
ensure_can_edit(item)
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.archive_view"))
get_db().execute(
"UPDATE items SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(g.user["id"], item_id),
)
get_db().commit()
flash(f"{item['name']} liegt jetzt im Archiv und bleibt später leicht wiederfindbar.", "info")
return redirect(request.referrer or url_for("main.archive_view"))
@main_bp.post("/items/<int:item_id>/restore")
@login_required
def item_restore(item_id: int):
try:
item = get_item(item_id)
ensure_can_edit(item)
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(request.referrer or url_for("main.archive_view"))
get_db().execute(
"UPDATE items SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(g.user["id"], item_id),
)
get_db().commit()
flash(f"{item['name']} ist wieder in der aktiven Liste.", "success")
return redirect(request.referrer or url_for("main.archive_view"))
@main_bp.route("/shopping", methods=("GET", "POST"))
@login_required
def shopping_list():
if request.method == "POST":
selected_item_id = request.form.get("item_id", "").strip()
if not selected_item_id.isdigit():
flash("Bitte zuerst etwas auswählen.", "error")
else:
try:
item = get_item(int(selected_item_id))
result = ensure_item_or_missing_components_are_shopped(
item["id"],
g.user["id"],
item["visibility"],
)
if result["count"]:
flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success")
else:
flash("Dafür ist gerade nichts zusätzlich nötig.", "info")
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("main.shopping_list"))
entries = fetch_shopping_entries()
addable_items = fetch_items(include_archived=False)
addable_items = [item for item in addable_items if not item["is_on_shopping_list"]]
return render_template("shopping/list.html", entries=entries, addable_items=addable_items)
@main_bp.post("/shopping/<int:entry_id>/check")
@login_required
def shopping_check(entry_id: int):
entry = get_db().execute(
f"""
SELECT shopping_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM shopping_entries
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Einkaufseintrag wurde nicht gefunden.", "error")
return redirect(url_for("main.shopping_list"))
entry_dict = describe_record(dict(entry))
try:
ensure_can_edit(entry_dict, "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
item = get_item(entry["item_id"])
except (ValueError, PermissionError) as exc:
flash(str(exc), "error")
return redirect(url_for("main.shopping_list"))
get_db().execute(
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
(g.user["id"], entry_id),
)
get_db().execute(
"UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(g.user["id"], item["id"]),
)
get_db().commit()
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
return redirect(url_for("main.shopping_list"))
@main_bp.post("/shopping/<int:entry_id>/remove")
@login_required
def shopping_remove(entry_id: int):
entry = get_db().execute(
f"""
SELECT shopping_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM shopping_entries
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Eintrag wurde nicht gefunden.", "error")
return redirect(url_for("main.shopping_list"))
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.")
except PermissionError as exc:
flash(str(exc), "error")
return redirect(url_for("main.shopping_list"))
get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,))
get_db().commit()
flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info")
return redirect(url_for("main.shopping_list"))
@main_bp.get("/home")
@login_required
def home_view():
query = request.args.get("q", "").strip()
scope = request.args.get("visibility", "").strip()
raw_daypart_id = request.args.get("daypart_id", "").strip()
daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None
dayparts = get_dayparts()
items = fetch_items(
availability="home",
query=query or None,
daypart_id=daypart_id,
visibility=scope or None,
)
return render_template(
"home/list.html",
sections=build_home_sections(items, dayparts, daypart_id),
query=query,
dayparts=dayparts,
selected_daypart_id=daypart_id,
selected_visibility=scope,
visibility_options=VISIBILITY_FILTER_OPTIONS,
today=date.today(),
)
@main_bp.get("/archive")
@login_required
def archive_view():
query = request.args.get("q", "").strip()
selected_kind = request.args.get("kind", "").strip()
selected_visibility = request.args.get("visibility", "").strip()
kind = selected_kind if selected_kind in ITEM_KIND_LABELS else None
items = fetch_items(
kind=kind,
availability="archived",
include_archived=True,
query=query or None,
visibility=selected_visibility or None,
)
return render_template(
"archive/list.html",
items=items,
query=query,
selected_kind=selected_kind,
selected_visibility=selected_visibility,
kind_options=KIND_FILTER_OPTIONS,
visibility_options=VISIBILITY_FILTER_OPTIONS,
today=date.today(),
)
@main_bp.get("/planner")
@login_required
def planner():
week_start = parse_week_start(request.args.get("week"))
return render_template(
"planner/week.html",
week_start=week_start,
week_end=week_start + timedelta(days=6),
prev_week=week_start - timedelta(days=7),
next_week=week_start + timedelta(days=7),
week_cards=fetch_week_cards(week_start),
today=date.today(),
week_templates=fetch_week_templates()[:6],
week_hints=build_week_hints(week_start),
)
@main_bp.route("/planner/day", methods=("GET", "POST"))
@login_required
def planner_day():
selected_date = parse_plan_date(request.values.get("date"))
if request.method == "POST":
item_id_raw = request.form.get("item_id", "").strip()
daypart_id_raw = request.form.get("daypart_id", "").strip()
note = request.form.get("note", "").strip()
selected_date = parse_plan_date(request.form.get("plan_date"))
visibility = normalize_visibility(request.form.get("visibility"), "shared")
if not item_id_raw.isdigit():
flash("Bitte etwas für den Tagesplan auswählen.", "error")
elif not daypart_id_raw.isdigit():
flash("Bitte eine Tageszeit auswählen.", "error")
else:
item_id = int(item_id_raw)
daypart_id = int(daypart_id_raw)
try:
get_item(item_id)
get_db().execute(
"""
INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
g.user["id"],
visibility,
selected_date.isoformat(),
daypart_id,
item_id,
note,
g.user["id"],
),
)
get_db().commit()
shopping_result = ensure_item_or_missing_components_are_shopped(
item_id,
g.user["id"],
visibility,
needed_for_date=selected_date.isoformat(),
needed_for_daypart_id=daypart_id,
)
if shopping_result["count"]:
flash("Fehlende Dinge wurden zusätzlich auf die Einkaufsliste gesetzt.", "info")
flash("Der Eintrag wurde in den Tagesplan gelegt.", "success")
return redirect(
f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}"
)
except ValueError as exc:
flash(str(exc), "error")
selected_item_raw = request.args.get("item_id", "").strip()
selected_daypart_raw = request.args.get("daypart_id", "").strip()
selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None
selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None
return render_template(
"planner/day.html",
selected_date=selected_date,
previous_day=selected_date - timedelta(days=1),
next_day=selected_date + timedelta(days=1),
sections=build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id),
today=date.today(),
visibility_options=VISIBILITY_FORM_OPTIONS,
day_templates=fetch_day_templates()[:6],
day_hints=build_day_hints(selected_date),
)
@main_bp.post("/planner/<int:entry_id>/remove")
@login_required
def planner_remove(entry_id: int):
selected_date = request.args.get("date", "")
entry = get_db().execute(
f"""
SELECT plan_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM plan_entries
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
WHERE plan_entries.id = ? AND {visible_clause('plan_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Planeintrag wurde nicht gefunden.", "error")
else:
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht entfernen.")
get_db().execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,))
get_db().commit()
flash("Der Planeintrag wurde entfernt.", "info")
except PermissionError as exc:
flash(str(exc), "error")
if selected_date:
return redirect(url_for("main.planner_day", date=selected_date))
return redirect(url_for("main.planner"))
@main_bp.post("/planner/<int:entry_id>/move")
@login_required
def planner_move(entry_id: int):
target_date = parse_plan_date(request.form.get("target_date"))
target_daypart_raw = request.form.get("target_daypart_id", "").strip()
if not target_daypart_raw.isdigit():
return jsonify({"ok": False, "error": "Ungültige Tageszeit"}), 400
entry = get_db().execute(
f"""
SELECT plan_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM plan_entries
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
WHERE plan_entries.id = ? AND {visible_clause('plan_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht verschieben.")
except PermissionError as exc:
return jsonify({"ok": False, "error": str(exc)}), 403
target_daypart_id = int(target_daypart_raw)
get_db().execute(
"UPDATE plan_entries SET plan_date = ?, daypart_id = ? WHERE id = ?",
(target_date.isoformat(), target_daypart_id, entry_id),
)
get_db().commit()
shopping_result = ensure_item_or_missing_components_are_shopped(
entry["item_id"],
g.user["id"],
entry["visibility"],
needed_for_date=target_date.isoformat(),
needed_for_daypart_id=target_daypart_id,
)
if shopping_result["count"]:
flash("Fehlende Dinge wurden nach dem Verschieben auf die Einkaufsliste gesetzt.", "info")
return jsonify(
{
"ok": True,
"added_to_shopping": bool(shopping_result["count"]),
"redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()),
}
)