Release 1.2.0 with calmer snack planning and PDF exports
This commit is contained in:
+305
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from io import BytesIO
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
@@ -72,6 +73,13 @@ VISIBILITY_FORM_OPTIONS = [
|
||||
]
|
||||
TARGET_USER_OPTIONS_DEFAULT = "__all__"
|
||||
WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
PRIMARY_DAYPART_SLUGS = {"breakfast", "lunch", "dinner"}
|
||||
SNACK_DAYPART_SLUGS = {"morning-snack", "afternoon-snack", "late-snack"}
|
||||
PDF_DAYPART_LABELS = {
|
||||
"morning-snack": "Snack am Vormittag",
|
||||
"afternoon-snack": "Snack am Nachmittag",
|
||||
"late-snack": "Später Snack",
|
||||
}
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
@@ -95,6 +103,10 @@ def get_dayparts() -> list:
|
||||
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
|
||||
|
||||
|
||||
def format_weekday(day_value: date) -> str:
|
||||
return WEEKDAY_LABELS[day_value.weekday()]
|
||||
|
||||
|
||||
def get_household_users(active_only: bool = True):
|
||||
query = """
|
||||
SELECT id, username, display_name, role
|
||||
@@ -193,6 +205,7 @@ def default_user_settings() -> dict:
|
||||
"push_missing_breakfast": False,
|
||||
"push_missing_lunch": False,
|
||||
"push_missing_dinner": False,
|
||||
"push_small_snack": False,
|
||||
"suggest_home_for_today": True,
|
||||
"remind_small_snack": False,
|
||||
"remind_nuts": False,
|
||||
@@ -235,6 +248,7 @@ def get_user_settings() -> dict:
|
||||
"push_missing_breakfast",
|
||||
"push_missing_lunch",
|
||||
"push_missing_dinner",
|
||||
"push_small_snack",
|
||||
"suggest_home_for_today",
|
||||
"remind_small_snack",
|
||||
"remind_nuts",
|
||||
@@ -1816,7 +1830,7 @@ def build_selected_quick_action(
|
||||
return {
|
||||
"type": "existing",
|
||||
"title": selected_item["name"],
|
||||
"subtitle": "Bereit zum Eintragen",
|
||||
"subtitle": "Ausgewählt. Du kannst es jetzt direkt eintragen.",
|
||||
"item_id": int(selected_item["id"]),
|
||||
"visibility": selected_item["visibility"],
|
||||
"daypart_id": daypart_id,
|
||||
@@ -1826,7 +1840,7 @@ def build_selected_quick_action(
|
||||
return {
|
||||
"type": "generated",
|
||||
"title": selected_meal_name,
|
||||
"subtitle": "Vorgeschlagen aus dem, was zuhause da ist",
|
||||
"subtitle": "Ausgewählt aus dem, was zuhause gut passt.",
|
||||
"component_ids": selected_component_ids,
|
||||
"visibility": "shared",
|
||||
"daypart_id": daypart_id,
|
||||
@@ -1881,6 +1895,9 @@ def build_day_planner_sections(
|
||||
candidates=candidates,
|
||||
),
|
||||
"is_open": selected_daypart_id == daypart["id"],
|
||||
"is_primary_daypart": daypart["slug"] in PRIMARY_DAYPART_SLUGS,
|
||||
"is_snack_daypart": daypart["slug"] in SNACK_DAYPART_SLUGS,
|
||||
"visible_by_default": daypart["slug"] in PRIMARY_DAYPART_SLUGS or bool(entries) or selected_daypart_id == daypart["id"],
|
||||
"summary_items": [entry["item_name"] for entry in entries][:2],
|
||||
"default_visibility": "shared",
|
||||
}
|
||||
@@ -1912,7 +1929,20 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None
|
||||
|
||||
def fetch_week_cards(week_start: date):
|
||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||
week_end = week_start + timedelta(days=6)
|
||||
grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6))
|
||||
picker_map = {}
|
||||
for daypart in get_dayparts():
|
||||
candidates = fetch_plan_candidates(int(daypart["id"]))
|
||||
meal_candidates = dedupe_items(
|
||||
[item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"]
|
||||
+ [item for item in candidates if item["kind"] == "meal"],
|
||||
limit=4,
|
||||
)
|
||||
picker_map[int(daypart["id"])] = {
|
||||
"meal_candidates": meal_candidates,
|
||||
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
|
||||
}
|
||||
cards = []
|
||||
for current_day in days:
|
||||
filled_dayparts = []
|
||||
@@ -1921,11 +1951,27 @@ def fetch_week_cards(week_start: date):
|
||||
slots = []
|
||||
for daypart in get_dayparts():
|
||||
slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
|
||||
slots.append({"daypart": dict(daypart), "entries": slot_entries})
|
||||
is_snack_daypart = daypart["slug"] in SNACK_DAYPART_SLUGS
|
||||
visible_by_default = (not is_snack_daypart) or bool(slot_entries)
|
||||
slots.append(
|
||||
{
|
||||
"daypart": dict(daypart),
|
||||
"entries": slot_entries,
|
||||
"copy_allowed": bool(slot_entries) and current_day < week_end,
|
||||
"picker": picker_map.get(int(daypart["id"]), {"meal_candidates": [], "recipe_suggestions": []}),
|
||||
"is_snack_daypart": is_snack_daypart,
|
||||
"visible_by_default": visible_by_default,
|
||||
}
|
||||
)
|
||||
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])
|
||||
hidden_snack_slots = [
|
||||
{"id": int(slot["daypart"]["id"]), "name": slot["daypart"]["name"]}
|
||||
for slot in slots
|
||||
if slot["is_snack_daypart"] and not slot["visible_by_default"]
|
||||
]
|
||||
cards.append(
|
||||
{
|
||||
"date": current_day,
|
||||
@@ -1933,11 +1979,161 @@ def fetch_week_cards(week_start: date):
|
||||
"planned_count": planned_count,
|
||||
"preview_items": preview_items[:4],
|
||||
"slots": slots,
|
||||
"hidden_snack_slots": hidden_snack_slots,
|
||||
}
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def format_week_pdf_entry(entry: dict) -> str:
|
||||
return entry["item_name"]
|
||||
|
||||
|
||||
def normalize_pdf_export_mode(raw: str | None) -> str:
|
||||
return "household" if raw == "household" else "mine"
|
||||
|
||||
|
||||
def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mode: str):
|
||||
params: list[object] = [start_date.isoformat(), end_date.isoformat()]
|
||||
if mode == "household":
|
||||
where_clause = "plan_entries.household_id = ?"
|
||||
params.append(current_household_id())
|
||||
else:
|
||||
where_clause = visible_clause("plan_entries")
|
||||
params.extend(visible_params())
|
||||
|
||||
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 {where_clause}
|
||||
ORDER BY plan_date, dayparts.sort_order, items.name
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
grouped = defaultdict(list)
|
||||
for row in describe_records(rows):
|
||||
grouped[(row["plan_date"], row["daypart_id"])].append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
def format_week_pdf_entry(entry: dict, *, mode: str) -> str:
|
||||
label = entry["item_name"]
|
||||
if mode == "household":
|
||||
if entry.get("target_name"):
|
||||
return f"{label} (Für {entry['target_name']})"
|
||||
if entry.get("is_personal"):
|
||||
return f"{label} (Für {entry['owner_name']})"
|
||||
return f"{label} (Für alle)"
|
||||
|
||||
if entry.get("is_shared"):
|
||||
return f"{label} (gemeinsam)"
|
||||
return label
|
||||
|
||||
|
||||
def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]:
|
||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||
grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode)
|
||||
rows: list[list[str]] = []
|
||||
dayparts = get_dayparts()
|
||||
visible_dayparts = []
|
||||
for daypart in dayparts:
|
||||
row_cells: list[str] = []
|
||||
has_content = False
|
||||
row_label = PDF_DAYPART_LABELS.get(daypart["slug"], daypart["name"])
|
||||
row = [daypart["name"]]
|
||||
for current_day in days:
|
||||
entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
|
||||
cell_value = "\n".join(format_week_pdf_entry(entry, mode=mode) for entry in entries)
|
||||
row_cells.append(cell_value)
|
||||
has_content = has_content or bool(cell_value.strip())
|
||||
|
||||
if daypart["slug"] in PRIMARY_DAYPART_SLUGS or has_content:
|
||||
visible_dayparts.append([row_label, *row_cells])
|
||||
|
||||
return days, visible_dayparts
|
||||
|
||||
|
||||
def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes:
|
||||
try:
|
||||
from fpdf import FPDF
|
||||
from fpdf.fonts import FontFace
|
||||
except ImportError as exc: # pragma: no cover - depends on optional package in local env
|
||||
raise RuntimeError("Für den PDF-Export fehlt noch die Abhängigkeit aus der requirements.txt.") from exc
|
||||
|
||||
mode = normalize_pdf_export_mode(mode)
|
||||
week_end = week_start + timedelta(days=6)
|
||||
week_number = week_start.isocalendar().week
|
||||
days, rows = build_week_pdf_rows(week_start, mode=mode)
|
||||
plan_label = "Mein Essensplan" if mode == "mine" else "Unser Essensplan"
|
||||
|
||||
pdf = FPDF(orientation="L", unit="mm", format="A4")
|
||||
pdf.set_auto_page_break(auto=True, margin=14)
|
||||
pdf.set_margins(left=14, top=14, right=14)
|
||||
pdf.add_page()
|
||||
|
||||
pdf.set_title(f"{plan_label} KW {week_number:02d}")
|
||||
pdf.set_author("Nouri")
|
||||
pdf.set_creator("Nouri")
|
||||
|
||||
pdf.set_font("Helvetica", "B", 18)
|
||||
pdf.cell(0, 9, f"{plan_label} vom {week_start.strftime('%d.%m.%Y')} bis {week_end.strftime('%d.%m.%Y')}", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("Helvetica", "", 11)
|
||||
pdf.set_text_color(82, 82, 82)
|
||||
pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(3)
|
||||
pdf.set_text_color(20, 20, 20)
|
||||
|
||||
headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days]
|
||||
first_column_width = 34
|
||||
remaining_width = pdf.w - pdf.l_margin - pdf.r_margin - first_column_width
|
||||
day_column_width = remaining_width / 7
|
||||
column_widths = (first_column_width, *([day_column_width] * 7))
|
||||
|
||||
header_style = FontFace(emphasis="B", fill_color=(240, 240, 240))
|
||||
first_column_style = FontFace(emphasis="B", fill_color=(248, 248, 248))
|
||||
body_style = FontFace(fill_color=(255, 255, 255))
|
||||
|
||||
with pdf.table(
|
||||
borders_layout="SINGLE_TOP_LINE",
|
||||
cell_fill_color=(255, 255, 255),
|
||||
cell_fill_mode="ROWS",
|
||||
col_widths=column_widths,
|
||||
gutter_height=0,
|
||||
gutter_width=0,
|
||||
headings_style=header_style,
|
||||
line_height=5.5,
|
||||
text_align=("LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT"),
|
||||
width=pdf.w - pdf.l_margin - pdf.r_margin,
|
||||
) as table:
|
||||
header_row = table.row()
|
||||
for heading in headings:
|
||||
header_row.cell(heading, padding=(2.8, 2.5, 2.8, 2.5), v_align="M")
|
||||
|
||||
for row in rows:
|
||||
table_row = table.row()
|
||||
table_row.cell(row[0], style=first_column_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="M")
|
||||
for value in row[1:]:
|
||||
table_row.cell(value or " ", style=body_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="TOP")
|
||||
|
||||
return bytes(pdf.output())
|
||||
|
||||
|
||||
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')}",
|
||||
@@ -2927,6 +3123,7 @@ def settings_view():
|
||||
push_missing_breakfast = ?,
|
||||
push_missing_lunch = ?,
|
||||
push_missing_dinner = ?,
|
||||
push_small_snack = ?,
|
||||
suggest_home_for_today = ?,
|
||||
remind_small_snack = ?,
|
||||
remind_nuts = ?,
|
||||
@@ -2951,6 +3148,7 @@ def settings_view():
|
||||
parse_checkbox("push_missing_breakfast", False),
|
||||
parse_checkbox("push_missing_lunch", False),
|
||||
parse_checkbox("push_missing_dinner", False),
|
||||
parse_checkbox("push_small_snack", False),
|
||||
parse_checkbox("suggest_home_for_today", True),
|
||||
parse_checkbox("remind_small_snack", False),
|
||||
parse_checkbox("remind_nuts", False),
|
||||
@@ -3602,6 +3800,28 @@ def planner():
|
||||
)
|
||||
|
||||
|
||||
@main_bp.get("/planner/export.pdf")
|
||||
@login_required
|
||||
def planner_export_pdf():
|
||||
week_start = parse_week_start(request.args.get("week"))
|
||||
mode = normalize_pdf_export_mode(request.args.get("mode"))
|
||||
try:
|
||||
pdf_bytes = build_week_plan_pdf(week_start, mode=mode)
|
||||
except RuntimeError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("main.planner", week=week_start.isoformat()))
|
||||
|
||||
week_number = week_start.isocalendar().week
|
||||
prefix = "mein-essensplan" if mode == "mine" else "unser-essensplan"
|
||||
filename = f"{prefix}-kw-{week_number:02d}-{week_start.year}.pdf"
|
||||
return send_file(
|
||||
BytesIO(pdf_bytes),
|
||||
mimetype="application/pdf",
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/planner/day", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def planner_day():
|
||||
@@ -3813,3 +4033,85 @@ def planner_move(entry_id: int):
|
||||
"redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@main_bp.post("/planner/slot/copy-forward")
|
||||
@login_required
|
||||
def planner_slot_copy_forward():
|
||||
source_date = parse_plan_date(request.form.get("source_date"))
|
||||
target_date = source_date + timedelta(days=1)
|
||||
daypart_raw = request.form.get("daypart_id", "").strip()
|
||||
if not daypart_raw.isdigit():
|
||||
flash("Die Tageszeit konnte nicht erkannt werden.", "error")
|
||||
return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat()))
|
||||
|
||||
daypart_id = int(daypart_raw)
|
||||
entries = 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.plan_date = ? AND plan_entries.daypart_id = ? AND {visible_clause('plan_entries')}
|
||||
ORDER BY plan_entries.id
|
||||
""",
|
||||
[source_date.isoformat(), daypart_id, *visible_params()],
|
||||
).fetchall()
|
||||
|
||||
copied_count = 0
|
||||
shopping_added = 0
|
||||
shopping_scheduled = 0
|
||||
|
||||
for raw_entry in entries:
|
||||
entry = describe_record(dict(raw_entry))
|
||||
try:
|
||||
ensure_can_edit(entry, "Diesen Planeintrag kannst du gerade nicht kopieren.")
|
||||
except PermissionError:
|
||||
continue
|
||||
|
||||
duplicate = get_db().execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM plan_entries
|
||||
WHERE household_id = ?
|
||||
AND plan_date = ?
|
||||
AND daypart_id = ?
|
||||
AND item_id = ?
|
||||
AND visibility = ?
|
||||
AND COALESCE(note, '') = COALESCE(?, '')
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
target_date.isoformat(),
|
||||
daypart_id,
|
||||
entry["item_id"],
|
||||
entry["visibility"],
|
||||
entry.get("note", ""),
|
||||
),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
continue
|
||||
|
||||
shopping_result = insert_plan_entry(
|
||||
item_id=entry["item_id"],
|
||||
daypart_id=daypart_id,
|
||||
plan_date=target_date,
|
||||
visibility=entry["visibility"],
|
||||
note=entry.get("note", "") or "",
|
||||
)
|
||||
copied_count += 1
|
||||
shopping_added += int(shopping_result["count"])
|
||||
shopping_scheduled += int(shopping_result["scheduled_count"])
|
||||
|
||||
if copied_count == 0:
|
||||
flash("Für diese Tageszeit gab es nichts Neues zum Kopieren.", "info")
|
||||
else:
|
||||
if shopping_added:
|
||||
flash("Fehlende Lebensmittel wurden für den passenden Einkauf ergänzt.", "info")
|
||||
elif shopping_scheduled:
|
||||
flash("Fehlende Lebensmittel wurden für später vorgemerkt.", "info")
|
||||
flash(f"{copied_count} Eintrag{' wurde' if copied_count == 1 else 'e wurden'} zum nächsten Tag kopiert.", "success")
|
||||
|
||||
return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat()))
|
||||
|
||||
Reference in New Issue
Block a user