Feature: Subscription-Tier-System (standard/pro/breeder + _test), has_pro_access(), Admin-Tier-UI (SW by-v734)
This commit is contained in:
parent
bcc7c27556
commit
71f29dcce0
8 changed files with 104 additions and 12 deletions
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
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,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
@ -130,6 +130,32 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
return 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)):
|
def require_social_media(user=Depends(get_current_user)):
|
||||||
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
|
"""Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
|
|
|
||||||
|
|
@ -2075,6 +2075,14 @@ def _migrate(conn_factory):
|
||||||
_seed_help_articles(conn)
|
_seed_help_articles(conn)
|
||||||
logger.info("Migration: Hilfe/FAQ-Tabelle bereit.")
|
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):
|
def _seed_help_articles(conn):
|
||||||
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""
|
||||||
|
|
|
||||||
|
|
@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
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")
|
@app.get("/api/version")
|
||||||
async def app_version():
|
async def app_version():
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,15 @@ def require_admin(user=Depends(get_current_user)):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Schemas
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
_VALID_TIERS = {"standard", "pro", "breeder", "standard_test", "pro_test", "breeder_test"}
|
||||||
|
|
||||||
class UserPatch(BaseModel):
|
class UserPatch(BaseModel):
|
||||||
rolle: Optional[str] = None # user | moderator | admin
|
rolle: Optional[str] = None # user | moderator | admin
|
||||||
is_moderator: Optional[int] = None
|
is_moderator: Optional[int] = None
|
||||||
is_banned: Optional[int] = None
|
is_banned: Optional[int] = None
|
||||||
ban_reason: Optional[str] = None
|
ban_reason: Optional[str] = None
|
||||||
is_social_media: Optional[int] = None
|
is_social_media: Optional[int] = None
|
||||||
|
subscription_tier: Optional[str] = None
|
||||||
|
|
||||||
class WikiEnrichBody(BaseModel):
|
class WikiEnrichBody(BaseModel):
|
||||||
limit: int = 10
|
limit: int = 10
|
||||||
|
|
@ -331,7 +334,7 @@ async def list_users(
|
||||||
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
|
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
|
||||||
u.is_moderator, u.is_banned, u.ban_reason,
|
u.is_moderator, u.is_banned, u.ban_reason,
|
||||||
u.is_founder, u.is_partner, u.founder_number,
|
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 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,
|
(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,
|
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.")
|
raise HTTPException(403, "is_moderator darf nur von Admins geändert werden.")
|
||||||
if data.is_social_media is not None and user["rolle"] != "admin":
|
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.")
|
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:
|
with db() as conn:
|
||||||
target = conn.execute("SELECT id, rolle, name FROM users WHERE id=?", (uid,)).fetchone()
|
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)
|
cols = ", ".join(f"{k}=?" for k in updates)
|
||||||
conn.execute(f"UPDATE users SET {cols} WHERE id=?", [*updates.values(), uid])
|
conn.execute(f"UPDATE users SET {cols} WHERE id=?", [*updates.values(), uid])
|
||||||
row = conn.execute(
|
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,)
|
(uid,)
|
||||||
).fetchone()
|
).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")
|
detail_parts.append("gesperrt" if updates["is_banned"] else "entsperrt")
|
||||||
if "rolle" in updates:
|
if "rolle" in updates:
|
||||||
detail_parts.append(f"Rolle→{updates['rolle']}")
|
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)
|
_audit(conn, user, "user_patch", f"user:{uid} ({target['name']})", ", ".join(detail_parts) or None)
|
||||||
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,7 @@
|
||||||
<script src="/js/api.js?v=94"></script>
|
<script src="/js/api.js?v=94"></script>
|
||||||
<script src="/js/ui.js?v=94"></script>
|
<script src="/js/ui.js?v=94"></script>
|
||||||
<script src="/js/app.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 -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 APP_VERSION = '1.4.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -795,6 +795,9 @@ window.Page_admin = (() => {
|
||||||
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
|
<span style="color:${u.rolle === 'admin' ? 'var(--c-danger)' : u.rolle === 'moderator' ? '#f59e0b' : 'var(--c-text-muted)'}">
|
||||||
${_esc(u.rolle)}
|
${_esc(u.rolle)}
|
||||||
</span>
|
</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.dog_count} Hund${u.dog_count !== 1 ? 'e' : ''}
|
||||||
· ${u.thread_count} Threads
|
· ${u.thread_count} Threads
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -823,6 +826,11 @@ window.Page_admin = (() => {
|
||||||
title="Rolle ändern">
|
title="Rolle ändern">
|
||||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#shield"></use></svg>
|
||||||
</button>
|
</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}"
|
<button class="btn btn-sm btn-ghost adm-delete" data-uid="${u.id}"
|
||||||
data-name="${_esc(u.name)}" title="Löschen"
|
data-name="${_esc(u.name)}" title="Löschen"
|
||||||
style="color:var(--c-danger)">
|
style="color:var(--c-danger)">
|
||||||
|
|
@ -847,6 +855,9 @@ window.Page_admin = (() => {
|
||||||
el.querySelectorAll('.adm-rolle').forEach(btn => {
|
el.querySelectorAll('.adm-rolle').forEach(btn => {
|
||||||
btn.addEventListener('click', () => _changeRolle(btn.dataset.uid, btn.dataset.name, btn.dataset.rolle));
|
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 => {
|
el.querySelectorAll('.adm-delete').forEach(btn => {
|
||||||
btn.addEventListener('click', () => _deleteUser(btn.dataset.uid, btn.dataset.name));
|
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) {
|
async function _deleteUser(uid, name) {
|
||||||
const ok = await UI.modal.confirm({
|
const ok = await UI.modal.confirm({
|
||||||
title: `${name} löschen?`,
|
title: `${name} löschen?`,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v733';
|
const CACHE_VERSION = 'by-v734';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue