v0.2 planning and ux improvements
This commit is contained in:
+538
-120
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -11,6 +10,7 @@ from flask import (
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
@@ -31,12 +31,29 @@ 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"),
|
||||
]
|
||||
|
||||
|
||||
def get_dayparts() -> list:
|
||||
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
|
||||
|
||||
|
||||
def get_daypart_by_id(daypart_id: int):
|
||||
return get_db().execute(
|
||||
"SELECT * FROM dayparts WHERE id = ?",
|
||||
(daypart_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def parse_week_start(raw: str | None) -> date:
|
||||
if raw:
|
||||
try:
|
||||
@@ -48,6 +65,15 @@ def parse_week_start(raw: str | None) -> date:
|
||||
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 allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
@@ -108,26 +134,34 @@ def attach_dayparts(items: list) -> list[dict]:
|
||||
return []
|
||||
|
||||
database = get_db()
|
||||
ids = [item["id"] for item in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
item_ids = [item["id"] for item in items]
|
||||
placeholders = ",".join("?" for _ in item_ids)
|
||||
rows = database.execute(
|
||||
f"""
|
||||
SELECT item_dayparts.item_id, dayparts.name
|
||||
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
|
||||
""",
|
||||
ids,
|
||||
item_ids,
|
||||
).fetchall()
|
||||
grouped = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[row["item_id"]].append(row["name"])
|
||||
grouped[row["item_id"]].append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
}
|
||||
)
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
entry = dict(item)
|
||||
entry["dayparts"] = grouped.get(item["id"], [])
|
||||
entry["dayparts_meta"] = grouped.get(item["id"], [])
|
||||
entry["dayparts"] = [daypart["name"] for daypart in entry["dayparts_meta"]]
|
||||
entry["primary_daypart_id"] = entry["dayparts_meta"][0]["id"] if entry["dayparts_meta"] else None
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
@@ -135,6 +169,8 @@ def attach_dayparts(items: list) -> list[dict]:
|
||||
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"] = []
|
||||
return items
|
||||
|
||||
placeholders = ",".join("?" for _ in meal_ids)
|
||||
@@ -157,21 +193,42 @@ def attach_components(items: list[dict]) -> list[dict]:
|
||||
return items
|
||||
|
||||
|
||||
def fetch_items(kind: str | None = None, availability: str | None = None, include_archived: bool = False):
|
||||
def fetch_items(
|
||||
kind: str | None = None,
|
||||
availability: str | None = None,
|
||||
include_archived: bool = False,
|
||||
query: str | None = None,
|
||||
daypart_id: int | None = None,
|
||||
):
|
||||
database = get_db()
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if kind:
|
||||
conditions.append("kind = ?")
|
||||
conditions.append("items.kind = ?")
|
||||
params.append(kind)
|
||||
if availability:
|
||||
conditions.append("availability_state = ?")
|
||||
conditions.append("items.availability_state = ?")
|
||||
params.append(availability)
|
||||
elif not include_archived:
|
||||
conditions.append("availability_state != '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)
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
rows = database.execute(
|
||||
f"""
|
||||
SELECT items.*,
|
||||
@@ -181,15 +238,73 @@ def fetch_items(kind: str | None = None, availability: str | None = None, includ
|
||||
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
|
||||
) AS is_on_shopping_list
|
||||
FROM items
|
||||
{where}
|
||||
ORDER BY LOWER(name)
|
||||
"""
|
||||
, params).fetchall()
|
||||
{where_clause}
|
||||
ORDER BY
|
||||
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
|
||||
LOWER(items.name)
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return attach_components(attach_dayparts(rows))
|
||||
|
||||
|
||||
def fetch_food_options():
|
||||
return fetch_items(kind="food", include_archived=False)
|
||||
return fetch_items(kind="food", include_archived=True)
|
||||
|
||||
|
||||
def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||
grouped = defaultdict(list)
|
||||
for item in items:
|
||||
grouped[item["availability_state"]].append(item)
|
||||
|
||||
ordered_states = ["home", "idea", "archived"]
|
||||
result = []
|
||||
for state in ordered_states:
|
||||
entries = grouped.get(state, [])
|
||||
if entries:
|
||||
result.append(
|
||||
{
|
||||
"state": state,
|
||||
"title": AVAILABILITY_LABELS[state],
|
||||
"items": entries,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def extract_item_form_data() -> dict:
|
||||
return {
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"category": request.form.get("category", "").strip(),
|
||||
"note": request.form.get("note", "").strip(),
|
||||
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids")],
|
||||
"component_ids": [int(value) for value in request.form.getlist("component_ids")],
|
||||
"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(),
|
||||
}
|
||||
|
||||
|
||||
def create_quick_food_from_form(form_data: dict) -> int:
|
||||
database = get_db()
|
||||
# Inline item creation keeps the meal-idea flow intact instead of forcing a detour.
|
||||
cursor = database.execute(
|
||||
"""
|
||||
INSERT INTO items (kind, name, category, note, created_by, updated_by)
|
||||
VALUES ('food', ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_note"],
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
),
|
||||
)
|
||||
food_id = cursor.lastrowid
|
||||
sync_item_dayparts(food_id, form_data["daypart_ids"])
|
||||
database.commit()
|
||||
return food_id
|
||||
|
||||
|
||||
def add_to_shopping_list(item_id: int, user_id: int) -> bool:
|
||||
@@ -215,6 +330,14 @@ def add_to_shopping_list(item_id: int, user_id: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def ensure_planned_item_is_shopped(item_id: int, user_id: int) -> bool:
|
||||
item = get_item(item_id)
|
||||
if item["availability_state"] == "home":
|
||||
return False
|
||||
# Planning something that is not at home should create a gentle follow-up on the shopping list.
|
||||
return add_to_shopping_list(item_id, user_id)
|
||||
|
||||
|
||||
def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None:
|
||||
database = get_db()
|
||||
database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,))
|
||||
@@ -239,7 +362,7 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None:
|
||||
|
||||
|
||||
def fetch_shopping_entries():
|
||||
rows = get_db().execute(
|
||||
return get_db().execute(
|
||||
"""
|
||||
SELECT shopping_entries.*,
|
||||
items.name AS item_name,
|
||||
@@ -255,42 +378,202 @@ def fetch_shopping_entries():
|
||||
ORDER BY shopping_entries.added_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def fetch_archive_items():
|
||||
return fetch_items(availability="archived", include_archived=True)
|
||||
|
||||
|
||||
def planner_entries_for_week(week_start: date):
|
||||
week_end = week_start + timedelta(days=6)
|
||||
def fetch_plan_entries_for_range(start_date: date, end_date: date):
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
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.slug AS daypart_slug,
|
||||
dayparts.sort_order
|
||||
FROM plan_entries
|
||||
JOIN items ON items.id = plan_entries.item_id
|
||||
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
|
||||
WHERE plan_date BETWEEN ? AND ?
|
||||
ORDER BY plan_date, dayparts.sort_order, items.name
|
||||
""",
|
||||
(week_start.isoformat(), week_end.isoformat()),
|
||||
(start_date.isoformat(), end_date.isoformat()),
|
||||
).fetchall()
|
||||
grouped = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped[(row["plan_date"], row["daypart_id"])].append(row)
|
||||
grouped[(row["plan_date"], row["daypart_id"])].append(dict(row))
|
||||
return grouped
|
||||
|
||||
|
||||
def fetch_day_plan_entries(selected_date: date):
|
||||
return fetch_plan_entries_for_range(selected_date, selected_date)
|
||||
|
||||
|
||||
def fetch_week_cards(week_start: date):
|
||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||
dayparts = get_dayparts()
|
||||
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 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 dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
|
||||
seen_ids = set()
|
||||
result = []
|
||||
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 fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT DISTINCT items.id, items.name, items.kind, items.photo_filename, items.availability_state
|
||||
FROM plan_entries
|
||||
JOIN items ON items.id = plan_entries.item_id
|
||||
WHERE plan_entries.daypart_id = ?
|
||||
ORDER BY plan_entries.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(daypart_id, limit * 3),
|
||||
).fetchall()
|
||||
return attach_components(attach_dayparts(rows))
|
||||
|
||||
|
||||
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
||||
params = [daypart_id]
|
||||
conditions = ["items.availability_state != 'archived'"]
|
||||
if query:
|
||||
conditions.append("LOWER(items.name) LIKE ?")
|
||||
params.append(f"%{query.lower()}%")
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
SELECT items.*,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM item_dayparts
|
||||
WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ?
|
||||
) AS matches_daypart
|
||||
FROM items
|
||||
{where_clause}
|
||||
ORDER BY
|
||||
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
|
||||
matches_daypart DESC,
|
||||
LOWER(items.name)
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return attach_components(attach_dayparts(rows))
|
||||
|
||||
|
||||
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,
|
||||
"slug": selected_daypart["slug"] if selected_daypart else "selected",
|
||||
}
|
||||
)
|
||||
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,
|
||||
"slug": daypart["slug"],
|
||||
}
|
||||
)
|
||||
|
||||
anytime_items = [item for item in items if not item["dayparts_meta"]]
|
||||
if anytime_items:
|
||||
sections.append(
|
||||
{
|
||||
"title": "Ohne feste Tageszeit",
|
||||
"items": anytime_items,
|
||||
"slug": "anytime",
|
||||
}
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
|
||||
dayparts = get_dayparts()
|
||||
day_entries = fetch_day_plan_entries(selected_date)
|
||||
sections = []
|
||||
|
||||
for daypart in 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)
|
||||
sections.append(
|
||||
{
|
||||
"daypart": daypart,
|
||||
"entries": day_entries.get((selected_date.isoformat(), daypart["id"]), []),
|
||||
"candidates": candidates,
|
||||
"quick_items": quick_items,
|
||||
"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 day_entries.get((selected_date.isoformat(), daypart["id"]), [])][:2],
|
||||
}
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
@main_bp.get("/")
|
||||
@login_required
|
||||
def dashboard():
|
||||
database = get_db()
|
||||
today = date.today().isoformat()
|
||||
today = date.today()
|
||||
home_count = database.execute(
|
||||
"SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'"
|
||||
).fetchone()["count"]
|
||||
@@ -305,6 +588,7 @@ def dashboard():
|
||||
SELECT plan_entries.id,
|
||||
items.name AS item_name,
|
||||
items.kind AS item_kind,
|
||||
items.availability_state,
|
||||
dayparts.name AS daypart_name
|
||||
FROM plan_entries
|
||||
JOIN items ON items.id = plan_entries.item_id
|
||||
@@ -312,8 +596,9 @@ def dashboard():
|
||||
WHERE plan_entries.plan_date = ?
|
||||
ORDER BY dayparts.sort_order, items.name
|
||||
""",
|
||||
(today,),
|
||||
(today.isoformat(),),
|
||||
).fetchall()
|
||||
week_cards = fetch_week_cards(today - timedelta(days=today.weekday()))
|
||||
home_items = fetch_items(availability="home")
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
@@ -323,6 +608,7 @@ def dashboard():
|
||||
today_entries=today_entries,
|
||||
home_items=home_items[:8],
|
||||
today=today,
|
||||
week_cards=week_cards[:3],
|
||||
)
|
||||
|
||||
|
||||
@@ -331,12 +617,29 @@ def dashboard():
|
||||
def item_list(kind: str):
|
||||
if kind not in ITEM_KIND_LABELS:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
items = fetch_items(kind=kind)
|
||||
|
||||
query = request.args.get("q", "").strip()
|
||||
state = request.args.get("state", "").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,
|
||||
)
|
||||
return render_template(
|
||||
"items/list.html",
|
||||
kind=kind,
|
||||
items=items,
|
||||
availability_labels=AVAILABILITY_LABELS,
|
||||
query=query,
|
||||
selected_state=state,
|
||||
selected_daypart_id=daypart_id,
|
||||
dayparts=get_dayparts(),
|
||||
state_options=ACTIVE_STATE_OPTIONS,
|
||||
today=date.today(),
|
||||
)
|
||||
|
||||
|
||||
@@ -349,35 +652,55 @@ def item_create(kind: str):
|
||||
database = get_db()
|
||||
dayparts = get_dayparts()
|
||||
foods = fetch_food_options()
|
||||
food_groups = group_items_by_availability(foods)
|
||||
form_data = {
|
||||
"name": "",
|
||||
"category": "",
|
||||
"note": "",
|
||||
"daypart_ids": [],
|
||||
"component_ids": [],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
category = request.form.get("category", "").strip()
|
||||
note = request.form.get("note", "").strip()
|
||||
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")]
|
||||
component_ids = [int(value) for value in request.form.getlist("component_ids")]
|
||||
form_data.update(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"note": note,
|
||||
"daypart_ids": daypart_ids,
|
||||
"component_ids": component_ids,
|
||||
}
|
||||
)
|
||||
form_action = request.form.get("form_action", "save_item")
|
||||
form_data.update(extract_item_form_data())
|
||||
name = form_data["name"]
|
||||
category = form_data["category"]
|
||||
note = form_data["note"]
|
||||
daypart_ids = form_data["daypart_ids"]
|
||||
component_ids = form_data["component_ids"]
|
||||
|
||||
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["component_ids"] = sorted(form_data["component_ids"])
|
||||
form_data["quick_food_name"] = ""
|
||||
form_data["quick_food_category"] = ""
|
||||
form_data["quick_food_note"] = ""
|
||||
foods = fetch_food_options()
|
||||
food_groups = group_items_by_availability(foods)
|
||||
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
|
||||
return render_template(
|
||||
"items/form.html",
|
||||
kind=kind,
|
||||
item=None,
|
||||
dayparts=dayparts,
|
||||
foods=foods,
|
||||
food_groups=food_groups,
|
||||
categories=CATEGORIES,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
error = None
|
||||
if not name:
|
||||
error = "Bitte einen Namen eintragen."
|
||||
elif kind == "meal" and not component_ids:
|
||||
error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen."
|
||||
|
||||
photo_filename = None
|
||||
if error is None:
|
||||
@@ -410,6 +733,7 @@ def item_create(kind: str):
|
||||
item=None,
|
||||
dayparts=dayparts,
|
||||
foods=foods,
|
||||
food_groups=food_groups,
|
||||
categories=CATEGORIES,
|
||||
form_data=form_data,
|
||||
)
|
||||
@@ -423,35 +747,55 @@ def item_edit(item_id: int):
|
||||
kind = item["kind"]
|
||||
dayparts = get_dayparts()
|
||||
foods = fetch_food_options()
|
||||
food_groups = group_items_by_availability(foods)
|
||||
form_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"] or "",
|
||||
"note": item["note"] or "",
|
||||
"daypart_ids": get_item_daypart_ids(item_id),
|
||||
"component_ids": get_meal_component_ids(item_id) if kind == "meal" else [],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
category = request.form.get("category", "").strip()
|
||||
note = request.form.get("note", "").strip()
|
||||
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")]
|
||||
component_ids = [int(value) for value in request.form.getlist("component_ids")]
|
||||
form_data.update(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"note": note,
|
||||
"daypart_ids": daypart_ids,
|
||||
"component_ids": component_ids,
|
||||
}
|
||||
)
|
||||
form_action = request.form.get("form_action", "save_item")
|
||||
form_data.update(extract_item_form_data())
|
||||
name = form_data["name"]
|
||||
category = form_data["category"]
|
||||
note = form_data["note"]
|
||||
daypart_ids = form_data["daypart_ids"]
|
||||
component_ids = form_data["component_ids"]
|
||||
|
||||
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["component_ids"] = sorted(form_data["component_ids"])
|
||||
form_data["quick_food_name"] = ""
|
||||
form_data["quick_food_category"] = ""
|
||||
form_data["quick_food_note"] = ""
|
||||
foods = fetch_food_options()
|
||||
food_groups = group_items_by_availability(foods)
|
||||
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
|
||||
return render_template(
|
||||
"items/form.html",
|
||||
kind=kind,
|
||||
item=item,
|
||||
dayparts=dayparts,
|
||||
foods=foods,
|
||||
food_groups=food_groups,
|
||||
categories=CATEGORIES,
|
||||
form_data=form_data,
|
||||
)
|
||||
|
||||
error = None
|
||||
if not name:
|
||||
error = "Bitte einen Namen eintragen."
|
||||
elif kind == "meal" and not component_ids:
|
||||
error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen."
|
||||
|
||||
photo_filename = item["photo_filename"]
|
||||
if error is None:
|
||||
@@ -484,6 +828,7 @@ def item_edit(item_id: int):
|
||||
item=item,
|
||||
dayparts=dayparts,
|
||||
foods=foods,
|
||||
food_groups=food_groups,
|
||||
categories=CATEGORIES,
|
||||
form_data=form_data,
|
||||
)
|
||||
@@ -533,7 +878,7 @@ def item_archive(item_id: int):
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
database.commit()
|
||||
flash(f"{item['name']} liegt jetzt im Archiv und bleibt spaeter leicht wiederfindbar.", "info")
|
||||
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"))
|
||||
|
||||
|
||||
@@ -563,7 +908,7 @@ def shopping_list():
|
||||
if request.method == "POST":
|
||||
selected_item_id = request.form.get("item_id", "").strip()
|
||||
if not selected_item_id:
|
||||
flash("Bitte zuerst etwas auswaehlen.", "error")
|
||||
flash("Bitte zuerst etwas auswählen.", "error")
|
||||
else:
|
||||
item = get_item(int(selected_item_id))
|
||||
added = add_to_shopping_list(item["id"], g.user["id"])
|
||||
@@ -636,89 +981,162 @@ def shopping_remove(entry_id: int):
|
||||
@main_bp.get("/home")
|
||||
@login_required
|
||||
def home_view():
|
||||
items = fetch_items(availability="home")
|
||||
grouped = defaultdict(list)
|
||||
for item in items:
|
||||
key = item["dayparts"][0] if item["dayparts"] else "Ohne feste Tageszeit"
|
||||
grouped[key].append(item)
|
||||
return render_template("home/list.html", grouped=grouped)
|
||||
query = request.args.get("q", "").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,
|
||||
)
|
||||
sections = build_home_sections(items, dayparts, daypart_id)
|
||||
return render_template(
|
||||
"home/list.html",
|
||||
sections=sections,
|
||||
query=query,
|
||||
dayparts=dayparts,
|
||||
selected_daypart_id=daypart_id,
|
||||
today=date.today(),
|
||||
)
|
||||
|
||||
|
||||
@main_bp.get("/archive")
|
||||
@login_required
|
||||
def archive_view():
|
||||
items = fetch_archive_items()
|
||||
return render_template("archive/list.html", items=items)
|
||||
query = request.args.get("q", "").strip()
|
||||
selected_kind = request.args.get("kind", "").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,
|
||||
)
|
||||
return render_template(
|
||||
"archive/list.html",
|
||||
items=items,
|
||||
query=query,
|
||||
selected_kind=selected_kind,
|
||||
kind_options=KIND_FILTER_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/planner", methods=("GET", "POST"))
|
||||
@main_bp.get("/planner")
|
||||
@login_required
|
||||
def planner():
|
||||
database = get_db()
|
||||
week_start = parse_week_start(request.values.get("week"))
|
||||
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(),
|
||||
)
|
||||
|
||||
|
||||
@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":
|
||||
try:
|
||||
selected_date = datetime.strptime(request.form.get("plan_date", ""), "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
selected_date = None
|
||||
|
||||
item_id = request.form.get("item_id", "").strip()
|
||||
daypart_id = request.form.get("daypart_id", "").strip()
|
||||
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"))
|
||||
|
||||
error = None
|
||||
if selected_date is None:
|
||||
error = "Bitte einen gueltigen Tag auswaehlen."
|
||||
elif not item_id:
|
||||
error = "Bitte etwas fuer den Plan waehlen."
|
||||
elif not daypart_id:
|
||||
error = "Bitte eine Tageszeit waehlen."
|
||||
if not item_id_raw:
|
||||
error = "Bitte etwas für den Tagesplan auswählen."
|
||||
elif not daypart_id_raw:
|
||||
error = "Bitte eine Tageszeit auswählen."
|
||||
|
||||
if error is None:
|
||||
database.execute(
|
||||
item_id = int(item_id_raw)
|
||||
daypart_id = int(daypart_id_raw)
|
||||
get_db().execute(
|
||||
"""
|
||||
INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(selected_date.isoformat(), int(daypart_id), int(item_id), note, g.user["id"]),
|
||||
(selected_date.isoformat(), daypart_id, item_id, note, g.user["id"]),
|
||||
)
|
||||
get_db().commit()
|
||||
if ensure_planned_item_is_shopped(item_id, g.user["id"]):
|
||||
flash("Der Eintrag ist noch nicht zuhause und wurde 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}"
|
||||
)
|
||||
database.commit()
|
||||
flash("Der Eintrag wurde in den Wochenplan gelegt.", "success")
|
||||
else:
|
||||
flash(error, "error")
|
||||
|
||||
return redirect(url_for("main.planner", week=week_start.isoformat()))
|
||||
flash(error, "error")
|
||||
|
||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||
dayparts = get_dayparts()
|
||||
entries = planner_entries_for_week(week_start)
|
||||
selectable_items = database.execute(
|
||||
"""
|
||||
SELECT id, name, kind, availability_state
|
||||
FROM items
|
||||
WHERE availability_state != 'archived'
|
||||
ORDER BY CASE availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(name)
|
||||
"""
|
||||
).fetchall()
|
||||
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
|
||||
sections = build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id)
|
||||
return render_template(
|
||||
"planner/week.html",
|
||||
week_start=week_start,
|
||||
prev_week=week_start - timedelta(days=7),
|
||||
next_week=week_start + timedelta(days=7),
|
||||
days=days,
|
||||
dayparts=dayparts,
|
||||
entries=entries,
|
||||
selectable_items=selectable_items,
|
||||
"planner/day.html",
|
||||
selected_date=selected_date,
|
||||
previous_day=selected_date - timedelta(days=1),
|
||||
next_day=selected_date + timedelta(days=1),
|
||||
sections=sections,
|
||||
today=date.today(),
|
||||
)
|
||||
|
||||
|
||||
@main_bp.post("/planner/<int:entry_id>/remove")
|
||||
@login_required
|
||||
def planner_remove(entry_id: int):
|
||||
database = get_db()
|
||||
week = request.args.get("week")
|
||||
database.execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,))
|
||||
database.commit()
|
||||
selected_date = request.args.get("date", "")
|
||||
get_db().execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,))
|
||||
get_db().commit()
|
||||
flash("Der Planeintrag wurde entfernt.", "info")
|
||||
return redirect(url_for("main.planner", week=week))
|
||||
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
|
||||
|
||||
database = get_db()
|
||||
entry = database.execute(
|
||||
"SELECT * FROM plan_entries WHERE id = ?",
|
||||
(entry_id,),
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404
|
||||
|
||||
target_daypart_id = int(target_daypart_raw)
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE plan_entries
|
||||
SET plan_date = ?, daypart_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(target_date.isoformat(), target_daypart_id, entry_id),
|
||||
)
|
||||
database.commit()
|
||||
|
||||
# Reuse the same shopping safeguard as the day planner after drag-and-drop moves.
|
||||
was_added_to_shopping = ensure_planned_item_is_shopped(entry["item_id"], g.user["id"])
|
||||
if was_added_to_shopping:
|
||||
flash("Der verschobene Eintrag ist noch nicht zuhause und wurde auf die Einkaufsliste gesetzt.", "info")
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"added_to_shopping": was_added_to_shopping,
|
||||
"redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user