Compare commits
3 Commits
V1.0.1
...
1c87d653d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c87d653d6 | |||
| 1490fc8f1d | |||
| 40bab48806 |
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"upstreamVersion": "1.0.0",
|
"upstreamVersion": "1.1.1",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Nouri 1.1.1
|
||||||
|
|
||||||
|
Nouri 1.1.1 ist ein kleiner Feinschliff-Release. Der Schwerpunkt liegt auf saubereren Bezeichnungen in der Oberfläche und einer einheitlichen Versionsanhebung für App und Cloudron-Paket.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Beschriftungen im Plan werden wieder korrekt großgeschrieben, zum Beispiel `Mahlzeitideen`
|
||||||
|
- App-Version und Cloudron-Version stehen jetzt auf `1.1.1`
|
||||||
|
- Versions-Fallback in der App wurde an den neuen Stand angepasst
|
||||||
|
|
||||||
|
## Neu in 1.1.1
|
||||||
|
|
||||||
|
### Oberfläche
|
||||||
|
|
||||||
|
- Die automatisch kleingeschriebene Anzeige im Tagesplan wurde korrigiert.
|
||||||
|
- Begriffe wie `Mahlzeitideen` erscheinen wieder so, wie sie in der App gedacht sind.
|
||||||
|
|
||||||
|
### Versionierung
|
||||||
|
|
||||||
|
- `CloudronManifest.json` wurde auf `1.1.1` angehoben.
|
||||||
|
- Der interne App-Fallback in `nouri/__init__.py` wurde ebenfalls auf `1.1.1` gesetzt.
|
||||||
|
- Die Schema-Version in `nouri/db.py` folgt jetzt ebenfalls `1.1.1`.
|
||||||
|
|
||||||
|
## Cloudron
|
||||||
|
|
||||||
|
- Das Update kann sauber als neue Version ausgerollt werden.
|
||||||
|
- Footer, Release-Link und Versionsanzeige greifen damit wieder auf einen konsistenten Stand zu.
|
||||||
+32
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -34,6 +35,7 @@ from .main import main_bp
|
|||||||
|
|
||||||
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||||
|
|
||||||
|
|
||||||
def load_secret_key(data_dir: Path) -> str:
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
@@ -54,11 +56,38 @@ def load_secret_key(data_dir: Path) -> str:
|
|||||||
return secret_value
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_version(root_dir: Path) -> str:
|
||||||
|
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
|
||||||
|
if env_version:
|
||||||
|
return env_version
|
||||||
|
|
||||||
|
manifest_path = root_dir / "CloudronManifest.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
manifest_data = {}
|
||||||
|
manifest_version = str(
|
||||||
|
manifest_data.get("upstreamVersion")
|
||||||
|
or manifest_data.get("version")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if manifest_version:
|
||||||
|
return manifest_version
|
||||||
|
return "1.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
def load_release_url() -> str:
|
||||||
|
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||||
upload_dir = data_dir / "uploads"
|
upload_dir = data_dir / "uploads"
|
||||||
db_path = data_dir / "nouri.sqlite3"
|
db_path = data_dir / "nouri.sqlite3"
|
||||||
|
app_version = load_app_version(root_dir)
|
||||||
|
release_url = load_release_url()
|
||||||
|
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ensure_upload_structure(upload_dir)
|
ensure_upload_structure(upload_dir)
|
||||||
@@ -74,7 +103,8 @@ def create_app() -> Flask:
|
|||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||||
APP_VERSION="1.0.0",
|
APP_VERSION=app_version,
|
||||||
|
RELEASE_URL=release_url,
|
||||||
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||||
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
||||||
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
||||||
@@ -114,6 +144,7 @@ def create_app() -> Flask:
|
|||||||
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
"today": date.today(),
|
"today": date.today(),
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"app_release_url": app.config["RELEASE_URL"],
|
||||||
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||||
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
|
|||||||
+20
-1
@@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash
|
|||||||
|
|
||||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.0.0"
|
CURRENT_SCHEMA_VERSION = "1.1.1"
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
@@ -205,6 +205,19 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, suggestion_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||||
@@ -456,6 +469,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
ON shopping_needs (household_id, activation_date, is_activated)
|
ON shopping_needs (household_id, activation_date, is_activated)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
|
||||||
|
ON hidden_generated_suggestions (user_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+157
-6
@@ -357,7 +357,12 @@ def describe_record(entry: dict) -> dict:
|
|||||||
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
|
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
|
||||||
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
|
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
|
||||||
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
|
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
|
||||||
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle"
|
if target_name:
|
||||||
|
entry["for_label"] = f"Für {target_name}"
|
||||||
|
elif entry["is_personal"]:
|
||||||
|
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
|
||||||
|
else:
|
||||||
|
entry["for_label"] = "Für alle"
|
||||||
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
|
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@@ -1267,6 +1272,23 @@ def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
|
|||||||
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
||||||
|
|
||||||
|
|
||||||
|
def generated_suggestion_key(component_ids: list[int]) -> str:
|
||||||
|
signature = normalized_component_signature(component_ids)
|
||||||
|
return "generated:" + "-".join(str(component_id) for component_id in signature)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_hidden_generated_suggestion_keys() -> set[str]:
|
||||||
|
rows = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT suggestion_key
|
||||||
|
FROM hidden_generated_suggestions
|
||||||
|
WHERE user_id = ?
|
||||||
|
""",
|
||||||
|
(g.user["id"],),
|
||||||
|
).fetchall()
|
||||||
|
return {row["suggestion_key"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
|
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
|
||||||
names = [item["name"] for item in combo]
|
names = [item["name"] for item in combo]
|
||||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
|
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
|
||||||
@@ -1341,6 +1363,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
|||||||
"reason": pattern["reason"],
|
"reason": pattern["reason"],
|
||||||
"component_ids": [item["id"] for item in combo_items],
|
"component_ids": [item["id"] for item in combo_items],
|
||||||
"existing_item_id": None,
|
"existing_item_id": None,
|
||||||
|
"visibility": "shared",
|
||||||
|
"daypart_id": None,
|
||||||
|
"missing_component_ids": [],
|
||||||
|
"missing_components": [],
|
||||||
|
"needs_shopping": False,
|
||||||
|
"is_generated": True,
|
||||||
|
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if len(suggestions) >= limit * 3:
|
if len(suggestions) >= limit * 3:
|
||||||
@@ -1353,6 +1382,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
|||||||
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
|
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
|
||||||
settings = get_user_settings()
|
settings = get_user_settings()
|
||||||
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
|
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
|
||||||
|
hidden_keys = fetch_hidden_generated_suggestion_keys()
|
||||||
home_foods = [
|
home_foods = [
|
||||||
item
|
item
|
||||||
for item in fetch_items(kind="food", availability="home")
|
for item in fetch_items(kind="food", availability="home")
|
||||||
@@ -1360,30 +1390,76 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
|
|||||||
]
|
]
|
||||||
home_food_ids = {item["id"] for item in home_foods}
|
home_food_ids = {item["id"] for item in home_foods}
|
||||||
home_food_map = {int(item["id"]): item for item in home_foods}
|
home_food_map = {int(item["id"]): item for item in home_foods}
|
||||||
|
visible_foods = [
|
||||||
|
item
|
||||||
|
for item in fetch_items(kind="food", include_archived=False)
|
||||||
|
if item_matches_daypart(item, daypart_id)
|
||||||
|
]
|
||||||
|
visible_food_map = {int(item["id"]): item for item in visible_foods}
|
||||||
|
|
||||||
suggestions: list[dict] = []
|
suggestions: list[dict] = []
|
||||||
seen_signatures: set[tuple[int, ...]] = set()
|
seen_signatures: set[tuple[int, ...]] = set()
|
||||||
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
|
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
|
||||||
for meal in meals:
|
for meal in meals:
|
||||||
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
|
if not meal["component_ids"]:
|
||||||
signature = normalized_component_signature(meal["component_ids"])
|
continue
|
||||||
|
component_ids = [int(component_id) for component_id in meal["component_ids"]]
|
||||||
|
if not all(component_id in visible_food_map for component_id in component_ids):
|
||||||
|
continue
|
||||||
|
|
||||||
|
signature = normalized_component_signature(component_ids)
|
||||||
if signature in seen_signatures:
|
if signature in seen_signatures:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
component_items = [visible_food_map[component_id] for component_id in component_ids]
|
||||||
|
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
|
||||||
|
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
|
||||||
|
|
||||||
|
if not available_items:
|
||||||
|
continue
|
||||||
|
if missing_items and len(missing_items) > 2:
|
||||||
|
continue
|
||||||
|
|
||||||
seen_signatures.add(signature)
|
seen_signatures.add(signature)
|
||||||
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map]
|
if missing_items:
|
||||||
|
missing_names = [item["name"] for item in missing_items]
|
||||||
|
suggestions.append(
|
||||||
|
{
|
||||||
|
"title": meal["name"],
|
||||||
|
"reason": f"Es fehlt noch: {', '.join(missing_names)}",
|
||||||
|
"component_ids": component_ids,
|
||||||
|
"existing_item_id": meal["id"],
|
||||||
|
"visibility": meal["visibility"],
|
||||||
|
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
|
||||||
|
"missing_component_ids": [item["id"] for item in missing_items],
|
||||||
|
"missing_components": missing_names,
|
||||||
|
"needs_shopping": True,
|
||||||
|
"is_generated": False,
|
||||||
|
"suggestion_key": None,
|
||||||
|
"score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"title": meal["name"],
|
"title": meal["name"],
|
||||||
"reason": "Zuhause vorhanden",
|
"reason": "Zuhause vorhanden",
|
||||||
"component_ids": meal["component_ids"],
|
"component_ids": component_ids,
|
||||||
"existing_item_id": meal["id"],
|
"existing_item_id": meal["id"],
|
||||||
|
"visibility": meal["visibility"],
|
||||||
|
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
|
||||||
|
"missing_component_ids": [],
|
||||||
|
"missing_components": [],
|
||||||
|
"needs_shopping": False,
|
||||||
|
"is_generated": False,
|
||||||
|
"suggestion_key": None,
|
||||||
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
|
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
|
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
|
||||||
signature = normalized_component_signature(suggestion["component_ids"])
|
signature = normalized_component_signature(suggestion["component_ids"])
|
||||||
if signature in seen_signatures:
|
if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys:
|
||||||
continue
|
continue
|
||||||
seen_signatures.add(signature)
|
seen_signatures.add(signature)
|
||||||
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
|
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
|
||||||
@@ -1460,6 +1536,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]:
|
|||||||
"reason": "Für später vormerken",
|
"reason": "Für später vormerken",
|
||||||
"component_ids": [],
|
"component_ids": [],
|
||||||
"existing_item_id": item["id"] if item["kind"] == "meal" else None,
|
"existing_item_id": item["id"] if item["kind"] == "meal" else None,
|
||||||
|
"visibility": item["visibility"],
|
||||||
|
"daypart_id": daypart_id,
|
||||||
|
"missing_component_ids": [],
|
||||||
|
"missing_components": [],
|
||||||
|
"needs_shopping": False,
|
||||||
|
"is_generated": False,
|
||||||
|
"suggestion_key": None,
|
||||||
}
|
}
|
||||||
for item in archived_items[:2]
|
for item in archived_items[:2]
|
||||||
]
|
]
|
||||||
@@ -2397,6 +2480,23 @@ def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibil
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_plan_entry(entry_id: int, *, visibility: str, note: str) -> None:
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
UPDATE plan_entries
|
||||||
|
SET visibility = ?,
|
||||||
|
owner_user_id = CASE
|
||||||
|
WHEN ? = 'personal' THEN ?
|
||||||
|
ELSE owner_user_id
|
||||||
|
END,
|
||||||
|
note = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(visibility, visibility, g.user["id"], note, entry_id),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
|
||||||
|
|
||||||
def planner_template_options():
|
def planner_template_options():
|
||||||
return fetch_day_templates()
|
return fetch_day_templates()
|
||||||
|
|
||||||
@@ -2457,6 +2557,25 @@ def template_library():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/suggestions/hide")
|
||||||
|
@login_required
|
||||||
|
def suggestion_hide():
|
||||||
|
component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()]
|
||||||
|
if not component_ids:
|
||||||
|
flash("Diese Kombination konnte gerade nicht ausgeblendet werden.", "error")
|
||||||
|
return redirect(request.referrer or url_for("main.dashboard"))
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO hidden_generated_suggestions (user_id, suggestion_key)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(g.user["id"], generated_suggestion_key(component_ids)),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Diese generierte Mahlzeit wird dir künftig nicht mehr vorgeschlagen.", "info")
|
||||||
|
return redirect(request.referrer or url_for("main.dashboard"))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/templates/day/new", methods=("GET", "POST"))
|
@main_bp.route("/templates/day/new", methods=("GET", "POST"))
|
||||||
@login_required
|
@login_required
|
||||||
def day_template_create():
|
def day_template_create():
|
||||||
@@ -3580,6 +3699,38 @@ def planner_generated_meal():
|
|||||||
return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}")
|
return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/planner/<int:entry_id>/update")
|
||||||
|
@login_required
|
||||||
|
def planner_update(entry_id: int):
|
||||||
|
selected_date = parse_plan_date(request.form.get("plan_date"))
|
||||||
|
entry = get_db().execute(
|
||||||
|
f"""
|
||||||
|
SELECT plan_entries.*,
|
||||||
|
owner.display_name AS owner_display_name,
|
||||||
|
owner.username AS owner_username
|
||||||
|
FROM plan_entries
|
||||||
|
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
|
||||||
|
WHERE plan_entries.id = ? AND {visible_clause('plan_entries')}
|
||||||
|
""",
|
||||||
|
[entry_id, *visible_params()],
|
||||||
|
).fetchone()
|
||||||
|
if entry is None:
|
||||||
|
flash("Der Planeintrag wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
|
||||||
|
|
||||||
|
try:
|
||||||
|
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.")
|
||||||
|
except PermissionError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
|
||||||
|
|
||||||
|
visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"])
|
||||||
|
note = request.form.get("note", "").strip()
|
||||||
|
update_plan_entry(entry_id, visibility=visibility, note=note)
|
||||||
|
flash("Der Planeintrag wurde angepasst.", "success")
|
||||||
|
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.post("/planner/<int:entry_id>/remove")
|
@main_bp.post("/planner/<int:entry_id>/remove")
|
||||||
@login_required
|
@login_required
|
||||||
def planner_remove(entry_id: int):
|
def planner_remove(entry_id: int):
|
||||||
|
|||||||
+9
-8
@@ -13,9 +13,9 @@ from .push import send_push_message
|
|||||||
|
|
||||||
|
|
||||||
MEAL_PUSH_RULES = [
|
MEAL_PUSH_RULES = [
|
||||||
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"},
|
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"},
|
||||||
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"},
|
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"},
|
||||||
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"},
|
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -92,10 +92,11 @@ def mark_reminder_event(user_id: int, event_key: str) -> None:
|
|||||||
get_db().commit()
|
get_db().commit()
|
||||||
|
|
||||||
|
|
||||||
def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool:
|
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
|
||||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
current_minutes = (now.hour * 60) + now.minute
|
||||||
delta = (now - target).total_seconds()
|
target_minutes = (hour * 60) + minute
|
||||||
return 0 <= delta < 180
|
end_minutes = end_hour * 60
|
||||||
|
return target_minutes <= current_minutes < end_minutes
|
||||||
|
|
||||||
|
|
||||||
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
||||||
@@ -155,7 +156,7 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
|
|||||||
for rule in MEAL_PUSH_RULES:
|
for rule in MEAL_PUSH_RULES:
|
||||||
if not settings.get(rule["setting"]):
|
if not settings.get(rule["setting"]):
|
||||||
continue
|
continue
|
||||||
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]):
|
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
daypart = dayparts.get(rule["slug"])
|
daypart = dayparts.get(rule["slug"])
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ CREATE TABLE IF NOT EXISTS reminder_events (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, suggestion_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dayparts (
|
CREATE TABLE IF NOT EXISTS dayparts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -89,7 +89,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
.button {
|
.button,
|
||||||
|
.ghost-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -937,6 +938,31 @@ legend {
|
|||||||
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
|
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-entry-edit {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-entry-edit > summary {
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-entry-edit > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-entry-inline-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-entry-inline-form .wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.template-card,
|
.template-card,
|
||||||
.template-list-card,
|
.template-list-card,
|
||||||
.suggestion-card {
|
.suggestion-card {
|
||||||
@@ -952,6 +978,23 @@ legend {
|
|||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-list-card-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list-card-actions form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list-card .ghost-button,
|
||||||
|
.template-list-card .button {
|
||||||
|
width: auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.week-template-row {
|
.week-template-row {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -1505,6 +1548,10 @@ legend {
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-entry-inline-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-nav-stack {
|
.mobile-nav-stack {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
<span>Version {{ app_version }}</span>
|
<a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||||
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
|
|||||||
@@ -134,8 +134,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="template-list-card-actions">
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% else %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||||
|
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
{% for component_id in suggestion.component_ids %}
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,8 +51,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="template-list-card-actions">
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% else %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||||
|
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
{% for component_id in suggestion.component_ids %}
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,6 +150,19 @@
|
|||||||
<h3>Passt gut dazu</h3>
|
<h3>Passt gut dazu</h3>
|
||||||
<div class="quick-add-row compact-quick-row">
|
<div class="quick-add-row compact-quick-row">
|
||||||
{% for suggestion in section.recipe_suggestions %}
|
{% for suggestion in section.recipe_suggestions %}
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
|
<span>{{ suggestion.title }}</span>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
|
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
@@ -164,6 +177,7 @@
|
|||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +199,7 @@
|
|||||||
<button class="picker-row" type="submit">
|
<button class="picker-row" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<small>
|
<small>
|
||||||
{{ item_kind_labels[item.kind]|lower }}
|
{{ item_kind_labels[item.kind] }}
|
||||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
@@ -220,6 +234,28 @@
|
|||||||
{% if entry.note %}
|
{% if entry.note %}
|
||||||
<p>{{ entry.note }}</p>
|
<p>{{ entry.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<details class="planner-entry-edit">
|
||||||
|
<summary class="ghost-button">Anpassen</summary>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user