From b68ed628871ec3cfc9713b8cfba615ba66a61463 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 12 Apr 2026 13:15:33 +0200 Subject: [PATCH] release nouri 0.3 household sharing and mobile polish --- CloudronManifest.json | 4 +- README.md | 12 +- nouri/__init__.py | 20 +- nouri/admin.py | 180 +++++++ nouri/auth.py | 195 ++++++- nouri/constants.py | 15 + nouri/db.py | 216 +++++++- nouri/main.py | 724 +++++++++++++++++--------- nouri/schema.sql | 43 +- nouri/static/css/styles.css | 573 ++++++++++++-------- nouri/static/js/planner.js | 1 + nouri/static/js/theme.js | 22 +- nouri/templates/admin/user_form.html | 53 ++ nouri/templates/admin/users_list.html | 40 ++ nouri/templates/archive/list.html | 24 +- nouri/templates/auth/login.html | 4 +- nouri/templates/auth/profile.html | 63 +++ nouri/templates/auth/setup.html | 10 +- nouri/templates/base.html | 54 +- nouri/templates/dashboard.html | 15 +- nouri/templates/home/list.html | 24 +- nouri/templates/items/form.html | 47 +- nouri/templates/items/list.html | 25 +- nouri/templates/more.html | 56 ++ nouri/templates/planner/day.html | 33 +- nouri/templates/planner/week.html | 4 +- nouri/templates/shopping/list.html | 25 +- 27 files changed, 1929 insertions(+), 553 deletions(-) create mode 100644 nouri/admin.py create mode 100644 nouri/templates/admin/user_form.html create mode 100644 nouri/templates/admin/users_list.html create mode 100644 nouri/templates/auth/profile.html create mode 100644 nouri/templates/more.html diff --git a/CloudronManifest.json b/CloudronManifest.json index acf775a..3bdfda2 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "0.2.3", - "upstreamVersion": "0.2.2", + "version": "0.3.0", + "upstreamVersion": "0.3.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/README.md b/README.md index a8f2f3b..138140f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. -## Merkmale in Version 0.2 +## Merkmale in Version 0.3 - Lebensmittel und Mahlzeitenideen anlegen - Fotos lokal hochladen @@ -12,7 +12,11 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein - Tagesplan mit schnellen Vorschlägen je Tageszeit - Wochenansicht für die nächsten 7 Tage - einfache Suche und Filter für Lebensmittel und Mahlzeitenideen -- einfache Benutzeranmeldung für einen Haushalt +- mehrere Haushaltsnutzer mit Rollen +- gemeinsame und persönliche Inhalte +- Profilseite und Passwortänderung +- kleine Admin-Verwaltung für Nutzer +- kompaktere mobile Navigation mit Bottom-Bar ## Lokal starten @@ -35,9 +39,9 @@ Wichtige Umgebungsvariablen: - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` -## Migration von 0.1 auf 0.2 +## Migration von 0.2 auf 0.3 -Beim Start führt Nouri das Schema erneut mit `CREATE ... IF NOT EXISTS` aus und gleicht die festen Tageszeiten ab. Vorhandene Daten bleiben erhalten; neue Indizes und aktualisierte Tageszeit-Namen werden automatisch ergänzt. +Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite: Haushalt, Rollen, Aktiv-Status und Sichtbarkeit (`persönlich` oder `Für alle`) werden ergänzt. Vorhandene 0.2-Daten bleiben erhalten und werden automatisch einem gemeinsamen Haushaltskontext zugeordnet. ## Cloudron-Hinweis diff --git a/nouri/__init__.py b/nouri/__init__.py index 508a29b..1529afb 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -5,11 +5,20 @@ import secrets from datetime import date, timedelta from pathlib import Path -from flask import Flask, send_from_directory +from flask import Flask, g, send_from_directory from . import db +from .admin import admin_bp from .auth import auth_bp -from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS +from .constants import ( + CATEGORIES, + DAYPARTS, + ITEM_KIND_LABELS, + ITEM_KIND_SINGULAR_LABELS, + ROLE_LABELS, + VISIBILITY_DESCRIPTIONS, + VISIBILITY_LABELS, +) from .main import main_bp @@ -46,8 +55,6 @@ def create_app() -> Flask: app = Flask(__name__, instance_relative_config=False) app.config.update( - # Persist the signing key inside the app's data directory so all - # gunicorn workers and future restarts agree on the same sessions. SECRET_KEY=load_secret_key(data_dir), DATABASE_PATH=str(db_path), DATA_DIR=str(data_dir), @@ -62,6 +69,7 @@ def create_app() -> Flask: db.init_db_if_needed(app) app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) app.register_blueprint(main_bp) @app.context_processor @@ -71,9 +79,13 @@ def create_app() -> Flask: "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "category_suggestions": CATEGORIES, "daypart_suggestions": DAYPARTS, + "visibility_labels": VISIBILITY_LABELS, + "visibility_descriptions": VISIBILITY_DESCRIPTIONS, + "role_labels": ROLE_LABELS, "today": date.today(), "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], + "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", } @app.get("/uploads/") diff --git a/nouri/admin.py b/nouri/admin.py new file mode 100644 index 0000000..7ba2278 --- /dev/null +++ b/nouri/admin.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from flask import Blueprint, flash, g, redirect, render_template, request, url_for +from werkzeug.security import generate_password_hash + +from .auth import admin_required, can_remove_last_admin, validate_admin_user_form +from .constants import ROLE_LABELS +from .db import get_db + + +admin_bp = Blueprint("admin", __name__, url_prefix="/admin") + + +def get_household_user(user_id: int): + user = get_db().execute( + """ + SELECT users.*, households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE users.id = ? AND users.household_id = ? + """, + (user_id, g.user["household_id"]), + ).fetchone() + if user is None: + raise ValueError("Der Nutzer wurde nicht gefunden.") + return user + + +@admin_bp.get("/users") +@admin_required +def user_list(): + users = get_db().execute( + """ + SELECT * + FROM users + WHERE household_id = ? + ORDER BY is_active DESC, LOWER(COALESCE(display_name, username)) + """, + (g.user["household_id"],), + ).fetchall() + return render_template("admin/users_list.html", users=users, role_labels=ROLE_LABELS) + + +@admin_bp.route("/users/new", methods=("GET", "POST")) +@admin_required +def user_create(): + form_data = { + "display_name": "", + "username": "", + "email": "", + "role": "member", + "is_active": True, + } + + if request.method == "POST": + database = get_db() + form_data = { + "display_name": request.form.get("display_name", "").strip(), + "username": request.form.get("username", "").strip().lower(), + "email": request.form.get("email", "").strip().lower(), + "role": request.form.get("role", "member").strip(), + "is_active": request.form.get("is_active", "1") == "1", + } + password = request.form.get("password", "") + password_repeat = request.form.get("password_repeat", "") + + error = validate_admin_user_form( + database, + username=form_data["username"], + email=form_data["email"] or None, + role=form_data["role"], + is_active=form_data["is_active"], + password=password, + password_repeat=password_repeat, + ) + + if error is None: + database.execute( + """ + INSERT INTO users (household_id, username, email, display_name, role, is_active, password_hash) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + g.user["household_id"], + form_data["username"], + form_data["email"] or None, + form_data["display_name"], + form_data["role"], + 1 if form_data["is_active"] else 0, + generate_password_hash(password), + ), + ) + database.commit() + flash("Der Nutzer wurde angelegt.", "success") + return redirect(url_for("admin.user_list")) + + flash(error, "error") + + return render_template("admin/user_form.html", user=None, form_data=form_data, role_labels=ROLE_LABELS) + + +@admin_bp.route("/users//edit", methods=("GET", "POST")) +@admin_required +def user_edit(user_id: int): + try: + user = get_household_user(user_id) + except ValueError as exc: + flash(str(exc), "error") + return redirect(url_for("admin.user_list")) + + form_data = { + "display_name": user["display_name"] or "", + "username": user["username"], + "email": user["email"] or "", + "role": user["role"], + "is_active": bool(user["is_active"]), + } + + if request.method == "POST": + database = get_db() + form_data = { + "display_name": request.form.get("display_name", "").strip(), + "username": request.form.get("username", "").strip().lower(), + "email": request.form.get("email", "").strip().lower(), + "role": request.form.get("role", "member").strip(), + "is_active": request.form.get("is_active", "0") == "1", + } + password = request.form.get("password", "") + password_repeat = request.form.get("password_repeat", "") + + error = validate_admin_user_form( + database, + username=form_data["username"], + email=form_data["email"] or None, + role=form_data["role"], + is_active=form_data["is_active"], + password=password, + password_repeat=password_repeat, + current_user_id=user_id, + ) + if error is None and can_remove_last_admin(user_id, form_data["role"], form_data["is_active"]): + error = "Mindestens ein aktiver Admin sollte im Haushalt bleiben." + + if error is None: + database.execute( + """ + UPDATE users + SET username = ?, + email = ?, + display_name = ?, + role = ?, + is_active = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + form_data["username"], + form_data["email"] or None, + form_data["display_name"], + form_data["role"], + 1 if form_data["is_active"] else 0, + user_id, + ), + ) + if password: + database.execute( + """ + UPDATE users + SET password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (generate_password_hash(password), user_id), + ) + database.commit() + flash("Der Nutzer wurde aktualisiert.", "success") + return redirect(url_for("admin.user_list")) + + flash(error, "error") + + return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS) diff --git a/nouri/auth.py b/nouri/auth.py index cd291e2..d7fa0fe 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -16,7 +16,8 @@ from flask import ( from markupsafe import Markup from werkzeug.security import check_password_hash, generate_password_hash -from .db import get_db, user_count +from .constants import ROLE_LABELS +from .db import active_admin_count, ensure_default_household, get_db, user_count auth_bp = Blueprint("auth", __name__, url_prefix="/auth") @@ -32,6 +33,19 @@ def login_required(view): return wrapped_view +def admin_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for("auth.login")) + if g.user["role"] != "admin": + flash("Dieser Bereich ist für Admins gedacht.", "error") + return redirect(url_for("main.dashboard")) + return view(**kwargs) + + return wrapped_view + + def ensure_csrf_token() -> str: token = session.get("_csrf_token") if not token: @@ -39,6 +53,32 @@ def ensure_csrf_token() -> str: return token +def normalize_login_value(raw: str) -> str: + return raw.strip().lower() + + +def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None: + if not username: + return "Bitte einen Benutzernamen eintragen." + + existing_user = database.execute( + "SELECT id FROM users WHERE LOWER(username) = LOWER(?)", + (username,), + ).fetchone() + if existing_user and int(existing_user["id"]) != current_user_id: + return "Dieser Benutzername ist bereits vergeben." + + if email: + existing_email = database.execute( + "SELECT id FROM users WHERE LOWER(email) = LOWER(?)", + (email,), + ).fetchone() + if existing_email and int(existing_email["id"]) != current_user_id: + return "Diese E-Mail-Adresse wird bereits verwendet." + + return None + + @auth_bp.app_context_processor def inject_csrf_input(): return { @@ -56,9 +96,18 @@ def load_logged_in_user(): g.user = None else: g.user = get_db().execute( - "SELECT * FROM users WHERE id = ?", + """ + SELECT users.*, + households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE users.id = ? + """, (user_id,), ).fetchone() + if g.user is not None and not g.user["is_active"]: + session.clear() + g.user = None endpoint = request.endpoint or "" if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}: @@ -78,27 +127,32 @@ def setup(): return redirect(url_for("auth.login")) if request.method == "POST": - username = request.form.get("username", "").strip().lower() + household_name = request.form.get("household_name", "").strip() or "Unser Haushalt" + username = normalize_login_value(request.form.get("username", "")) + email = normalize_login_value(request.form.get("email", "")) or None display_name = request.form.get("display_name", "").strip() password = request.form.get("password", "") password_repeat = request.form.get("password_repeat", "") + database = get_db() - error = None - if not username: - error = "Bitte einen Benutzernamen eintragen." - elif not password: + error = validate_identity_fields(database, username, email) + if error is None and not password: error = "Bitte ein Passwort vergeben." - elif password != password_repeat: + elif error is None and password != password_repeat: error = "Die Passwörter stimmen nicht überein." if error is None: - database = get_db() + database.execute( + "INSERT INTO households (name) VALUES (?)", + (household_name,), + ) + household_id = ensure_default_household(database) database.execute( """ - INSERT INTO users (username, display_name, password_hash) - VALUES (?, ?, ?) + INSERT INTO users (household_id, username, email, display_name, role, password_hash) + VALUES (?, ?, ?, ?, 'admin', ?) """, - (username, display_name, generate_password_hash(password)), + (household_id, username, email, display_name, generate_password_hash(password)), ) database.commit() flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success") @@ -115,22 +169,28 @@ def login(): return redirect(url_for("auth.setup")) if request.method == "POST": - username = request.form.get("username", "").strip().lower() + identity = normalize_login_value(request.form.get("username", "")) password = request.form.get("password", "") remember_me = request.form.get("remember_me") == "1" database = get_db() user = database.execute( - "SELECT * FROM users WHERE username = ?", - (username,), + """ + SELECT users.*, households.name AS household_name + FROM users + LEFT JOIN households ON households.id = users.household_id + WHERE LOWER(users.username) = LOWER(?) OR LOWER(COALESCE(users.email, '')) = LOWER(?) + """, + (identity, identity), ).fetchone() error = None if user is None or not check_password_hash(user["password_hash"], password): - error = "Benutzername oder Passwort passen nicht zusammen." + error = "Login oder Passwort passen nicht zusammen." + elif not user["is_active"]: + error = "Dieser Zugang ist derzeit nicht aktiv." if error is None: session.clear() - # Opt-in long-lived session so the shared household device stays low-friction. session.permanent = remember_me session["user_id"] = user["id"] ensure_csrf_token() @@ -141,8 +201,109 @@ def login(): return render_template("auth/login.html") +@auth_bp.route("/profile", methods=("GET", "POST")) +@login_required +def profile(): + database = get_db() + + if request.method == "POST": + username = normalize_login_value(request.form.get("username", "")) + email = normalize_login_value(request.form.get("email", "")) or None + display_name = request.form.get("display_name", "").strip() + + error = validate_identity_fields(database, username, email, current_user_id=g.user["id"]) + if error is None: + database.execute( + """ + UPDATE users + SET username = ?, email = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (username, email, display_name, g.user["id"]), + ) + database.commit() + flash("Dein Profil wurde aktualisiert.", "success") + return redirect(url_for("auth.profile")) + + flash(error, "error") + + return render_template("auth/profile.html", role_labels=ROLE_LABELS) + + +@auth_bp.post("/password") +@login_required +def change_password(): + current_password = request.form.get("current_password", "") + new_password = request.form.get("new_password", "") + new_password_repeat = request.form.get("new_password_repeat", "") + + error = None + if not check_password_hash(g.user["password_hash"], current_password): + error = "Das aktuelle Passwort stimmt nicht." + elif not new_password: + error = "Bitte ein neues Passwort eintragen." + elif new_password != new_password_repeat: + error = "Die neuen Passwörter stimmen nicht überein." + + if error is None: + get_db().execute( + """ + UPDATE users + SET password_hash = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (generate_password_hash(new_password), g.user["id"]), + ) + get_db().commit() + flash("Dein Passwort wurde geändert.", "success") + else: + flash(error, "error") + + return redirect(url_for("auth.profile")) + + @auth_bp.post("/logout") def logout(): session.clear() flash("Du bist abgemeldet.", "info") return redirect(url_for("auth.login")) + + +def validate_admin_user_form( + database, + *, + username: str, + email: str | None, + role: str, + is_active: bool, + password: str, + password_repeat: str, + current_user_id: int | None = None, +) -> str | None: + error = validate_identity_fields(database, username, email, current_user_id=current_user_id) + if error: + return error + if role not in ROLE_LABELS: + return "Bitte eine gültige Rolle auswählen." + if current_user_id is None and not password: + return "Bitte ein Passwort vergeben." + if password and password != password_repeat: + return "Die Passwörter stimmen nicht überein." + if current_user_id == g.user["id"] and not is_active: + return "Du kannst deinen eigenen Zugang hier nicht deaktivieren." + return None + + +def can_remove_last_admin(target_user_id: int, new_role: str, is_active: bool) -> bool: + if g.user is None: + return False + if target_user_id != g.user["id"] and g.user["role"] == "admin": + target = get_db().execute("SELECT * FROM users WHERE id = ?", (target_user_id,)).fetchone() + if target is None: + return False + if target["role"] != "admin" or not target["is_active"]: + return False + if new_role == "admin" and is_active: + return False + return active_admin_count(g.user["household_id"]) <= 1 + return False diff --git a/nouri/constants.py b/nouri/constants.py index a5e75e4..f44c8bc 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -35,3 +35,18 @@ AVAILABILITY_LABELS = { "home": "Zuhause", "archived": "Archiv", } + +ROLE_LABELS = { + "admin": "Admin", + "member": "Mitglied", +} + +VISIBILITY_LABELS = { + "shared": "Für alle", + "personal": "Persönlich", +} + +VISIBILITY_DESCRIPTIONS = { + "shared": "Gemeinsam im Haushalt sichtbar und nutzbar.", + "personal": "Nur für dich sichtbar und planbar.", +} diff --git a/nouri/db.py b/nouri/db.py index 07554af..148309b 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -28,9 +28,195 @@ def close_db(_error=None) -> None: database.close() +def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]: + rows = database.execute(f"PRAGMA table_info({table_name})").fetchall() + return {row["name"] for row in rows} + + +def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None: + column_name = definition.split()[0] + if column_name not in table_columns(database, table_name): + database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}") + + +def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: + database.execute( + """ + CREATE TABLE IF NOT EXISTS households ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + existing_tables = { + row["name"] + for row in database.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ).fetchall() + } + + if "users" in existing_tables: + add_column_if_missing(database, "users", "household_id INTEGER") + add_column_if_missing(database, "users", "email TEXT") + add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") + add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1") + add_column_if_missing(database, "users", "updated_at TEXT") + + if "items" in existing_tables: + add_column_if_missing(database, "items", "household_id INTEGER") + add_column_if_missing(database, "items", "owner_user_id INTEGER") + add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") + + if "shopping_entries" in existing_tables: + add_column_if_missing(database, "shopping_entries", "household_id INTEGER") + add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER") + add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") + + if "plan_entries" in existing_tables: + add_column_if_missing(database, "plan_entries", "household_id INTEGER") + add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER") + add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") + + +def ensure_default_household(database: sqlite3.Connection) -> int: + household = database.execute( + "SELECT id FROM households ORDER BY id LIMIT 1" + ).fetchone() + if household: + return int(household["id"]) + + database.execute( + "INSERT INTO households (name) VALUES (?)", + ("Unser Haushalt",), + ) + return int( + database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"] + ) + + +def first_user_id(database: sqlite3.Connection) -> int | None: + row = database.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone() + return int(row["id"]) if row else None + + +def ensure_schema_upgrades(database: sqlite3.Connection) -> None: + add_column_if_missing(database, "users", "household_id INTEGER") + add_column_if_missing(database, "users", "email TEXT") + add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") + add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1") + add_column_if_missing(database, "users", "updated_at TEXT") + + default_household_id = ensure_default_household(database) + database.execute( + "UPDATE users SET household_id = ? WHERE household_id IS NULL", + (default_household_id,), + ) + database.execute( + "UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''", + ) + database.execute( + "UPDATE users SET is_active = 1 WHERE is_active IS NULL", + ) + database.execute( + "UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''", + ) + database.execute( + "UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)" + ) + + admin_row = database.execute( + "SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1" + ).fetchone() + if admin_row is None: + first_id = first_user_id(database) + if first_id is not None: + database.execute( + "UPDATE users SET role = 'admin' WHERE id = ?", + (first_id,), + ) + + default_owner_id = first_user_id(database) + for table_name in ("items", "shopping_entries", "plan_entries"): + add_column_if_missing(database, table_name, "household_id INTEGER") + add_column_if_missing(database, table_name, "owner_user_id INTEGER") + add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") + + if default_owner_id is not None: + database.execute( + """ + UPDATE items + SET household_id = COALESCE(household_id, ?), + owner_user_id = COALESCE(owner_user_id, created_by, ?), + visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END + WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = '' + """, + (default_household_id, default_owner_id), + ) + database.execute( + """ + UPDATE shopping_entries + SET household_id = COALESCE(household_id, ?), + owner_user_id = COALESCE(owner_user_id, added_by, ?), + visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END + WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = '' + """, + (default_household_id, default_owner_id), + ) + database.execute( + """ + UPDATE plan_entries + SET household_id = COALESCE(household_id, ?), + owner_user_id = COALESCE(owner_user_id, created_by, ?), + visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END + WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = '' + """, + (default_household_id, default_owner_id), + ) + else: + database.execute( + "UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" + ) + database.execute( + "UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" + ) + database.execute( + "UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" + ) + + database.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique + ON users (email) + WHERE email IS NOT NULL AND email != '' + """ + ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_items_household_visibility + ON items (household_id, visibility, availability_state) + """ + ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility + ON plan_entries (household_id, visibility, plan_date) + """ + ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility + ON shopping_entries (household_id, visibility, is_checked) + """ + ) + + def apply_schema(database: sqlite3.Connection) -> None: + bootstrap_legacy_schema(database) schema_path = Path(__file__).with_name("schema.sql") database.executescript(schema_path.read_text(encoding="utf-8")) + ensure_schema_upgrades(database) sync_dayparts(database) @@ -69,6 +255,18 @@ def user_count() -> int: return int(row["count"]) +def active_admin_count(household_id: int) -> int: + row = get_db().execute( + """ + SELECT COUNT(*) AS count + FROM users + WHERE household_id = ? AND role = 'admin' AND is_active = 1 + """, + (household_id,), + ).fetchone() + return int(row["count"]) + + @click.command("init-db") @with_appcontext def init_db_command() -> None: @@ -80,15 +278,25 @@ def init_db_command() -> None: @click.argument("username") @click.argument("password") @click.option("--display-name", default="", help="Friendly display name.") +@click.option("--email", default="", help="Optional email address.") +@click.option("--role", default="member", type=click.Choice(["admin", "member"])) @with_appcontext -def create_user_command(username: str, password: str, display_name: str) -> None: +def create_user_command(username: str, password: str, display_name: str, email: str, role: str) -> None: database = get_db() + household_id = ensure_default_household(database) database.execute( """ - INSERT INTO users (username, display_name, password_hash) - VALUES (?, ?, ?) + INSERT INTO users (household_id, username, email, display_name, role, password_hash) + VALUES (?, ?, ?, ?, ?, ?) """, - (username.strip().lower(), display_name.strip(), generate_password_hash(password)), + ( + household_id, + username.strip().lower(), + email.strip().lower() or None, + display_name.strip(), + role, + generate_password_hash(password), + ), ) database.commit() click.echo(f"User '{username}' created.") diff --git a/nouri/main.py b/nouri/main.py index 8ff0e75..986dbb4 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -24,6 +24,8 @@ from .constants import ( CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, + VISIBILITY_DESCRIPTIONS, + VISIBILITY_LABELS, ) from .db import get_db @@ -41,19 +43,21 @@ KIND_FILTER_OPTIONS = [ ("food", "Lebensmittel"), ("meal", "Mahlzeitenideen"), ] +VISIBILITY_FILTER_OPTIONS = [ + ("", "Alles Sichtbare"), + ("shared", "Für alle"), + ("personal", "Persönlich"), +] +VISIBILITY_FORM_OPTIONS = [ + ("shared", "Für alle"), + ("personal", "Persönlich"), +] 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: @@ -74,6 +78,54 @@ def parse_plan_date(raw: str | None, fallback: date | None = None) -> date: return fallback or date.today() +def normalize_visibility(raw: str | None, default: str = "shared") -> str: + if raw in VISIBILITY_LABELS: + return raw + return default + + +def current_household_id() -> int: + return int(g.user["household_id"]) + + +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 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")) + entry["owner_name"] = owner_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"), "Für alle") + entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") + entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" + entry["context_label"] = "Gemeinsam" if entry["is_shared"] else "Persönlich" + 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 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 allowed_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS @@ -99,18 +151,26 @@ def save_photo(upload, current_filename: str | None = None) -> str | None: return filename -def get_item(item_id: int): +def get_item(item_id: int) -> dict: item = get_db().execute( - """ - SELECT * + f""" + SELECT items.*, + owner.display_name AS owner_display_name, + owner.username AS owner_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 - WHERE id = ? + LEFT JOIN users AS owner ON owner.id = items.owner_user_id + WHERE items.id = ? AND {visible_clause('items')} """, - (item_id,), + [item_id, *visible_params()], ).fetchone() if item is None: raise ValueError("Der Eintrag wurde nicht gefunden.") - return item + return describe_record(dict(item)) def get_item_daypart_ids(item_id: int) -> list[int]: @@ -118,25 +178,31 @@ def get_item_daypart_ids(item_id: int) -> list[int]: "SELECT daypart_id FROM item_dayparts WHERE item_id = ?", (item_id,), ).fetchall() - return [row["daypart_id"] for row in rows] + return [int(row["daypart_id"]) for row in rows] def get_meal_component_ids(meal_id: int) -> list[int]: rows = get_db().execute( - "SELECT food_item_id FROM meal_components WHERE meal_item_id = ?", - (meal_id,), + """ + 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 [row["food_item_id"] for row in rows] + return [int(row["food_item_id"]) for row in rows] -def attach_dayparts(items: list) -> list[dict]: +def attach_dayparts(items: list[dict]) -> list[dict]: if not items: return [] - database = get_db() item_ids = [item["id"] for item in items] placeholders = ",".join("?" for _ in item_ids) - rows = database.execute( + rows = get_db().execute( f""" SELECT item_dayparts.item_id, dayparts.id, dayparts.slug, dayparts.name FROM item_dayparts @@ -158,11 +224,10 @@ def attach_dayparts(items: list) -> list[dict]: enriched = [] for item in items: - entry = dict(item) - 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) + 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 + enriched.append(item) return enriched @@ -180,9 +245,11 @@ def attach_components(items: list[dict]) -> list[dict]: 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, + [*meal_ids, current_household_id(), g.user["id"]], ).fetchall() grouped = defaultdict(list) for row in rows: @@ -194,15 +261,17 @@ def attach_components(items: list[dict]) -> list[dict]: 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, ): database = get_db() - conditions = [] - params = [] + conditions = [visible_clause("items")] + params = visible_params() if kind: conditions.append("items.kind = ?") @@ -227,25 +296,31 @@ def fetch_items( """ ) params.append(daypart_id) + if visibility: + conditions.append("items.visibility = ?") + params.append(visibility) - where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" rows = database.execute( f""" SELECT items.*, + owner.display_name AS owner_display_name, + owner.username AS owner_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 - {where_clause} + LEFT JOIN users AS owner ON owner.id = items.owner_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(rows)) + return attach_components(attach_dayparts(describe_records(rows))) def fetch_food_options(): @@ -272,28 +347,37 @@ def group_items_by_availability(items: list[dict]) -> list[dict]: 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 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")), + "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: 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', ?, ?, ?, ?, ?) + INSERT INTO items ( + household_id, owner_user_id, visibility, kind, name, category, note, created_by, updated_by + ) + VALUES (?, ?, ?, 'food', ?, ?, ?, ?, ?) """, ( + current_household_id(), + g.user["id"], + form_data["visibility"], form_data["quick_food_name"], form_data["quick_food_category"], form_data["quick_food_note"], @@ -301,14 +385,15 @@ def create_quick_food_from_form(form_data: dict) -> int: g.user["id"], ), ) - food_id = cursor.lastrowid + food_id = int(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: +def add_to_shopping_list(item_id: int, user_id: int, visibility_override: str | None = None) -> bool: database = get_db() + item = get_item(item_id) existing = database.execute( """ SELECT id FROM shopping_entries @@ -319,23 +404,24 @@ def add_to_shopping_list(item_id: int, user_id: int) -> bool: if existing: return False + visibility = normalize_visibility(visibility_override, item["visibility"]) + owner_user_id = user_id if visibility == "personal" else item["owner_user_id"] database.execute( """ - INSERT INTO shopping_entries (item_id, added_by) - VALUES (?, ?) + INSERT INTO shopping_entries (household_id, owner_user_id, visibility, item_id, added_by) + VALUES (?, ?, ?, ?, ?) """, - (item_id, user_id), + (current_household_id(), owner_user_id, visibility, item_id, user_id), ) database.commit() return True -def ensure_planned_item_is_shopped(item_id: int, user_id: int) -> bool: +def ensure_planned_item_is_shopped(item_id: int, user_id: int, visibility: str) -> 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) + return add_to_shopping_list(item_id, user_id, visibility_override=visibility) def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: @@ -351,7 +437,20 @@ def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: database = get_db() database.execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,)) + visible_foods = { + row["id"] + for row in database.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 not in visible_foods: + continue database.execute( """ INSERT INTO meal_components (meal_item_id, food_item_id) @@ -362,27 +461,34 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: def fetch_shopping_entries(): - return get_db().execute( - """ + rows = get_db().execute( + f""" SELECT shopping_entries.*, items.name AS item_name, items.kind AS item_kind, items.photo_filename, items.availability_state, - users.display_name, - users.username + owner.display_name AS owner_display_name, + owner.username AS owner_username, + added_by_user.display_name AS added_by_display_name, + added_by_user.username AS added_by_username FROM shopping_entries JOIN items ON items.id = shopping_entries.item_id - LEFT JOIN users ON users.id = shopping_entries.added_by - WHERE shopping_entries.is_checked = 0 - ORDER BY shopping_entries.added_at DESC - """ + LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id + LEFT JOIN users AS added_by_user ON added_by_user.id = shopping_entries.added_by + 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() + return describe_records(rows) 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, @@ -390,18 +496,21 @@ def fetch_plan_entries_for_range(start_date: date, end_date: date): items.availability_state, dayparts.name AS daypart_name, dayparts.slug AS daypart_slug, - dayparts.sort_order + dayparts.sort_order, + owner.display_name AS owner_display_name, + owner.username AS owner_username 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 ? + LEFT JOIN users AS owner ON owner.id = plan_entries.owner_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()), + [start_date.isoformat(), end_date.isoformat(), *visible_params()], ).fetchall() grouped = defaultdict(list) - for row in rows: - grouped[(row["plan_date"], row["daypart_id"])].append(dict(row)) + for row in describe_records(rows): + grouped[(row["plan_date"], row["daypart_id"])].append(row) return grouped @@ -409,100 +518,62 @@ 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 + f""" + SELECT DISTINCT items.id, + items.household_id, + items.owner_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 FROM plan_entries JOIN items ON items.id = plan_entries.item_id - WHERE plan_entries.daypart_id = ? + LEFT JOIN users AS owner ON owner.id = items.owner_user_id + WHERE plan_entries.daypart_id = ? AND {visible_clause('items')} ORDER BY plan_entries.created_at DESC LIMIT ? """, - (daypart_id, limit * 3), + [daypart_id, *visible_params(), limit * 3], ).fetchall() - return attach_components(attach_dayparts(rows)) + return attach_components(attach_dayparts(describe_records(rows))) def fetch_plan_candidates(daypart_id: int, query: str | None = None): - params = [daypart_id] - conditions = ["items.availability_state != 'archived'"] + 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()}%") - where_clause = f"WHERE {' AND '.join(conditions)}" rows = get_db().execute( f""" SELECT items.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, 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} + LEFT JOIN users AS owner ON owner.id = items.owner_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(rows)) + return attach_components(attach_dayparts(describe_records(rows))) def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): @@ -541,6 +612,19 @@ def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: return sections +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 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) @@ -550,8 +634,7 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None 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"]) + 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) @@ -564,40 +647,69 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None "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], + "default_visibility": "shared", } ) return sections +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 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"]) + + @main_bp.get("/") @login_required def dashboard(): - database = get_db() today = date.today() - home_count = database.execute( - "SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'" + 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"] - shopping_count = database.execute( - "SELECT COUNT(*) AS count FROM shopping_entries WHERE is_checked = 0" - ).fetchone()["count"] - archive_count = database.execute( - "SELECT COUNT(*) AS count FROM items WHERE availability_state = 'archived'" - ).fetchone()["count"] - today_entries = database.execute( - """ - 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 - JOIN dayparts ON dayparts.id = plan_entries.daypart_id - WHERE plan_entries.plan_date = ? - ORDER BY dayparts.sort_order, items.name - """, - (today.isoformat(),), - ).fetchall() + 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( @@ -612,6 +724,12 @@ def dashboard(): ) +@main_bp.get("/more") +@login_required +def more_view(): + return render_template("more.html") + + @main_bp.route("/items/") @login_required def item_list(kind: str): @@ -620,6 +738,7 @@ def item_list(kind: str): 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 @@ -628,6 +747,7 @@ def item_list(kind: str): availability=state or None, query=query or None, daypart_id=daypart_id, + visibility=scope or None, ) return render_template( "items/list.html", @@ -636,9 +756,11 @@ def item_list(kind: str): 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(), ) @@ -657,6 +779,7 @@ def item_create(kind: str): "name": "", "category": "", "note": "", + "visibility": "shared", "daypart_ids": [], "component_ids": [], "quick_food_name": "", @@ -666,12 +789,8 @@ def item_create(kind: str): if request.method == "POST": form_action = request.form.get("form_action", "save_item") - form_data.update(extract_item_form_data()) + form_data = extract_item_form_data(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"]: @@ -692,10 +811,10 @@ def item_create(kind: str): kind=kind, item=None, dayparts=dayparts, - foods=foods, food_groups=food_groups, categories=CATEGORIES, form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, ) error = None @@ -712,15 +831,28 @@ def item_create(kind: str): if error is None: cursor = database.execute( """ - INSERT INTO items (kind, name, category, note, photo_filename, created_by, updated_by) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO items ( + household_id, owner_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (kind, name, category, note, photo_filename, g.user["id"], g.user["id"]), + ( + current_household_id(), + g.user["id"], + form_data["visibility"], + kind, + name, + form_data["category"], + form_data["note"], + photo_filename, + g.user["id"], + g.user["id"], + ), ) - item_id = cursor.lastrowid - sync_item_dayparts(item_id, daypart_ids) + item_id = int(cursor.lastrowid) + sync_item_dayparts(item_id, form_data["daypart_ids"]) if kind == "meal": - sync_meal_components(item_id, component_ids) + sync_meal_components(item_id, form_data["component_ids"]) database.commit() flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success") return redirect(url_for("main.item_list", kind=kind)) @@ -732,10 +864,10 @@ def item_create(kind: str): kind=kind, item=None, dayparts=dayparts, - foods=foods, food_groups=food_groups, categories=CATEGORIES, form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, ) @@ -743,7 +875,13 @@ def item_create(kind: str): @login_required def item_edit(item_id: int): database = get_db() - item = get_item(item_id) + 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")) + kind = item["kind"] dayparts = get_dayparts() foods = fetch_food_options() @@ -752,6 +890,7 @@ def item_edit(item_id: int): "name": item["name"], "category": item["category"] or "", "note": item["note"] or "", + "visibility": item["visibility"], "daypart_ids": get_item_daypart_ids(item_id), "component_ids": get_meal_component_ids(item_id) if kind == "meal" else [], "quick_food_name": "", @@ -761,12 +900,8 @@ def item_edit(item_id: int): if request.method == "POST": form_action = request.form.get("form_action", "save_item") - form_data.update(extract_item_form_data()) + form_data = extract_item_form_data(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"]: @@ -787,10 +922,10 @@ def item_edit(item_id: int): kind=kind, item=item, dayparts=dayparts, - foods=foods, food_groups=food_groups, categories=CATEGORIES, form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, ) error = None @@ -808,14 +943,28 @@ def item_edit(item_id: int): database.execute( """ UPDATE items - SET name = ?, category = ?, note = ?, photo_filename = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP + SET name = ?, + category = ?, + note = ?, + visibility = ?, + photo_filename = ?, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (name, category, note, photo_filename, g.user["id"], item_id), + ( + name, + form_data["category"], + form_data["note"], + form_data["visibility"], + photo_filename, + g.user["id"], + item_id, + ), ) - sync_item_dayparts(item_id, daypart_ids) + sync_item_dayparts(item_id, form_data["daypart_ids"]) if kind == "meal": - sync_meal_components(item_id, component_ids) + sync_meal_components(item_id, form_data["component_ids"]) database.commit() flash("Der Eintrag wurde aktualisiert.", "success") return redirect(url_for("main.item_list", kind=kind)) @@ -827,18 +976,23 @@ def item_edit(item_id: int): kind=kind, item=item, dayparts=dayparts, - foods=foods, food_groups=food_groups, categories=CATEGORIES, form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, ) @main_bp.post("/items//shopping") @login_required def item_add_to_shopping(item_id: int): - item = get_item(item_id) - added = add_to_shopping_list(item_id, g.user["id"]) + try: + item = get_item(item_id) + except ValueError as exc: + flash(str(exc), "error") + return redirect(request.referrer or url_for("main.shopping_list")) + + added = add_to_shopping_list(item_id, g.user["id"], visibility_override=item["visibility"]) if added: flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") else: @@ -849,9 +1003,14 @@ def item_add_to_shopping(item_id: int): @main_bp.post("/items//set-home") @login_required def item_set_home(item_id: int): - item = get_item(item_id) - database = get_db() - database.execute( + 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 @@ -859,7 +1018,7 @@ def item_set_home(item_id: int): """, (g.user["id"], item_id), ) - database.commit() + get_db().commit() flash(f"{item['name']} ist jetzt unter Zuhause sichtbar.", "success") return redirect(request.referrer or url_for("main.home_view")) @@ -867,9 +1026,14 @@ def item_set_home(item_id: int): @main_bp.post("/items//archive") @login_required def item_archive(item_id: int): - item = get_item(item_id) - database = get_db() - database.execute( + 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 @@ -877,7 +1041,7 @@ def item_archive(item_id: int): """, (g.user["id"], item_id), ) - database.commit() + 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")) @@ -885,9 +1049,14 @@ def item_archive(item_id: int): @main_bp.post("/items//restore") @login_required def item_restore(item_id: int): - item = get_item(item_id) - database = get_db() - database.execute( + 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 @@ -895,7 +1064,7 @@ def item_restore(item_id: int): """, (g.user["id"], item_id), ) - database.commit() + get_db().commit() flash(f"{item['name']} ist wieder in der aktiven Liste.", "success") return redirect(request.referrer or url_for("main.archive_view")) @@ -907,46 +1076,53 @@ def shopping_list(): if request.method == "POST": selected_item_id = request.form.get("item_id", "").strip() - if not selected_item_id: + if not selected_item_id or not selected_item_id.isdigit(): 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"]) - if added: - flash(f"{item['name']} wurde auf die Einkaufsliste gesetzt.", "success") - else: - flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + try: + item = get_item(int(selected_item_id)) + added = add_to_shopping_list(item["id"], g.user["id"], visibility_override=item["visibility"]) + if added: + flash(f"{item['name']} wurde auf die Einkaufsliste gesetzt.", "success") + else: + flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + except ValueError as exc: + flash(str(exc), "error") return redirect(url_for("main.shopping_list")) entries = fetch_shopping_entries() - addable_items = database.execute( - """ - SELECT items.id, items.name, items.kind, items.availability_state - FROM items - WHERE items.availability_state != 'archived' - AND NOT EXISTS ( - SELECT 1 FROM shopping_entries - WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 - ) - ORDER BY CASE items.availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(items.name) - """ - ).fetchall() + 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//check") @login_required def shopping_check(entry_id: int): - database = get_db() - entry = database.execute( - "SELECT * FROM shopping_entries WHERE id = ?", - (entry_id,), + 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")) - item = get_item(entry["item_id"]) + 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")) + + database = get_db() database.execute( """ UPDATE shopping_entries @@ -971,9 +1147,30 @@ def shopping_check(entry_id: int): @main_bp.post("/shopping//remove") @login_required def shopping_remove(entry_id: int): - database = get_db() - database.execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,)) - database.commit() + 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")) + + entry_dict = describe_record(dict(entry)) + try: + ensure_can_edit(entry_dict, "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")) @@ -982,6 +1179,7 @@ def shopping_remove(entry_id: int): @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() @@ -989,6 +1187,7 @@ def home_view(): availability="home", query=query or None, daypart_id=daypart_id, + visibility=scope or None, ) sections = build_home_sections(items, dayparts, daypart_id) return render_template( @@ -997,6 +1196,8 @@ def home_view(): query=query, dayparts=dayparts, selected_daypart_id=daypart_id, + selected_visibility=scope, + visibility_options=VISIBILITY_FILTER_OPTIONS, today=date.today(), ) @@ -1006,19 +1207,24 @@ def home_view(): 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(), ) @@ -1047,25 +1253,41 @@ def planner_day(): 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") error = None - if not item_id_raw: + if not item_id_raw or not item_id_raw.isdigit(): error = "Bitte etwas für den Tagesplan auswählen." - elif not daypart_id_raw: + elif not daypart_id_raw or not daypart_id_raw.isdigit(): error = "Bitte eine Tageszeit auswählen." if error is None: item_id = int(item_id_raw) daypart_id = int(daypart_id_raw) + try: + item = get_item(item_id) + except ValueError as exc: + error = str(exc) + + if error is None: get_db().execute( """ - INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by) - VALUES (?, ?, ?, ?, ?) + INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (selected_date.isoformat(), daypart_id, item_id, note, g.user["id"]), + ( + current_household_id(), + g.user["id"], + visibility, + 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"]): + if ensure_planned_item_is_shopped(item_id, g.user["id"], visibility): 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( @@ -1086,6 +1308,7 @@ def planner_day(): next_day=selected_date + timedelta(days=1), sections=sections, today=date.today(), + visibility_options=VISIBILITY_FORM_OPTIONS, ) @@ -1093,9 +1316,28 @@ def planner_day(): @login_required def planner_remove(entry_id: int): 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") + 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")) @@ -1112,12 +1354,25 @@ def planner_move(entry_id: int): database = get_db() entry = database.execute( - "SELECT * FROM plan_entries WHERE id = ?", - (entry_id,), + 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 + entry_dict = describe_record(dict(entry)) + try: + ensure_can_edit(entry_dict, "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) database.execute( """ @@ -1129,8 +1384,7 @@ def planner_move(entry_id: int): ) 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"]) + was_added_to_shopping = ensure_planned_item_is_shopped(entry["item_id"], g.user["id"], entry["visibility"]) if was_added_to_shopping: flash("Der verschobene Eintrag ist noch nicht zuhause und wurde auf die Einkaufsliste gesetzt.", "info") return jsonify( diff --git a/nouri/schema.sql b/nouri/schema.sql index a653e93..0792280 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -1,13 +1,29 @@ PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS households ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER, username TEXT NOT NULL UNIQUE, + email TEXT, display_name TEXT, + role TEXT NOT NULL DEFAULT 'member', + is_active INTEGER NOT NULL DEFAULT 1, password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE RESTRICT ); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique +ON users (email) +WHERE email IS NOT NULL AND email != ''; + CREATE TABLE IF NOT EXISTS dayparts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -17,6 +33,9 @@ CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), name TEXT NOT NULL, category TEXT, @@ -27,6 +46,8 @@ CREATE TABLE IF NOT EXISTS items ( updated_by INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ); @@ -49,12 +70,17 @@ CREATE TABLE IF NOT EXISTS meal_components ( CREATE TABLE IF NOT EXISTS shopping_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', item_id INTEGER NOT NULL, added_by INTEGER, checked_by INTEGER, is_checked INTEGER NOT NULL DEFAULT 0, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, checked_at TEXT, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL @@ -66,12 +92,17 @@ WHERE is_checked = 0; CREATE TABLE IF NOT EXISTS plan_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', plan_date TEXT NOT NULL, daypart_id INTEGER NOT NULL, item_id INTEGER NOT NULL, note TEXT, created_by INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL @@ -80,11 +111,17 @@ CREATE TABLE IF NOT EXISTS plan_entries ( CREATE INDEX IF NOT EXISTS idx_items_kind_name ON items (kind, name); -CREATE INDEX IF NOT EXISTS idx_items_availability_name -ON items (availability_state, name); +CREATE INDEX IF NOT EXISTS idx_items_household_visibility +ON items (household_id, visibility, availability_state); CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item ON item_dayparts (daypart_id, item_id); CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart ON plan_entries (plan_date, daypart_id); + +CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility +ON plan_entries (household_id, visibility, plan_date); + +CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility +ON shopping_entries (household_id, visibility, is_checked); diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index 12c8989..f79a1d9 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -1,20 +1,21 @@ :root { color-scheme: light; - --bg: #fff5ee; - --bg-elevated: rgba(255, 249, 244, 0.78); - --surface: rgba(255, 255, 255, 0.82); - --surface-strong: #fffdfa; - --surface-soft: #fff0e3; - --line: rgba(133, 113, 95, 0.12); - --text: #342e2d; - --muted: #7c716d; - --accent: #f0a46c; - --accent-strong: #dd8d52; - --accent-soft: rgba(240, 164, 108, 0.18); + --bg: #fff6ef; + --bg-elevated: rgba(255, 248, 242, 0.86); + --surface: rgba(255, 255, 255, 0.86); + --surface-strong: #fffdf9; + --surface-soft: #fff1e4; + --line: rgba(126, 104, 85, 0.14); + --text: #352d2b; + --muted: #7d7069; + --accent: #efab72; + --accent-strong: #da8b4d; + --accent-soft: rgba(239, 171, 114, 0.18); --mint-soft: rgba(174, 214, 193, 0.24); --peach-soft: rgba(255, 210, 179, 0.24); - --sky-soft: rgba(194, 213, 235, 0.2); - --rose-soft: rgba(237, 196, 205, 0.22); + --sky-soft: rgba(194, 213, 235, 0.22); + --rose-soft: rgba(237, 196, 205, 0.24); + --lilac-soft: rgba(199, 176, 224, 0.18); --shadow: 0 20px 50px rgba(125, 92, 68, 0.10); --radius: 22px; --font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; @@ -23,21 +24,22 @@ [data-theme="dark"] { color-scheme: dark; - --bg: #201d1d; - --bg-elevated: rgba(32, 29, 29, 0.82); - --surface: rgba(45, 39, 39, 0.82); - --surface-strong: #383131; - --surface-soft: #433a39; - --line: rgba(226, 232, 225, 0.1); + --bg: #211d1c; + --bg-elevated: rgba(34, 29, 28, 0.86); + --surface: rgba(44, 38, 37, 0.86); + --surface-strong: #3a3230; + --surface-soft: #473d3a; + --line: rgba(228, 224, 220, 0.10); --text: #f4efec; - --muted: #cabeb7; - --accent: #f2b07d; - --accent-strong: #ffc190; - --accent-soft: rgba(242, 176, 125, 0.18); + --muted: #cbbeb7; + --accent: #f3b17d; + --accent-strong: #ffc28f; + --accent-soft: rgba(243, 177, 125, 0.18); --mint-soft: rgba(155, 198, 175, 0.20); --peach-soft: rgba(224, 161, 128, 0.18); --sky-soft: rgba(146, 171, 201, 0.18); --rose-soft: rgba(189, 133, 145, 0.20); + --lilac-soft: rgba(170, 148, 204, 0.18); --shadow: 0 18px 40px rgba(0, 0, 0, 0.28); } @@ -45,7 +47,8 @@ box-sizing: border-box; } -html, body { +html, +body { margin: 0; min-height: 100%; } @@ -60,6 +63,10 @@ body { linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 92%, #f6decb 8%)); } +body.has-mobile-nav { + padding-bottom: 6rem; +} + a { color: inherit; text-decoration: none; @@ -125,31 +132,39 @@ button.secondary:hover, grid-template-columns: auto 1fr auto; gap: 1rem; align-items: center; - padding: 1rem 1.25rem; - margin-bottom: 1.25rem; + padding: 1rem 1.2rem; + margin-bottom: 1.15rem; background: var(--bg-elevated); border: 1px solid var(--line); border-radius: 28px; box-shadow: var(--shadow); - backdrop-filter: blur(26px) saturate(1.2); + backdrop-filter: blur(26px) saturate(1.18); } .brand { display: inline-flex; align-items: center; - gap: 0.85rem; + gap: 0.8rem; + min-width: 0; } .brand strong, -h1, h2, h3, .planner-label { +h1, +h2, +h3, +.planner-label { font-family: var(--font-heading); letter-spacing: -0.02em; } +.brand-copy { + display: grid; + gap: 0.08rem; +} + .brand small { display: block; color: var(--muted); - margin-top: 0.12rem; } .brand-mark { @@ -159,7 +174,7 @@ h1, h2, h3, .planner-label { place-items: center; border-radius: 1rem; background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 236, 219, 0.92)); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.8); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); } .brand-mark img { @@ -167,71 +182,12 @@ h1, h2, h3, .planner-label { height: 100%; } -.nav-link-inner { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.ui-icon { - width: 1rem; - height: 1rem; - display: inline-block; - background: currentColor; - flex: 0 0 auto; - -webkit-mask-position: center; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: contain; - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; -} - -.icon-house { - -webkit-mask-image: url("../icons/fa/house.svg"); - mask-image: url("../icons/fa/house.svg"); -} - -.icon-utensils { - -webkit-mask-image: url("../icons/fa/utensils.svg"); - mask-image: url("../icons/fa/utensils.svg"); -} - -.icon-bowl-food { - -webkit-mask-image: url("../icons/fa/bowl-food.svg"); - mask-image: url("../icons/fa/bowl-food.svg"); -} - -.icon-cart-shopping { - -webkit-mask-image: url("../icons/fa/cart-shopping.svg"); - mask-image: url("../icons/fa/cart-shopping.svg"); -} - -.icon-calendar { - -webkit-mask-image: url("../icons/fa/calendar.svg"); - mask-image: url("../icons/fa/calendar.svg"); -} - -.icon-calendar-days { - -webkit-mask-image: url("../icons/fa/calendar-days.svg"); - mask-image: url("../icons/fa/calendar-days.svg"); -} - -.icon-archive { - -webkit-mask-image: url("../icons/fa/archive.svg"); - mask-image: url("../icons/fa/archive.svg"); -} - -.icon-sparkles { - -webkit-mask-image: url("../icons/fa/sparkles.svg"); - mask-image: url("../icons/fa/sparkles.svg"); -} - .site-nav { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: center; + min-width: 0; } .site-nav a { @@ -244,18 +200,65 @@ h1, h2, h3, .planner-label { .site-nav a:hover { background: var(--accent-soft); color: var(--text); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.nav-link-inner { + display: inline-flex; + align-items: center; + gap: 0.5rem; } .header-actions { display: flex; align-items: center; gap: 0.75rem; + justify-content: flex-end; +} + +.user-chip, +.mobile-profile-link { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.6rem 0.8rem; + border-radius: 999px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 88%, #fff 12%); +} + +.user-chip { + flex-direction: column; + align-items: flex-start; + gap: 0.08rem; +} + +.user-chip-title { + font-weight: 600; +} + +.mobile-profile-link { + display: none; +} + +.mobile-profile-avatar { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-strong); + font-weight: 700; +} + +.mobile-bottom-nav { + display: none; } .content { display: grid; - gap: 1.25rem; + gap: 1.2rem; } .hero, @@ -280,7 +283,7 @@ h1, h2, h3, .planner-label { .panel, .auth-card, .week-card { - padding: 1.35rem; + padding: 1.3rem; } .hero { @@ -301,7 +304,9 @@ h1, h2, h3, .planner-label { color: var(--muted); } -h1, h2, h3 { +h1, +h2, +h3 { margin: 0; } @@ -315,30 +320,42 @@ h2 { } h3 { - font-size: 1.15rem; + font-size: 1.1rem; } .lead, .muted, .empty-state, -.empty-slot, .planner-entry p, .simple-list span, -.simple-list small { +.simple-list small, +.helper-text { color: var(--muted); } .lead { - max-width: 60ch; + max-width: 62ch; line-height: 1.6; } +.intro-pills, +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.chip-row { + margin-top: 0.75rem; +} + .stats-grid, .two-column, .card-grid, .mini-card-grid, .week-mini-grid, -.week-overview-grid { +.week-overview-grid, +.more-link-grid { display: grid; gap: 1rem; } @@ -351,6 +368,14 @@ h3 { grid-template-columns: 1.05fr 0.95fr; } +.card-grid { + grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); +} + +.mini-card-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + .week-mini-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } @@ -359,10 +384,13 @@ h3 { grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } +.more-link-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + .stat-card { padding: 1.15rem 1.2rem; - background: - linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%)); + background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%)); } .stat-card span { @@ -385,7 +413,8 @@ h3 { .form-actions, .week-nav, .week-card-head, -.planner-entry-top { +.planner-entry-top, +.more-actions { display: flex; gap: 0.85rem; justify-content: space-between; @@ -397,6 +426,8 @@ h3 { list-style: none; padding: 0; margin: 0; + display: grid; + gap: 1rem; } .simple-list li, @@ -405,39 +436,36 @@ h3 { justify-content: space-between; gap: 1rem; align-items: center; - padding: 1rem 0; - border-bottom: 1px solid var(--line); } -.simple-list li:last-child { - border-bottom: 0; - padding-bottom: 0; -} - -.mini-card-grid { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +.stacked-mobile { + align-items: flex-start; } .mini-card, -.week-mini-card { +.week-mini-card, +.more-link-card { border-radius: 18px; background: var(--surface-strong); border: 1px solid var(--line); padding: 1rem; } -.component-group { - padding: 1rem; - border-radius: 18px; - background: rgba(255, 255, 255, 0.4); - border: 1px solid var(--line); +.week-mini-card, +.more-link-card { + display: grid; + gap: 0.35rem; } +.more-link-card small { + color: var(--muted); +} + +.component-group, .quick-food-panel { - margin-top: 1rem; padding: 1rem; border-radius: 18px; - background: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.42); border: 1px solid var(--line); } @@ -452,37 +480,6 @@ h3 { grid-column: span 2; } -.quick-food-panel { - margin-top: 1rem; - padding: 1rem; - border-radius: 18px; - background: rgba(255, 255, 255, 0.5); - border: 1px solid var(--line); -} - -.quick-food-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.8rem; - align-items: end; -} - -.quick-food-grid .wide { - grid-column: span 2; -} - -.week-mini-card { - display: grid; - gap: 0.4rem; -} - -.chip-row { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; - margin-top: 0.75rem; -} - .chip, .status-pill { display: inline-flex; @@ -506,8 +503,8 @@ h3 { background: var(--sky-soft); } -.card-grid { - grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); +.status-soft { + background: var(--lilac-soft); } .item-card { @@ -569,14 +566,20 @@ h3 { width: min(560px, 100%); } -.stack-form { +.stack-form, +.stack-sections, +.planner-day-stack, +.planner-entry-list, +.week-entry-stack, +.week-slot-stack { display: grid; gap: 1rem; } .stack-form label, .planner-entry-form label, -.filter-form label { +.filter-form label, +.inline-form label { display: grid; gap: 0.5rem; color: var(--muted); @@ -595,6 +598,7 @@ h3 { } input[type="text"], +input[type="email"], input[type="password"], input[type="date"], input[type="file"], @@ -660,6 +664,11 @@ legend { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.planner-entry-form-wide { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.inline-form > :first-child, .filter-form .wide, .planner-entry-form .wide { grid-column: span 2; @@ -671,22 +680,6 @@ legend { align-items: center; } -.list-row { - padding: 1rem 1.1rem; -} - -.row-actions { - justify-content: end; - flex-wrap: wrap; -} - -.stack-sections, -.planner-day-stack, -.planner-entry-list { - display: grid; - gap: 1rem; -} - .day-tile { border-radius: 24px; background: var(--surface); @@ -725,7 +718,7 @@ legend { display: grid; place-items: center; border-radius: 1rem; - background: linear-gradient(145deg, rgba(255,255,255,0.92), var(--peach-soft)); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), var(--peach-soft)); color: var(--accent-strong); } @@ -759,7 +752,7 @@ legend { background: color-mix(in srgb, var(--surface-strong) 76%, #fff 24%); color: var(--text); border: 1px solid var(--line); - box-shadow: inset 0 1px 0 rgba(255,255,255,0.55); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55); } .quick-add-button:hover { @@ -771,12 +764,13 @@ legend { } .planner-entry { - padding: 0.9rem 1rem; + padding: 0.95rem 1rem; border-radius: 18px; + background: color-mix(in srgb, var(--surface) 88%, #fff 12%); } -.planner-entry-list .planner-entry { - background: color-mix(in srgb, var(--surface) 88%, #fff 12%); +.planner-entry-top { + align-items: flex-start; } .week-card-count { @@ -789,12 +783,6 @@ legend { margin-top: 1rem; } -.week-slot-stack { - display: grid; - gap: 0.75rem; - margin-top: 1rem; -} - .week-slot { padding: 0.85rem; border-radius: 18px; @@ -817,18 +805,18 @@ legend { margin-bottom: 0.5rem; } -.week-entry-stack { - display: grid; - gap: 0.5rem; -} - .plan-chip { padding: 0.7rem 0.8rem; border-radius: 16px; - background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,246,239,0.92)); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 246, 239, 0.92)); border: 1px solid var(--line); cursor: grab; - box-shadow: inset 0 1px 0 rgba(255,255,255,0.65); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.plan-chip[draggable="false"] { + cursor: default; + opacity: 0.8; } .plan-chip:active { @@ -873,39 +861,89 @@ legend { min-width: 5rem; } +.ui-icon { + width: 1rem; + height: 1rem; + display: inline-block; + background: currentColor; + flex: 0 0 auto; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.icon-house { + -webkit-mask-image: url("../icons/fa/house.svg"); + mask-image: url("../icons/fa/house.svg"); +} + +.icon-utensils { + -webkit-mask-image: url("../icons/fa/utensils.svg"); + mask-image: url("../icons/fa/utensils.svg"); +} + +.icon-bowl-food { + -webkit-mask-image: url("../icons/fa/bowl-food.svg"); + mask-image: url("../icons/fa/bowl-food.svg"); +} + +.icon-cart-shopping { + -webkit-mask-image: url("../icons/fa/cart-shopping.svg"); + mask-image: url("../icons/fa/cart-shopping.svg"); +} + +.icon-calendar { + -webkit-mask-image: url("../icons/fa/calendar.svg"); + mask-image: url("../icons/fa/calendar.svg"); +} + +.icon-calendar-days { + -webkit-mask-image: url("../icons/fa/calendar-days.svg"); + mask-image: url("../icons/fa/calendar-days.svg"); +} + +.icon-archive { + -webkit-mask-image: url("../icons/fa/archive.svg"); + mask-image: url("../icons/fa/archive.svg"); +} + +.icon-sparkles { + -webkit-mask-image: url("../icons/fa/sparkles.svg"); + mask-image: url("../icons/fa/sparkles.svg"); +} + @media (max-width: 1080px) { .site-header, .hero, .page-intro, .panel-head, .week-card-head { - grid-template-columns: 1fr; flex-direction: column; - align-items: start; + align-items: flex-start; } .site-header { position: static; + grid-template-columns: 1fr; } .stats-grid, .two-column, .inline-form, .planner-entry-form, - .filter-form { - grid-template-columns: 1fr; - } - - .filter-form .wide, - .planner-entry-form .wide { - grid-column: auto; - } - + .planner-entry-form-wide, + .filter-form, .quick-food-grid { grid-template-columns: 1fr; } - .quick-food-grid .wide { + .quick-food-grid .wide, + .inline-form > :first-child, + .filter-form .wide, + .planner-entry-form .wide { grid-column: auto; } } @@ -913,28 +951,141 @@ legend { @media (max-width: 720px) { .page-shell { width: min(100% - 1rem, 100%); + margin: 0.7rem auto 1rem; } .site-header { + position: sticky; + top: 0.7rem; + grid-template-columns: 1fr auto; + gap: 0.6rem; + padding: 0.75rem 0.9rem; + margin-bottom: 0.85rem; + border-radius: 22px; + } + + .desktop-nav, + .desktop-actions { + display: none; + } + + .mobile-profile-link { + display: inline-flex; + } + + .brand { + gap: 0.65rem; + } + + .brand-mark { + width: 2.2rem; + height: 2.2rem; + border-radius: 0.85rem; + } + + .brand strong { + font-size: 1.04rem; + } + + .brand small { + display: none; + } + + .hero, + .page-intro, + .panel, + .auth-card, + .week-card { padding: 1rem; } - .site-nav, - .header-actions, - .item-card, - .list-row, - .row-actions, - .quick-add-row, - .filter-actions { - justify-content: start; + h1 { + font-size: clamp(1.6rem, 7vw, 2rem); + } + + .lead { + line-height: 1.45; + font-size: 0.98rem; + } + + .stats-grid, + .two-column, + .card-grid, + .mini-card-grid, + .week-mini-grid, + .week-overview-grid, + .more-link-grid { + grid-template-columns: 1fr; } .item-card { grid-template-columns: 1fr; } - .week-nav { - align-items: start; + .simple-list li, + .list-row, + .planner-entry-top, + .week-nav, + .row-actions, + .item-actions, + .hero-actions, + .more-actions, + .filter-actions { + align-items: flex-start; flex-wrap: wrap; + justify-content: flex-start; + } + + .row-actions > *, + .item-actions > *, + .hero-actions > *, + .more-actions > * { + flex: 1 1 auto; + } + + .quick-add-row { + display: grid; + gap: 0.75rem; + } + + .quick-add-button { + min-width: 100%; + } + + .mobile-bottom-nav { + position: fixed; + left: 0.75rem; + right: 0.75rem; + bottom: 0.75rem; + z-index: 20; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 0.35rem; + padding: 0.5rem; + border-radius: 22px; + background: var(--bg-elevated); + border: 1px solid var(--line); + box-shadow: var(--shadow); + backdrop-filter: blur(26px) saturate(1.15); + } + + .mobile-bottom-nav a { + display: grid; + justify-items: center; + gap: 0.28rem; + padding: 0.55rem 0.35rem; + border-radius: 16px; + color: var(--muted); + font-size: 0.78rem; + } + + .mobile-bottom-nav a.active { + background: var(--accent-soft); + color: var(--text); + } + + .mobile-bottom-nav .ui-icon { + width: 1rem; + height: 1rem; } } diff --git a/nouri/static/js/planner.js b/nouri/static/js/planner.js index 84e19ef..89694d4 100644 --- a/nouri/static/js/planner.js +++ b/nouri/static/js/planner.js @@ -11,6 +11,7 @@ let draggedEntry = null; board.querySelectorAll(".draggable-plan-entry").forEach((entry) => { + if (entry.getAttribute("draggable") !== "true") return; entry.addEventListener("dragstart", () => { draggedEntry = entry; entry.classList.add("is-dragging"); diff --git a/nouri/static/js/theme.js b/nouri/static/js/theme.js index 477b1d4..a13d9e4 100644 --- a/nouri/static/js/theme.js +++ b/nouri/static/js/theme.js @@ -1,7 +1,7 @@ (() => { const root = document.documentElement; const storageKey = "nouri-theme"; - const toggle = () => document.querySelector("[data-theme-toggle]"); + const toggles = () => Array.from(document.querySelectorAll("[data-theme-toggle]")); const applyTheme = (theme) => { const resolved = theme || localStorage.getItem(storageKey) || "auto"; @@ -11,22 +11,20 @@ : resolved; root.dataset.theme = finalTheme; - const button = toggle(); - if (button) { + toggles().forEach((button) => { button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel"; - } + }); }; document.addEventListener("DOMContentLoaded", () => { applyTheme(); - const button = toggle(); - if (!button) return; - - button.addEventListener("click", () => { - const current = root.dataset.theme === "dark" ? "dark" : "light"; - const next = current === "dark" ? "light" : "dark"; - localStorage.setItem(storageKey, next); - applyTheme(next); + toggles().forEach((button) => { + button.addEventListener("click", () => { + const current = root.dataset.theme === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + localStorage.setItem(storageKey, next); + applyTheme(next); + }); }); }); })(); diff --git a/nouri/templates/admin/user_form.html b/nouri/templates/admin/user_form.html new file mode 100644 index 0000000..e18a2c4 --- /dev/null +++ b/nouri/templates/admin/user_form.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}{% if user %}Nutzer bearbeiten{% else %}Nutzer anlegen{% endif %} | Nouri{% endblock %} +{% block content %} +
+
+

