Feature: Subscription-Tier-System (standard/pro/breeder + _test), has_pro_access(), Admin-Tier-UI (SW by-v734)

This commit is contained in:
rene 2026-05-06 18:39:27 +02:00
parent bcc7c27556
commit 71f29dcce0
8 changed files with 104 additions and 12 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"])
with db() as conn:
row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until, subscription_tier FROM users WHERE id=?",
(user_id,)
).fetchone()
@ -130,6 +130,32 @@ def require_admin(user=Depends(get_current_user)):
return user
def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf."""
if not user:
return False
role = user.get("rolle", "user")
tier = user.get("subscription_tier", "standard")
if role in ("admin", "moderator"):
return True
if user.get("is_moderator") or user.get("is_social_media"):
return True
return tier in ("pro", "breeder", "pro_test", "breeder_test")
def has_breeder_access(user: dict) -> bool:
"""True wenn User Züchter-Features nutzen darf."""
if not user:
return False
role = user.get("rolle", "user")
tier = user.get("subscription_tier", "standard")
if role in ("admin", "moderator"):
return True
if user.get("is_moderator") or user.get("is_social_media"):
return True
return tier in ("breeder", "breeder_test") or role == "breeder"
def require_social_media(user=Depends(get_current_user)):
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
from datetime import datetime as _dt

View file

@ -2075,6 +2075,14 @@ def _migrate(conn_factory):
_seed_help_articles(conn)
logger.info("Migration: Hilfe/FAQ-Tabelle bereit.")
# ---- Feature: Subscription-Tier ----
try:
conn.execute("ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'standard'")
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_tier ON users(subscription_tier)")
logger.info("Migration: subscription_tier Spalte hinzugefügt.")
except Exception:
pass # Spalte existiert bereits
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "733" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "734" # muss mit APP_VER in app.js übereinstimmen
@app.get("/api/version")
async def app_version():

View file

@ -81,12 +81,15 @@ def require_admin(user=Depends(get_current_user)):
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
class UserPatch(BaseModel):
rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None
is_banned: Optional[int] = None
ban_reason: Optional[str] = None
is_social_media: Optional[int] = None
rolle: Optional[str] = None # user | moderator | admin
is_moderator: Optional[int] = None
is_banned: Optional[int] = None
ban_reason: Optional[str] = None
is_social_media: Optional[int] = None
subscription_tier: Optional[str] = None
class WikiEnrichBody(BaseModel):
limit: int = 10
@ -331,7 +334,7 @@ async def list_users(
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
u.is_moderator, u.is_banned, u.ban_reason,
u.is_founder, u.is_partner, u.founder_number,
u.created_at, u.last_login,
u.created_at, u.last_login, u.subscription_tier,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
(SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count,
ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km,
@ -365,6 +368,10 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
if data.is_social_media is not None and user["rolle"] != "admin":
raise HTTPException(403, "is_social_media darf nur von Admins geändert werden.")
if data.subscription_tier is not None and user["rolle"] != "admin":
raise HTTPException(403, "subscription_tier darf nur von Admins geändert werden.")
if data.subscription_tier is not None and data.subscription_tier not in _VALID_TIERS:
raise HTTPException(400, f"Ungültiger Tier. Erlaubt: {', '.join(sorted(_VALID_TIERS))}")
with db() as conn:
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
@ -385,7 +392,7 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
cols = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE users SET {cols} WHERE id=?", [*updates.values(), uid])
row = conn.execute(
"SELECT id, name, email, rolle, is_moderator, is_banned, ban_reason FROM users WHERE id=?",
"SELECT id, name, email, rolle, is_moderator, is_banned, ban_reason, subscription_tier FROM users WHERE id=?",
(uid,)
).fetchone()
@ -395,6 +402,8 @@ async def patch_user(uid: int, data: UserPatch, user=Depends(require_mod)):
detail_parts.append("gesperrt" if updates["is_banned"] else "entsperrt")
if "rolle" in updates:
detail_parts.append(f"Rolle→{updates['rolle']}")
if "subscription_tier" in updates:
detail_parts.append(f"Tier→{updates['subscription_tier']}")
_audit(conn, user, "user_patch", f"user:{uid} ({target['name']})", ", ".join(detail_parts) or None)
return dict(row)

View file

@ -578,7 +578,7 @@
<script src="/js/api.js?v=94"></script>
<script src="/js/ui.js?v=94"></script>
<script src="/js/app.js?v=94"></script>
<script src="/js/worlds.js?v=733"></script>
<script src="/js/worlds.js?v=734"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '733'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '734'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.4.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -795,6 +795,9 @@ window.Page_admin = (() => {
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
${_esc(u.rolle)}
</span>
· <span style="color:${u.subscription_tier && u.subscription_tier !== 'standard' ? 'var(--c-primary)' : 'var(--c-text-muted)'}">
${_esc(u.subscription_tier || 'standard')}
</span>
· ${u.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
· ${u.thread_count} Threads
</div>
@ -823,6 +826,11 @@ window.Page_admin = (() => {
title="Rolle ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-tier" data-uid="${u.id}"
data-name="${_esc(u.name)}" data-tier="${_esc(u.subscription_tier || 'standard')}"
title="Abo-Stufe ändern">
<svg class="ph-icon"><use href="/icons/phosphor.svg#star"></use></svg>
</button>
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
data-name="${_esc(u.name)}" title="Löschen"
style="color:var(--c-danger)">
@ -847,6 +855,9 @@ window.Page_admin = (() => {
el.querySelectorAll('.adm-rolle').forEach(btn => {
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
});
el.querySelectorAll('.adm-tier').forEach(btn => {
btn.addEventListener('click', () => _changeTier(btn.dataset.uid, btn.dataset.name, btn.dataset.tier));
});
el.querySelectorAll('.adm-delete').forEach(btn => {
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
});
@ -903,6 +914,44 @@ window.Page_admin = (() => {
});
}
async function _changeTier(uid, name, currentTier) {
const tiers = ['standard', 'pro', 'breeder', 'standard_test', 'pro_test', 'breeder_test'];
const tierLabels = {
standard: 'Standard (kostenlos)',
pro: 'Pro (bezahlt)',
breeder: 'Breeder (Züchter)',
standard_test: 'Standard Test (intern)',
pro_test: 'Pro Test (intern)',
breeder_test: 'Breeder Test (intern)',
};
UI.modal.open({
title: `Abo-Stufe ändern: ${name}`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-4)">
Aktuelle Stufe: <strong>${currentTier}</strong>
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${tiers.filter(t => t !== currentTier).map(t => `
<button class="btn btn-secondary adm-tier-choice" data-tier="${t}" form="">
${tierLabels[t]}
</button>
`).join('')}
</div>
`,
});
document.querySelectorAll('.adm-tier-choice').forEach(btn => {
btn.addEventListener('click', async () => {
UI.modal.close();
try {
await API.patch(`/admin/users/${uid}`, { subscription_tier: btn.dataset.tier });
UI.toast.success(`${name}: Abo-Stufe ist jetzt ${btn.dataset.tier}.`);
_renderTab();
} catch (e) { UI.toast.error(e.message); }
});
});
}
async function _deleteUser(uid, name) {
const ok = await UI.modal.confirm({
title: `${name} löschen?`,

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v733';
const CACHE_VERSION = 'by-v734';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache