Add recipe share links with configurable validity, public guest access, and revocation
This commit is contained in:
+170
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user