Nutzer verwalten

+

{% if user %}{{ user.display_name or user.username }} bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}

+

Wenig Felder, klare Rollen und ein ruhiger Zugang für den gemeinsamen Haushalt.

+
+
+ +
+
+ {{ csrf_input() }} + + + + + + + +
+ + Zurück +
+
+
+{% endblock %} diff --git a/nouri/templates/admin/users_list.html b/nouri/templates/admin/users_list.html new file mode 100644 index 0000000..383cd26 --- /dev/null +++ b/nouri/templates/admin/users_list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Nutzer verwalten | Nouri{% endblock %} +{% block content %} +
+
+

Nutzer verwalten

+

Haushaltszugänge ruhig pflegen

+

Admins können hier weitere Mitglieder anlegen, Rollen anpassen und Zugänge bei Bedarf pausieren.

+
+ Neuen Nutzer anlegen +
+ +
+ {% for user in users %} +
+
+ {{ user.display_name or user.username }} +

+ {{ user.username }} + {% if user.email %} · {{ user.email }}{% endif %} +

+
+ {{ role_labels[user.role] }} + {% if user.is_active %} + Aktiv + {% else %} + Pausiert + {% endif %} + {% if user.id == g.user.id %} + Du + {% endif %} +
+
+ +
+ {% endfor %} +
+{% endblock %} diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index 542b8bc..ed1a397 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -5,7 +5,7 @@

