feat(2a): Erlaubte Sprachen pro Nutzer (Admin-Vorgabe)
Der Admin schaltet pro Nutzer Sprachen frei (Toggle-Chips in der
Nutzerverwaltung). Die Haupt-App zeigt dann nur diese Sprachen; bei genau
einer erlaubten Sprache wird das Sprachmenü ausgeblendet (kein Stress).
- admin.py: PUT /admin/users/{id}/prefs (allowed_languages), list_users
liefert allowed_languages mit.
- dependencies.py: resolve_route klemmt die Sprache auf eine erlaubte
(Backend-Durchsetzung, nicht nur UI).
- app.js: applyAllowedLanguages (Haupt-UI-Filter) + Sprach-Chips je Nutzer
im Admin. app.js v=44.
- Tests: Klemmung + Admin-Endpunkt. 213 passed.
Login-/nginx-Umbau (Ein-Klick-Link) folgt nach separater Design-Abstimmung.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f758db218c
commit
cc354938df
7 changed files with 103 additions and 6 deletions
|
|
@ -17,7 +17,7 @@ from app.auth import is_admin_user, require_admin, require_admin_or_user
|
|||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
|
||||
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
||||
from app.schemas import AdminUserPrefsUpdate, MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -94,6 +94,18 @@ async def reset_token(user_id: str):
|
|||
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}/prefs", dependencies=[Depends(require_admin)])
|
||||
async def update_user_prefs(user_id: str, payload: AdminUserPrefsUpdate):
|
||||
"""Setzt Nutzer-Einstellungen (z. B. erlaubte Sprachen). Merge mit bestehenden Prefs."""
|
||||
store = get_store()
|
||||
current = store.get_user(user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
||||
new = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
updated = store.set_user_prefs(user_id, {**current.prefs, **new})
|
||||
return {"user_id": updated.id, "prefs": updated.prefs}
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
|
||||
async def update_user(user_id: str, payload: UserUpdate):
|
||||
"""Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login)."""
|
||||
|
|
@ -122,6 +134,7 @@ async def list_users():
|
|||
"display_name": u.display_name,
|
||||
"external_id": u.external_id,
|
||||
"created_at": u.created_at,
|
||||
"allowed_languages": (u.prefs or {}).get("allowed_languages", ""),
|
||||
}
|
||||
for u in get_store().list_users()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -297,7 +297,12 @@ def resolve_route(
|
|||
if value is not None:
|
||||
resolved[key] = value
|
||||
|
||||
return ResolvedRoute(**resolved)
|
||||
route = ResolvedRoute(**resolved)
|
||||
# Admin-Vorgabe „erlaubte Sprachen pro Nutzer": auf eine erlaubte Sprache klemmen.
|
||||
allowed = [s.strip() for s in str(user_prefs.get("allowed_languages") or "").split(",") if s.strip()]
|
||||
if allowed and route.language not in allowed:
|
||||
route.language = allowed[0]
|
||||
return route
|
||||
|
||||
|
||||
_FALLBACK_CLASS = {
|
||||
|
|
|
|||
|
|
@ -108,3 +108,8 @@ class MemoryOut(BaseModel):
|
|||
class EmergencyRequest(BaseModel):
|
||||
"""Vom Nutzer ausgelöster Notruf (Knopf). language für den Hinweistext."""
|
||||
language: str | None = None
|
||||
|
||||
|
||||
class AdminUserPrefsUpdate(BaseModel):
|
||||
"""Admin setzt Nutzer-Einstellungen (z. B. erlaubte Sprachen, CSV: "de,en")."""
|
||||
allowed_languages: str | None = None
|
||||
|
|
|
|||
|
|
@ -319,6 +319,25 @@ let mePromise = null;
|
|||
let currentUserId = null;
|
||||
const sessionKey = (uid) => "va-session-" + uid;
|
||||
|
||||
// Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl.
|
||||
const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"],
|
||||
["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]];
|
||||
|
||||
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
|
||||
// Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer).
|
||||
function applyAllowedLanguages(prefs) {
|
||||
if (!langSel) return;
|
||||
const allowed = String((prefs || {}).allowed_languages || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
Array.from(langSel.options).forEach((opt) => {
|
||||
opt.hidden = allowed.length > 0 && !allowed.includes(opt.value);
|
||||
});
|
||||
let val = (prefs || {}).language || "de";
|
||||
if (allowed.length && !allowed.includes(val)) val = allowed[0];
|
||||
langSel.value = val;
|
||||
langSel.classList.toggle("hidden", allowed.length === 1);
|
||||
}
|
||||
|
||||
// ---------- Identitaet / Menue ----------
|
||||
function loadMe() {
|
||||
mePromise = (async () => {
|
||||
|
|
@ -343,9 +362,7 @@ function loadMe() {
|
|||
}
|
||||
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
|
||||
const prefs = me.prefs || {};
|
||||
if (langSel) {
|
||||
langSel.value = prefs.language || "de";
|
||||
}
|
||||
applyAllowedLanguages(prefs);
|
||||
applyPlaybackChoice(prefs.tts_provider);
|
||||
applyGenderChoice(prefs.voice_gender || 'f');
|
||||
return me;
|
||||
|
|
@ -1043,6 +1060,12 @@ function buildUserCard(u) {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Erlaubte Sprachen -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Erlaubte Sprachen <span class="text-slate-400">(leer = alle)</span>:</p>
|
||||
<div class="lang-allow flex flex-wrap gap-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Erinnerungen -->
|
||||
<div class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
<button class="btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors">
|
||||
|
|
@ -1061,6 +1084,26 @@ function buildUserCard(u) {
|
|||
|
||||
const uid = u.user_id;
|
||||
|
||||
// Erlaubte Sprachen (Toggle-Chips) — Admin schaltet Sprachen pro Nutzer frei.
|
||||
const allowWrap = card.querySelector(".lang-allow");
|
||||
let allowed = String(u.allowed_languages || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
function renderLangChips() {
|
||||
allowWrap.innerHTML = ALL_LANGS.map(([code, label]) => {
|
||||
const on = allowed.includes(code);
|
||||
return `<button type="button" data-lang="${code}" class="lang-chip text-xs rounded-md border px-2 py-1 transition-colors ${on ? "bg-blue-600 text-white border-blue-600" : "border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700"}">${label}</button>`;
|
||||
}).join("");
|
||||
allowWrap.querySelectorAll(".lang-chip").forEach((b) =>
|
||||
b.addEventListener("click", async () => {
|
||||
const code = b.dataset.lang;
|
||||
allowed = allowed.includes(code) ? allowed.filter((c) => c !== code) : [...allowed, code];
|
||||
const res = await adminFetch(`/api/admin/users/${uid}/prefs`, "PUT",
|
||||
{ allowed_languages: allowed.join(",") });
|
||||
if (res) renderLangChips();
|
||||
})
|
||||
);
|
||||
}
|
||||
renderLangChips();
|
||||
|
||||
// Umbenennen
|
||||
card.querySelector(".btn-rename").addEventListener("click", () =>
|
||||
card.querySelector(".rename-box").classList.toggle("hidden")
|
||||
|
|
|
|||
|
|
@ -356,6 +356,6 @@
|
|||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=43"></script>
|
||||
<script src="/app.js?v=44"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -189,3 +189,17 @@ def test_delete_restores_base_value():
|
|||
client.delete("/api/admin/config/default_language", headers=ADM_HDR)
|
||||
invalidate_cache()
|
||||
assert str(runtime_settings.default_language) == base
|
||||
|
||||
|
||||
def test_admin_sets_user_allowed_languages():
|
||||
created = client.post("/api/admin/users", headers=ADM_HDR,
|
||||
json={"display_name": "Oma"}).json()
|
||||
uid = created["user_id"]
|
||||
resp = client.put(f"/api/admin/users/{uid}/prefs", headers=ADM_HDR,
|
||||
json={"allowed_languages": "de,en"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["prefs"]["allowed_languages"] == "de,en"
|
||||
# taucht in der Nutzerliste auf
|
||||
users = client.get("/api/admin/users", headers=ADM_HDR).json()
|
||||
entry = next(u for u in users if u["user_id"] == uid)
|
||||
assert entry["allowed_languages"] == "de,en"
|
||||
|
|
|
|||
|
|
@ -139,3 +139,20 @@ def test_route_has_no_language_mode():
|
|||
route = resolve_route()
|
||||
assert "language_mode" not in route.as_dict()
|
||||
assert not hasattr(route, "language_mode")
|
||||
|
||||
|
||||
def test_route_clamps_language_to_allowed():
|
||||
store = get_store()
|
||||
user, _ = store.create_user("Senior")
|
||||
store.set_user_prefs(user.id, {"allowed_languages": "en,fr", "language": "de"})
|
||||
user = store.get_user(user.id)
|
||||
# de ist nicht erlaubt -> auf erste erlaubte Sprache (en) geklemmt
|
||||
assert resolve_route(user).language == "en"
|
||||
|
||||
|
||||
def test_route_keeps_allowed_language():
|
||||
store = get_store()
|
||||
user, _ = store.create_user("Senior2")
|
||||
store.set_user_prefs(user.id, {"allowed_languages": "en,fr", "language": "fr"})
|
||||
user = store.get_user(user.id)
|
||||
assert resolve_route(user).language == "fr"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue