Add recipe share links with configurable validity, public guest access, and revocation

This commit is contained in:
2026-10-23 14:47:34 +02:00
parent 42f3de191c
commit e3bf88f018
6 changed files with 4498 additions and 3094 deletions
+170 -2
View File
@@ -1,8 +1,9 @@
import os
import re
import sqlite3
from datetime import datetime, date
from typing import List, Dict, Any, Optional
import secrets
from datetime import datetime, date, timedelta
from typing import List, Dict, Any, Optional, Tuple
try:
from dotenv import load_dotenv
@@ -524,6 +525,21 @@ def init_db():
except Exception:
pass
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipe_shares (
id INT AUTO_INCREMENT PRIMARY KEY,
recipe_id INT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL DEFAULT NULL,
views_count INT DEFAULT 0,
revoked TINYINT DEFAULT 0,
label VARCHAR(100) DEFAULT '',
INDEX idx_share_token (token),
INDEX idx_share_recipe (recipe_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
@@ -631,6 +647,21 @@ def init_db():
if "note" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipe_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL,
views_count INTEGER DEFAULT 0,
revoked INTEGER DEFAULT 0,
label TEXT DEFAULT ''
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_token ON recipe_shares (token)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_recipe ON recipe_shares (recipe_id)")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
@@ -1633,3 +1664,140 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]:
# --- Rezept-Freigaben (Share Links) ---
def parse_db_datetime(dt_val: Any) -> Optional[datetime]:
if not dt_val:
return None
if isinstance(dt_val, datetime):
return dt_val
try:
return datetime.fromisoformat(str(dt_val))
except Exception:
try:
return datetime.strptime(str(dt_val).split(".")[0], "%Y-%m-%d %H:%M:%S")
except Exception:
return None
def format_share_expiry(dt_val: Any) -> Tuple[str, str, bool]:
"""
Gibt (formatted_date, relative_status, is_expired) zurück.
"""
dt = parse_db_datetime(dt_val)
if not dt:
return ("Unbegrenzt", "Unbegrenzt gültig", False)
now = datetime.now()
is_expired = now > dt
date_str = dt.strftime("%d.%m.%Y %H:%M")
if is_expired:
return (date_str, f"Abgelaufen am {date_str} Uhr", True)
delta = dt - now
if delta.days > 0:
days = delta.days
rel = f"Noch {days} {'Tag' if days == 1 else 'Tage'} gültig"
else:
hours = max(1, int(delta.total_seconds() // 3600))
rel = f"Noch {hours} {'Stunde' if hours == 1 else 'Stunden'} gültig"
return (date_str, rel, False)
def create_recipe_share(recipe_id: int, expires_in_seconds: Optional[int] = None, label: str = "") -> Dict[str, Any]:
token = secrets.token_urlsafe(16)
expires_at_dt = None
expires_at_sql = None
if expires_in_seconds is not None and expires_in_seconds > 0:
expires_at_dt = datetime.now() + timedelta(seconds=expires_in_seconds)
expires_at_sql = expires_at_dt.strftime("%Y-%m-%d %H:%M:%S")
res = execute_query(
"INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)",
(recipe_id, token, expires_at_sql, label.strip()),
fetch="none",
commit=True
)
last_id = res.get("lastrowid") if isinstance(res, dict) else None
fmt_date, rel_status, is_exp = format_share_expiry(expires_at_dt)
return {
"id": last_id,
"recipe_id": recipe_id,
"token": token,
"created_at": datetime.now().isoformat(),
"expires_at": expires_at_dt.isoformat() if expires_at_dt else None,
"expires_at_formatted": fmt_date,
"relative_status": rel_status,
"is_expired": is_exp,
"views_count": 0,
"revoked": False,
"label": label.strip()
}
def get_recipe_share_by_token(token: str, record_view: bool = False) -> Optional[Dict[str, Any]]:
row = execute_query(
"SELECT * FROM recipe_shares WHERE token = ? LIMIT 1",
(token.strip(),),
fetch="one"
)
if not row:
return None
fmt_date, rel_status, is_exp = format_share_expiry(row.get("expires_at"))
is_revoked = bool(row.get("revoked", 0))
if record_view and not is_revoked and not is_exp:
execute_query("UPDATE recipe_shares SET views_count = views_count + 1 WHERE id = ?", (row["id"],), commit=True)
row["views_count"] = row.get("views_count", 0) + 1
row["is_expired"] = is_exp
row["revoked"] = is_revoked
row["expires_at_formatted"] = fmt_date
row["relative_status"] = rel_status
row["is_active"] = (not is_revoked and not is_exp)
return row
def get_recipe_shares_for_recipe(recipe_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"SELECT * FROM recipe_shares WHERE recipe_id = ? ORDER BY id DESC",
(recipe_id,),
fetch="all"
)
results = []
for r in (rows or []):
fmt_date, rel_status, is_exp = format_share_expiry(r.get("expires_at"))
is_revoked = bool(r.get("revoked", 0))
r["is_expired"] = is_exp
r["revoked"] = is_revoked
r["expires_at_formatted"] = fmt_date
r["relative_status"] = rel_status
r["is_active"] = (not is_revoked and not is_exp)
results.append(r)
return results
def delete_recipe_share(share_id: int, recipe_id: int) -> bool:
res = execute_query(
"DELETE FROM recipe_shares WHERE id = ? AND recipe_id = ?",
(share_id, recipe_id),
fetch="none",
commit=True
)
return bool(res and res.get("rowcount", 0) > 0)
def revoke_recipe_share(share_id: int, recipe_id: int) -> bool:
res = execute_query(
"UPDATE recipe_shares SET revoked = 1 WHERE id = ? AND recipe_id = ?",
(share_id, recipe_id),
fetch="none",
commit=True
)
return bool(res and res.get("rowcount", 0) > 0)