Archiv

Frühere Ideen bleiben greifbar

-

Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.

+

Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.

@@ -23,6 +23,14 @@ {% endfor %} +
Zurücksetzen @@ -43,6 +51,10 @@

{{ item.name }}

+
+ {{ item.visibility_label }} + {{ item.owner_label }} +

{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}

{% if item.dayparts %}
@@ -64,10 +76,12 @@ Im Tagesplan öffnen -
- {{ csrf_input() }} - -
+ {% if item.can_edit %} +
+ {{ csrf_input() }} + +
+ {% endif %}
{% endfor %} diff --git a/nouri/templates/auth/login.html b/nouri/templates/auth/login.html index 54efe0e..55cdb77 100644 --- a/nouri/templates/auth/login.html +++ b/nouri/templates/auth/login.html @@ -5,12 +5,12 @@

Willkommen zurück

Ruhig wieder einsteigen

-

Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.

+

Nouri bleibt ein kleiner, freundlicher Ort für euren Alltag rund um Essen, Einkauf und Planung.

{{ csrf_input() }}
{% if entry.note %}

{{ entry.note }}

diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html index c7b5cc0..b3e5c68 100644 --- a/nouri/templates/planner/week.html +++ b/nouri/templates/planner/week.html @@ -49,9 +49,9 @@ {% if slot.entries %}
{% for entry in slot.entries %} -
+
{{ entry.item_name }} - {{ item_kind_labels[entry.item_kind] }} + {{ entry.visibility_label }} · {{ entry.owner_label }}
{% endfor %}
diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index 51e1977..2008ca2 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -5,7 +5,7 @@

Einkaufsliste

Was noch mitkommen soll

-

Abhaken legt Dinge automatisch unter Zuhause ab. So wird aus der Liste direkt sichtbarer Vorrat.

+

Abhaken legt Dinge automatisch unter Zuhause ab. Gemeinsame und persönliche Einträge bleiben dabei klar erkennbar.

@@ -15,7 +15,10 @@ @@ -25,20 +28,26 @@ {% if entries %}
{% for entry in entries %} -
+
{{ entry.item_name }} -

{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefügt von {{ entry.display_name or entry.username }}{% endif %}

+

{{ item_kind_labels[entry.item_kind] }}

+
+ {{ entry.visibility_label }} + {{ entry.owner_label }} +
{{ csrf_input() }}
-
- {{ csrf_input() }} - -
+ {% if entry.can_edit %} +
+ {{ csrf_input() }} + +
+ {% endif %}
{% endfor %}