Admin Dashboard und Hilfeseite
This commit is contained in:
18
README.md
18
README.md
@@ -19,6 +19,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
||||
- **Stay-on-First**: Screen kann auf erstem Playlist-Element stehen bleiben (kein automatischer Refresh)
|
||||
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
|
||||
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
|
||||
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
|
||||
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
|
||||
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien (Tabler Tabs)
|
||||
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
|
||||
- Dark Mode (localStorage-persistiert)
|
||||
@@ -38,6 +40,7 @@ Flask App (Server)
|
||||
├── Priority-Seite /admin/<site>/priority
|
||||
├── config.json
|
||||
├── users.json
|
||||
├── history.json
|
||||
├── media/
|
||||
│ ├── <site>/
|
||||
│ │ ├── <screen>/
|
||||
@@ -63,6 +66,7 @@ signage/
|
||||
├── generate_welcome_page.py # Logo-Suche + Willkommensseite-Generierung
|
||||
├── config.json # Persistente Konfiguration
|
||||
├── users.json # User-Datenbank (gehashte Passwörter)
|
||||
├── history.json # Aktivitätsverlauf (Dashboard)
|
||||
├── media/
|
||||
│ ├── <site>/
|
||||
│ │ ├── lobby/
|
||||
@@ -71,6 +75,7 @@ signage/
|
||||
│ └── priority/
|
||||
├── templates/
|
||||
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
||||
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
|
||||
│ ├── priority.html # Priority-Playlist (eigene Seite)
|
||||
│ ├── customer.html # Willkommensseite-Formular
|
||||
│ ├── player.html # Player-Ansicht
|
||||
@@ -129,6 +134,7 @@ docker compose up -d
|
||||
| `GET /login` | Admin-Login |
|
||||
| `GET /logout` | Ausloggen |
|
||||
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
||||
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) |
|
||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
||||
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
||||
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
||||
@@ -160,7 +166,7 @@ docker compose up -d
|
||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||
|
||||
### Admin-Portal
|
||||
### Admin Portal
|
||||
|
||||
```
|
||||
http://localhost:5005/admin/<standort>
|
||||
@@ -177,6 +183,16 @@ http://localhost:5005/admin/<standort>
|
||||
- Standorte anlegen & löschen
|
||||
- **Userverwaltung**: User anlegen, bearbeiten, löschen, Passwort-Reset (temporäres Passwort wird angezeigt)
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
```
|
||||
http://localhost:5005/admin/dashboard
|
||||
```
|
||||
|
||||
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
|
||||
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
|
||||
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
|
||||
|
||||
### Player-URL
|
||||
|
||||
```
|
||||
|
||||
210
app.py
210
app.py
@@ -38,6 +38,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/Screen
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||||
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||||
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
||||
|
||||
APP_VERSION = "6.0.1"
|
||||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||
@@ -72,6 +73,40 @@ def get_site_list(cfg):
|
||||
return sorted(cfg.get("sites", {}).keys())
|
||||
|
||||
|
||||
def add_history_entry(action, detail, user_email):
|
||||
"""Hängt einen Eintrag in history.json an (Aktivitätsverlauf für das Admin-Dashboard)."""
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"action": action,
|
||||
"detail": detail,
|
||||
"user": user_email
|
||||
}
|
||||
history = []
|
||||
if os.path.exists(HISTORY_FILE):
|
||||
try:
|
||||
with open(HISTORY_FILE) as f:
|
||||
history = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
history = []
|
||||
history.insert(0, entry) # Neueste zuerst
|
||||
# Maximal 200 Einträge behalten
|
||||
history = history[:200]
|
||||
with open(HISTORY_FILE, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
|
||||
|
||||
def get_history(limit=50):
|
||||
"""Gibt die letzten history-Einträge zurück."""
|
||||
if not os.path.exists(HISTORY_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(HISTORY_FILE) as f:
|
||||
history = json.load(f)
|
||||
return history[:limit]
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def get_screen_config(cfg, site, screen):
|
||||
"""Holt die Konfiguration für einen bestimmten Screen.
|
||||
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
||||
@@ -231,15 +266,21 @@ def media_background(site, filename):
|
||||
# User-Verwaltung (Flask-Login mit users.json)
|
||||
# -------------------------------------------------
|
||||
class User(UserMixin):
|
||||
"""User mit E-Mail (id), Rolle und Standort-Berechtigungen."""
|
||||
"""User mit E-Mail (id), Rolle, Standort-Berechtigungen und Profilfeldern."""
|
||||
|
||||
def __init__(self, email, password_hash="", role="user", sites=None, must_change_password=False):
|
||||
def __init__(self, email, password_hash="", role="user", sites=None,
|
||||
must_change_password=False, first_name="", last_name="",
|
||||
department="", notes=""):
|
||||
self.email = email
|
||||
self.id = email
|
||||
self.password_hash = password_hash
|
||||
self.role = role
|
||||
self.sites = sites or []
|
||||
self.must_change_password = must_change_password
|
||||
self.first_name = first_name
|
||||
self.last_name = last_name
|
||||
self.department = department
|
||||
self.notes = notes
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
@@ -289,7 +330,11 @@ def init_user_db():
|
||||
"password_hash": generate_password_hash(password),
|
||||
"role": "admin",
|
||||
"sites": [],
|
||||
"must_change_password": False
|
||||
"must_change_password": False,
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"department": "",
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
save_users(users)
|
||||
@@ -362,11 +407,13 @@ def login():
|
||||
|
||||
if valid:
|
||||
login_user(user)
|
||||
add_history_entry("user_login", f"User '{email}' angemeldet", email)
|
||||
if user.must_change_password:
|
||||
return redirect("/change-password")
|
||||
return redirect("/admin")
|
||||
elif not user.password_hash:
|
||||
login_user(user)
|
||||
add_history_entry("user_login", f"User '{email}' angemeldet (Passwort-Änderung erforderlich)", email)
|
||||
return redirect("/change-password")
|
||||
error = "Ungültige Zugangsdaten"
|
||||
|
||||
@@ -376,7 +423,9 @@ def login():
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
"""Loggt den Admin aus und leitet zur Login-Seite weiter."""
|
||||
email = current_user.email if current_user.is_authenticated else "unknown"
|
||||
logout_user()
|
||||
add_history_entry("user_logout", f"User '{email}' abgemeldet", email)
|
||||
return redirect("/login")
|
||||
|
||||
|
||||
@@ -453,6 +502,7 @@ def add_customer():
|
||||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||||
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls}
|
||||
save_config(config)
|
||||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt", current_user.email)
|
||||
success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!"
|
||||
else:
|
||||
error = "Fehler beim Hinzufügen zur Playliste"
|
||||
@@ -723,6 +773,8 @@ def add_site():
|
||||
os.makedirs(os.path.join(MEDIA_DIR, name), exist_ok=True)
|
||||
save_config(cfg)
|
||||
|
||||
add_history_entry("site_created", f"Standort '{name}' angelegt", current_user.email)
|
||||
|
||||
return redirect(f"/admin/{name}")
|
||||
|
||||
|
||||
@@ -739,6 +791,8 @@ def delete_site(site):
|
||||
import shutil
|
||||
shutil.rmtree(site_dir)
|
||||
|
||||
add_history_entry("site_deleted", f"Standort '{site}' gelöscht", current_user.email)
|
||||
|
||||
sites = get_site_list(cfg)
|
||||
return redirect(f"/admin/{sites[0]}" if sites else "/admin")
|
||||
|
||||
@@ -911,7 +965,6 @@ def upload_background(site):
|
||||
site_dir = os.path.join(MEDIA_DIR, site)
|
||||
os.makedirs(site_dir, exist_ok=True)
|
||||
|
||||
# Alte Hintergrundbilder löschen
|
||||
for f in os.listdir(site_dir):
|
||||
if f.startswith("background."):
|
||||
os.remove(os.path.join(site_dir, f))
|
||||
@@ -919,9 +972,10 @@ def upload_background(site):
|
||||
filename = f"background{ext}"
|
||||
file.save(os.path.join(site_dir, filename))
|
||||
|
||||
# Willkommensseite mit neuen Hintergrund neu generieren
|
||||
_regenerate_welcome_for_site(site)
|
||||
|
||||
add_history_entry("background_uploaded", f"Hintergrundbild für Standort '{site}' hochgeladen", current_user.email)
|
||||
|
||||
return redirect(f"/willkommen?site={site}")
|
||||
|
||||
|
||||
@@ -935,9 +989,10 @@ def delete_background(site):
|
||||
if f.startswith("background."):
|
||||
os.remove(os.path.join(site_dir, f))
|
||||
|
||||
# Willkommensseite mit Standard-Hintergrund neu generieren
|
||||
_regenerate_welcome_for_site(site)
|
||||
|
||||
add_history_entry("background_deleted", f"Hintergrundbild für Standort '{site}' zurückgesetzt", current_user.email)
|
||||
|
||||
return redirect(f"/willkommen?site={site}")
|
||||
|
||||
|
||||
@@ -1017,6 +1072,8 @@ def delete_screen(site, screen):
|
||||
import shutil
|
||||
shutil.rmtree(screen_dir)
|
||||
|
||||
add_history_entry("screen_deleted", f"Screen '{screen}' an Standort '{site}' gelöscht", current_user.email)
|
||||
|
||||
return redirect(f"/admin/{site}")
|
||||
|
||||
|
||||
@@ -1040,6 +1097,7 @@ def add_screen(site):
|
||||
screens_cfg[screen_name] = {"playlist": [], "interval": 10, "show_images": True, "show_videos": True}
|
||||
|
||||
save_config(config)
|
||||
add_history_entry("screen_created", f"Screen '{screen_name}' an Standort '{site}' angelegt", current_user.email)
|
||||
return redirect(f"/admin/{site}#screen-{screen_name}")
|
||||
|
||||
|
||||
@@ -1206,6 +1264,7 @@ def change_password():
|
||||
users[email]["password_hash"] = generate_password_hash(new_pw)
|
||||
users[email]["must_change_password"] = False
|
||||
save_users(users)
|
||||
add_history_entry("password_changed", f"Passwort für User '{email}' geändert", current_user.email)
|
||||
return redirect("/admin")
|
||||
|
||||
return render_template(
|
||||
@@ -1224,6 +1283,96 @@ def change_password():
|
||||
# -------------------------------------------------
|
||||
# User-Verwaltung (nur für Admins)
|
||||
# -------------------------------------------------
|
||||
@app.route("/admin/dashboard")
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
"""Admin-Dashboard mit Statistiken: Sites, Screens, User, Admins, Superuser."""
|
||||
cfg = load_config()
|
||||
users_data = load_users()
|
||||
|
||||
sites = get_site_list(cfg)
|
||||
screen_count = sum(len(site_cfg.get("screens", {})) for site_cfg in cfg.get("sites", {}).values())
|
||||
|
||||
user_count = len(users_data)
|
||||
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
|
||||
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
|
||||
|
||||
history = get_history(30)
|
||||
|
||||
# Sparkline-Daten aus dem Verlauf berechnen
|
||||
def build_sparkline(entries, creates, deletes):
|
||||
seq = list(reversed(entries))
|
||||
data = [0]
|
||||
for e in seq:
|
||||
a = e.get("action")
|
||||
if a in creates:
|
||||
data.append(data[-1] + 1)
|
||||
elif a in deletes:
|
||||
data.append(data[-1] - 1)
|
||||
if len(data) < 3:
|
||||
data = [0, 0, 0]
|
||||
return data
|
||||
|
||||
site_spark = build_sparkline(history, {"site_created"}, {"site_deleted"})
|
||||
screen_spark = build_sparkline(history, {"screen_created"}, {"screen_deleted"})
|
||||
user_spark = build_sparkline(history, {"user_created"}, {"user_deleted"})
|
||||
admin_spark = build_sparkline(history, {"admin_created"}, {"admin_deleted"})
|
||||
superuser_spark = build_sparkline(history, {"superuser_created"}, {"superuser_deleted"})
|
||||
|
||||
def trend_val(creates, deletes):
|
||||
c = sum(1 for e in history if e.get("action") in creates)
|
||||
d = sum(1 for e in history if e.get("action") in deletes)
|
||||
return c - d
|
||||
|
||||
site_trend = trend_val({"site_created"}, {"site_deleted"})
|
||||
screen_trend = trend_val({"screen_created"}, {"screen_deleted"})
|
||||
user_trend = trend_val({"user_created"}, {"user_deleted"})
|
||||
admin_trend = trend_val({"admin_created"}, {"admin_deleted"})
|
||||
superuser_trend = trend_val({"superuser_created"}, {"superuser_deleted"})
|
||||
|
||||
return render_template(
|
||||
"admin_dashboard.html",
|
||||
site_count=len(sites),
|
||||
screen_count=screen_count,
|
||||
user_count=user_count,
|
||||
admin_count=admin_count,
|
||||
superuser_count=superuser_count,
|
||||
site_spark=site_spark,
|
||||
screen_spark=screen_spark,
|
||||
user_spark=user_spark,
|
||||
admin_spark=admin_spark,
|
||||
superuser_spark=superuser_spark,
|
||||
site_trend=site_trend,
|
||||
screen_trend=screen_trend,
|
||||
user_trend=user_trend,
|
||||
admin_trend=admin_trend,
|
||||
superuser_trend=superuser_trend,
|
||||
history=history,
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
version=APP_VERSION,
|
||||
year=datetime.now().year,
|
||||
hostname=os.uname().nodename,
|
||||
server_url=cfg.get("server_url", "")
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/help")
|
||||
@login_required
|
||||
def admin_help():
|
||||
"""Hilfeseite zur Bedienung des Tools."""
|
||||
cfg = load_config()
|
||||
return render_template(
|
||||
"help.html",
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
version=APP_VERSION,
|
||||
year=datetime.now().year,
|
||||
hostname=os.uname().nodename,
|
||||
server_url=cfg.get("server_url", "")
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/users")
|
||||
@admin_required
|
||||
def admin_users():
|
||||
@@ -1237,12 +1386,16 @@ def admin_users():
|
||||
"email": email,
|
||||
"role": data.get("role", "user"),
|
||||
"sites": data.get("sites", []),
|
||||
"must_change_password": data.get("must_change_password", False)
|
||||
"must_change_password": data.get("must_change_password", False),
|
||||
"first_name": data.get("first_name", ""),
|
||||
"last_name": data.get("last_name", ""),
|
||||
"department": data.get("department", ""),
|
||||
"notes": data.get("notes", "")
|
||||
})
|
||||
|
||||
return render_template(
|
||||
"user_list.html",
|
||||
users=sorted(users_list, key=lambda u: u["email"]),
|
||||
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
version=APP_VERSION,
|
||||
@@ -1296,9 +1449,18 @@ def admin_users_create():
|
||||
"password_hash": generate_password_hash(password),
|
||||
"role": role,
|
||||
"sites": sites if role == "user" else [],
|
||||
"must_change_password": True
|
||||
"must_change_password": True,
|
||||
"first_name": request.form.get("first_name", "").strip(),
|
||||
"last_name": request.form.get("last_name", "").strip(),
|
||||
"department": request.form.get("department", "").strip(),
|
||||
"notes": request.form.get("notes", "").strip()
|
||||
}
|
||||
save_users(users)
|
||||
add_history_entry("user_created", f"User '{email}' angelegt (Rolle: {role})", current_user.email)
|
||||
if role == "admin":
|
||||
add_history_entry("admin_created", f"Admin '{email}' angelegt", current_user.email)
|
||||
elif role == "superuser":
|
||||
add_history_entry("superuser_created", f"Superuser '{email}' angelegt", current_user.email)
|
||||
return redirect("/admin/users")
|
||||
|
||||
|
||||
@@ -1326,17 +1488,35 @@ def admin_users_edit(email):
|
||||
error = "Ungültige Rolle"
|
||||
else:
|
||||
user_data = users_data.pop(email)
|
||||
old_role = user_data.get("role", "user")
|
||||
user_data["role"] = new_role
|
||||
user_data["sites"] = new_sites if new_role == "user" else []
|
||||
user_data["first_name"] = request.form.get("first_name", "").strip()
|
||||
user_data["last_name"] = request.form.get("last_name", "").strip()
|
||||
user_data["department"] = request.form.get("department", "").strip()
|
||||
user_data["notes"] = request.form.get("notes", "").strip()
|
||||
users_data[new_email] = user_data
|
||||
save_users(users_data)
|
||||
add_history_entry("user_updated", f"User '{email}' bearbeitet (Rolle: {old_role} → {new_role})", current_user.email)
|
||||
if old_role == "admin" and new_role != "admin":
|
||||
add_history_entry("admin_deleted", f"Admin-Rechte von '{email}' entzogen", current_user.email)
|
||||
elif old_role != "admin" and new_role == "admin":
|
||||
add_history_entry("admin_created", f"'{email}' zum Admin ernannt", current_user.email)
|
||||
if old_role == "superuser" and new_role != "superuser":
|
||||
add_history_entry("superuser_deleted", f"Superuser-Rechte von '{email}' entzogen", current_user.email)
|
||||
elif old_role != "superuser" and new_role == "superuser":
|
||||
add_history_entry("superuser_created", f"'{email}' zum Superuser ernannt", current_user.email)
|
||||
return redirect(f"/admin/users?updated={new_email}")
|
||||
|
||||
user = {
|
||||
"email": email,
|
||||
"role": users_data[email].get("role", "user"),
|
||||
"sites": users_data[email].get("sites", []),
|
||||
"must_change_password": users_data[email].get("must_change_password", False)
|
||||
"must_change_password": users_data[email].get("must_change_password", False),
|
||||
"first_name": users_data[email].get("first_name", ""),
|
||||
"last_name": users_data[email].get("last_name", ""),
|
||||
"department": users_data[email].get("department", ""),
|
||||
"notes": users_data[email].get("notes", "")
|
||||
}
|
||||
|
||||
return render_template(
|
||||
@@ -1363,14 +1543,21 @@ def admin_users_delete(email):
|
||||
|
||||
users = load_users()
|
||||
|
||||
role = users.get(email, {}).get("role", "user")
|
||||
|
||||
# Prüfen, ob es der letzte Admin wäre
|
||||
if users.get(email, {}).get("role") == "admin":
|
||||
if role == "admin":
|
||||
admin_count = sum(1 for u in users.values() if u.get("role") == "admin")
|
||||
if admin_count <= 1:
|
||||
return "Letzter Admin kann nicht gelöscht werden", 400
|
||||
|
||||
users.pop(email, None)
|
||||
save_users(users)
|
||||
add_history_entry("user_deleted", f"User '{email}' gelöscht", current_user.email)
|
||||
if role == "admin":
|
||||
add_history_entry("admin_deleted", f"Admin '{email}' gelöscht", current_user.email)
|
||||
elif role == "superuser":
|
||||
add_history_entry("superuser_deleted", f"Superuser '{email}' gelöscht", current_user.email)
|
||||
return redirect("/admin/users")
|
||||
|
||||
|
||||
@@ -1389,6 +1576,7 @@ def admin_users_reset_password(email):
|
||||
users[email]["password_hash"] = generate_password_hash(temp_password)
|
||||
users[email]["must_change_password"] = True
|
||||
save_users(users)
|
||||
add_history_entry("user_password_reset", f"Passwort für User '{email}' zurückgesetzt", current_user.email)
|
||||
|
||||
return redirect(f"/admin/users?reset={email}&temp={temp_password}")
|
||||
|
||||
|
||||
200
history.json
Normal file
200
history.json
Normal file
@@ -0,0 +1,200 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-06-20 16:38:54",
|
||||
"action": "superuser_deleted",
|
||||
"detail": "Superuser-Rechte von 'test1@test.de' entzogen",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:38:54",
|
||||
"action": "user_updated",
|
||||
"detail": "User 'test1@test.de' bearbeitet (Rolle: superuser \u2192 user)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:38:19",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'wdqwsqws' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:30:45",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen '11wssss' an Standort 'wdqwsqws' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:30:41",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'dqwqqq' an Standort 'wdqwsqws' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:30:38",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'asdsad' an Standort 'wdqwsqws' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:29:59",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'qwsqwsqs' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:29:53",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'qwsqwsqs' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:29:49",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'wdqwsqws' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:29:33",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'wsqwdsqws' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:29:28",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'wsqwdsqws' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:27:58",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'sdwqdwddd' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:27:45",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'sdwqdwddd' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:26:50",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'test1' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:26:43",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'test2' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:26:40",
|
||||
"action": "screen_deleted",
|
||||
"detail": "Screen 'dasdsfs' an Standort 'test2' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:26:36",
|
||||
"action": "screen_deleted",
|
||||
"detail": "Screen 'asdwedwed' an Standort 'test2' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:22:12",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'dwedwed' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:22:05",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'dwedwed' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:21:38",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'asdwedwed' an Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:21:34",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'dasdsfs' an Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:21:31",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'asdasd' an Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:19:27",
|
||||
"action": "screen_deleted",
|
||||
"detail": "Screen 'test2' an Standort 'test2' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:18:49",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'test2' an Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:18:45",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'test1' an Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:18:40",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'test2' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:16:32",
|
||||
"action": "password_changed",
|
||||
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:14:03",
|
||||
"action": "superuser_created",
|
||||
"detail": "'test1@test.de' zum Superuser ernannt",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:14:03",
|
||||
"action": "user_updated",
|
||||
"detail": "User 'test1@test.de' bearbeitet (Rolle: user \u2192 superuser)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 16:13:29",
|
||||
"action": "user_created",
|
||||
"detail": "User 'test1@test.de' angelegt (Rolle: user)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 15:51:36",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'test2' an Standort 'test1' angelegt",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 15:51:31",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'test1' an Standort 'test1' angelegt",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-20 15:51:20",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'test1' angelegt",
|
||||
"user": "admin"
|
||||
}
|
||||
]
|
||||
@@ -33,7 +33,7 @@
|
||||
<a class="btn btn-outline-secondary me-2" href="javascript:void(0)"
|
||||
onclick="var n=prompt('Name des neuen Standorts:'); if(n&&n.trim()) location.href='/add-site?name='+encodeURIComponent(n.trim().toLowerCase());"
|
||||
title="Neuen Standort anlegen"
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-width:2.5rem;min-height:2.5rem;"><i class="ti ti-plus"></i></a>
|
||||
style="display:inline-flex;align-items:center;justify-content:center;min-width:2.5rem;min-height:2.5rem;padding:0;"><i class="ti ti-plus"></i></a>
|
||||
|
||||
<!-- Dark-Mode-Toggle (persistiert in localStorage("signage-theme")) -->
|
||||
<div>
|
||||
@@ -50,7 +50,9 @@
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" href="/change-password"><i class="ti ti-key me-1"></i>Passwort ändern</a>
|
||||
<a class="dropdown-item" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a class="dropdown-item" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||
<a class="dropdown-item" href="/admin/users"><i class="ti ti-users me-1"></i>Userverwaltung</a>
|
||||
{% endif %}
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
@@ -166,6 +166,23 @@
|
||||
box-shadow: 0 0 0 0.2rem rgba(218, 0, 45, 0.25);
|
||||
}
|
||||
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus {
|
||||
background-color: #e9ecef !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background-color: #e9ecef !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .dropdown-item:hover,
|
||||
[data-bs-theme="dark"] .dropdown-item:focus,
|
||||
[data-bs-theme="dark"] .dropdown-item.active,
|
||||
[data-bs-theme="dark"] .dropdown-item:active {
|
||||
background-color: #3a3f45 !important;
|
||||
}
|
||||
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background-color: var(--ccm-primary);
|
||||
|
||||
@@ -73,24 +73,6 @@
|
||||
color: var(--ccm-primary);
|
||||
}
|
||||
|
||||
/* ─── Dropdown-Hover (grauer Hintergrund) ─── */
|
||||
.dropdown-item:hover,
|
||||
.dropdown-item:focus {
|
||||
background-color: #e9ecef !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
.dropdown-item.active,
|
||||
.dropdown-item:active {
|
||||
background-color: #e9ecef !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .dropdown-item:hover,
|
||||
[data-bs-theme="dark"] .dropdown-item:focus,
|
||||
[data-bs-theme="dark"] .dropdown-item.active,
|
||||
[data-bs-theme="dark"] .dropdown-item:active {
|
||||
background-color: #3a3f45 !important;
|
||||
}
|
||||
|
||||
/* ─── Drag-Handle für SortableJS ─── */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
|
||||
206
templates/admin_dashboard.html
Normal file
206
templates/admin_dashboard.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CANCOM Simple Signage – Admin Dashboard</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<script>
|
||||
(() => {
|
||||
const stored = window.localStorage.getItem("signage-theme");
|
||||
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet"
|
||||
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||
<script defer
|
||||
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||
{% include "_styles.html" %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
{% set brand_subtitle = "Admin Dashboard" %}
|
||||
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="navbar-expand-md">
|
||||
<div class="collapse navbar-collapse show">
|
||||
<div class="navbar navbar-dark nav-surface">
|
||||
<div class="container-xl">
|
||||
<div class="d-flex flex-wrap gap-2 py-2">
|
||||
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||
<a class="btn btn-white active" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userverwaltung</a>
|
||||
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl mt-4">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body p-3 pb-0">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="text-secondary mt-1"><i class="ti ti-building-community" style="font-size:2.2rem;"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ site_count }}{% if site_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ site_trend }}</span>{% elif site_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ site_trend }}</span>{% endif %}</div>
|
||||
<div class="text-secondary small">Standorte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spark-sites" class="mx-n3" style="height:36px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body p-3 pb-0">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="text-secondary mt-1"><i class="ti ti-devices" style="font-size:2.2rem;"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ screen_count }}{% if screen_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ screen_trend }}</span>{% elif screen_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ screen_trend }}</span>{% endif %}</div>
|
||||
<div class="text-secondary small">Screens</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spark-screens" class="mx-n3" style="height:36px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body p-3 pb-0">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="text-secondary mt-1"><i class="ti ti-users" style="font-size:2.2rem;"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ user_count }}{% if user_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ user_trend }}</span>{% elif user_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ user_trend }}</span>{% endif %}</div>
|
||||
<div class="text-secondary small">User</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spark-users" class="mx-n3" style="height:36px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body p-3 pb-0">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="text-secondary mt-1"><i class="ti ti-shield" style="font-size:2.2rem;"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ admin_count }}{% if admin_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ admin_trend }}</span>{% elif admin_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ admin_trend }}</span>{% endif %}</div>
|
||||
<div class="text-secondary small">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spark-admins" class="mx-n3" style="height:36px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg">
|
||||
<div class="card card-sm">
|
||||
<div class="card-body p-3 pb-0">
|
||||
<div class="d-flex gap-3">
|
||||
<div class="text-secondary mt-1"><i class="ti ti-star" style="font-size:2.2rem;"></i></div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fs-1 fw-bold d-flex align-items-center gap-2">{{ superuser_count }}{% if superuser_trend > 0 %}<span class="text-green d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-up"></i>+{{ superuser_trend }}</span>{% elif superuser_trend < 0 %}<span class="text-red d-flex align-items-center gap-1" style="font-size:0.8rem;"><i class="ti ti-trending-down"></i>{{ superuser_trend }}</span>{% endif %}</div>
|
||||
<div class="text-secondary small">Superuser</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spark-superusers" class="mx-n3" style="height:36px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for entry in history %}
|
||||
<div class="list-group-item d-flex align-items-center gap-3 py-2">
|
||||
<span class="d-flex align-items-center">
|
||||
{% if entry.action == "site_created" %}
|
||||
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="Standort angelegt"><i class="ti ti-building-community text-white"></i></span>
|
||||
{% elif entry.action == "site_deleted" %}
|
||||
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="Standort gelöscht"><i class="ti ti-building-community text-white"></i></span>
|
||||
{% elif entry.action == "screen_created" %}
|
||||
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="Screen angelegt"><i class="ti ti-devices text-white"></i></span>
|
||||
{% elif entry.action == "screen_deleted" %}
|
||||
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="Screen gelöscht"><i class="ti ti-devices text-white"></i></span>
|
||||
{% elif entry.action == "user_created" %}
|
||||
<span class="avatar avatar-xs bg-green" data-bs-toggle="tooltip" title="User angelegt"><i class="ti ti-user-plus text-white"></i></span>
|
||||
{% elif entry.action == "user_deleted" %}
|
||||
<span class="avatar avatar-xs bg-red" data-bs-toggle="tooltip" title="User gelöscht"><i class="ti ti-user-minus text-white"></i></span>
|
||||
{% elif entry.action == "user_updated" %}
|
||||
<span class="avatar avatar-xs bg-blue" data-bs-toggle="tooltip" title="User bearbeitet"><i class="ti ti-user-edit text-white"></i></span>
|
||||
{% elif entry.action == "user_password_reset" %}
|
||||
<span class="avatar avatar-xs bg-warning" data-bs-toggle="tooltip" title="Passwort zurückgesetzt"><i class="ti ti-key text-white"></i></span>
|
||||
{% elif entry.action == "password_changed" %}
|
||||
<span class="avatar avatar-xs bg-purple" data-bs-toggle="tooltip" title="Passwort geändert"><i class="ti ti-key text-white"></i></span>
|
||||
{% elif entry.action == "background_uploaded" %}
|
||||
<span class="avatar avatar-xs bg-blue" data-bs-toggle="tooltip" title="Hintergrund hochgeladen"><i class="ti ti-photo text-white"></i></span>
|
||||
{% elif entry.action == "background_deleted" %}
|
||||
<span class="avatar avatar-xs bg-yellow" data-bs-toggle="tooltip" title="Hintergrund zurückgesetzt"><i class="ti ti-photo-off text-white"></i></span>
|
||||
{% elif entry.action == "customer_added" %}
|
||||
<span class="avatar avatar-xs bg-teal" data-bs-toggle="tooltip" title="Kunde hinzugefügt"><i class="ti ti-users text-white"></i></span>
|
||||
{% elif entry.action == "user_login" %}
|
||||
<span class="avatar avatar-xs bg-lime" data-bs-toggle="tooltip" title="Anmeldung"><i class="ti ti-login text-white"></i></span>
|
||||
{% elif entry.action == "user_logout" %}
|
||||
<span class="avatar avatar-xs bg-secondary" data-bs-toggle="tooltip" title="Abmeldung"><i class="ti ti-logout text-white"></i></span>
|
||||
{% else %}
|
||||
<span class="avatar avatar-xs bg-secondary" data-bs-toggle="tooltip" title="{{ entry.action }}"><i class="ti ti-info-circle text-white"></i></span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-secondary small text-nowrap" style="min-width:8.5rem;">{{ entry.timestamp }}</span>
|
||||
<span class="flex-grow-1">{{ entry.detail }}</span>
|
||||
<span class="text-secondary small text-nowrap">{{ entry.user }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-group-item text-center text-muted py-4">
|
||||
<i class="ti ti-inbox ti-lg d-block mb-1"></i>
|
||||
Noch keine Aktivitäten aufgezeichnet
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "_footer.html" %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (!window.ApexCharts) return;
|
||||
|
||||
function spark(id, data, color) {
|
||||
new ApexCharts(document.getElementById(id), {
|
||||
chart: { type: "area", height: 32, width: "100%", sparkline: { enabled: true }, animations: { enabled: false } },
|
||||
series: [{ data: data }],
|
||||
stroke: { width: 1.5, curve: "straight" },
|
||||
fill: { opacity: 0.25 },
|
||||
colors: [color],
|
||||
tooltip: { enabled: false }
|
||||
}).render();
|
||||
}
|
||||
|
||||
spark("spark-sites", {{ site_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||
spark("spark-screens", {{ screen_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||
spark("spark-users", {{ user_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||
spark("spark-admins", {{ admin_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||
spark("spark-superusers", {{ superuser_spark | tojson }}, "var(--ccm-primary, #da002d)");
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
216
templates/help.html
Normal file
216
templates/help.html
Normal file
@@ -0,0 +1,216 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>CANCOM Simple Signage – Hilfe</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<script>
|
||||
(() => {
|
||||
const stored = window.localStorage.getItem("signage-theme");
|
||||
const theme = stored === "dark" || stored === "light" ? stored : "light";
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet"
|
||||
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
||||
<script defer
|
||||
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js"></script>
|
||||
{% include "_styles.html" %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
{% set brand_subtitle = "Hilfe" %}
|
||||
{% set site_dropdown_url_prefix = "/admin/" %}
|
||||
{% include "_header.html" %}
|
||||
|
||||
<div class="navbar-expand-md">
|
||||
<div class="collapse navbar-collapse show">
|
||||
<div class="navbar navbar-dark nav-surface">
|
||||
<div class="container-xl">
|
||||
<div class="d-flex flex-wrap gap-2 py-2">
|
||||
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||
<a class="btn btn-white active" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl mt-4">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-building-community me-1"></i>Standort anlegen</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Ein Standort (Site) gruppiert mehrere Screens, z. B. <code>stuttgart</code> oder <code>karlsruhe</code>.</p>
|
||||
<ol>
|
||||
<li><strong>+ Button</strong> im Header (neben dem Standort-Dropdown) klicken</li>
|
||||
<li>Namen des neuen Standorts eingeben (z. B. <code>berlin</code>)</li>
|
||||
<li>Der Standort wird sofort angelegt und im Dropdown sichtbar</li>
|
||||
</ol>
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Nur Admins können Standorte anlegen oder löschen (Löschen über den Button im Standort-Dashboard).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-devices me-1"></i>Screen hinzufügen</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Ein Screen ist ein einzelner Player (z. B. <code>lobby</code>, <code>casino</code>), der eine Playlist abspielt.</p>
|
||||
<ol>
|
||||
<li>Im Admin-Bereich des Standorts (<code>/admin/<standort></code>) das <strong>+</strong>-Symbol oben rechts klicken</li>
|
||||
<li>Namen des Screens eingeben (z. B. <code>lobby</code>)</li>
|
||||
<li>Der Screen erscheint als neue Card mit Tabs: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien</li>
|
||||
</ol>
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Der Screen-Name wird automatisch in Kleinbuchstaben umgewandelt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-list me-1"></i>Playlist verwalten</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Jeder Screen hat eine eigene Playlist mit Medien und URLs.</p>
|
||||
<ul>
|
||||
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
|
||||
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
|
||||
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
|
||||
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
|
||||
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li>
|
||||
</ul>
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
||||
<ul>
|
||||
<li>Aufruf über <code>/admin/<standort>/priority</code></li>
|
||||
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li>
|
||||
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-external-link me-1"></i>Action Button (Custom-URL)</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Der Action Button ist ein konfigurierbarer Button im Player, der eine frei wählbare URL öffnet.</p>
|
||||
<ul>
|
||||
<li><strong>Konfiguration:</strong> Im Tab <strong>Aktionen</strong> der Screen-Card</li>
|
||||
<li><strong>URL:</strong> Beliebige Webadresse (z. B. Intranet-Seite, Dashboard)</li>
|
||||
<li><strong>Label:</strong> Text, der auf dem Button angezeigt wird (z. B. "Infos")</li>
|
||||
<li><strong>Position:</strong> 9 mögliche Positionen (oben/mitte/unten × links/mitte/rechts)</li>
|
||||
<li><strong>Ziel:</strong>
|
||||
<ul>
|
||||
<li><code>overlay</code> – URL wird in einem iframe-Overlay geöffnet, Zurück-Button schließt es</li>
|
||||
<li><code>redirect</code> – direkte Weiterleitung zur URL</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Der Button erscheint nur, wenn "Aktiviert" eingeschaltet ist. Bei Positionen unten weicht er automatisch 16px über dem Newsticker aus.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-robot me-1"></i>Digital Voice Agent</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Der Digital Voice Agent zeigt einen Button im Player, der eine Typewriter-Animation mit wechselnden mehrsprachigen Texten anzeigt.</p>
|
||||
<ul>
|
||||
<li><strong>Globale URL:</strong> Die Ziel-URL wird in <code>config.json</code> (Feld <code>voice_agent_url</code>) gesetzt und ist im Admin sichtbar, aber nicht änderbar</li>
|
||||
<li><strong>Konfiguration pro Screen:</strong> Im Tab <strong>Digital Voice Agent</strong> der Screen-Card</li>
|
||||
<li><strong>Label:</strong> Button-Beschriftung (Default: "Digitaler Assistent")</li>
|
||||
<li><strong>Position:</strong> Wie Action Button – 9 Positionen wählbar</li>
|
||||
<li><strong>Bild:</strong> Optional kann über dem Button ein Bild (dva.png) angezeigt werden (Beta-Feature)</li>
|
||||
<li><strong>Typewriter-Tagline:</strong> 11 Phrasen in 5 Sprachen wechseln alle 2,5 Sekunden mit Buchstaben-für-Buchstaben-Effekt</li>
|
||||
<li><strong>Animierte Balken:</strong> 5 rote Audiobalken neben dem Button signalisieren Aktivität</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-settings me-1"></i>Screen-Einstellungen</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Im Tab <strong>Einstellungen</strong> können pro Screen konfiguriert werden:</p>
|
||||
<ul>
|
||||
<li><strong>Intervall:</strong> Anzeigedauer pro Playlist-Eintrag in Sekunden</li>
|
||||
<li><strong>Bilder / Videos anzeigen:</strong> Jeweils ein-/ausschaltbar</li>
|
||||
<li><strong>Newsticker:</strong> Text (max. 200 Zeichen) und Aktivierung des Laufbands</li>
|
||||
<li><strong>Stay on First:</strong> Wenn aktiviert, bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
||||
<ul>
|
||||
<li>Aufruf über <code>/willkommen?site=<standort></code> (oder Menüpunkt)</li>
|
||||
<li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li>
|
||||
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
||||
<li>Im Hochformat (Portrait) werden die Logos untereinander angezeigt</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-shield me-1"></i>User-Verwaltung (Admin)</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Admins können über <code>/admin/users</code> die User-Verwaltung aufrufen.</p>
|
||||
<ul>
|
||||
<li><strong>User anlegen:</strong> E-Mail, Passwort, Rolle (Admin/Superuser/User) und Standort-Zugriff festlegen</li>
|
||||
<li><strong>User bearbeiten:</strong> E-Mail, Rolle, Standorte, Profilfelder (Vorname, Nachname, Abteilung) ändern</li>
|
||||
<li><strong>User löschen:</strong> Der letzte Admin kann nicht gelöscht werden</li>
|
||||
<li><strong>Passwort-Reset:</strong> Setzt ein temporäres Passwort, der User muss es beim nächsten Login ändern</li>
|
||||
<li><strong>Rollen:</strong>
|
||||
<ul>
|
||||
<li><strong>Admin</strong> – Zugriff auf alle Standorte + Userverwaltung</li>
|
||||
<li><strong>Superuser</strong> – Zugriff auf alle Standorte, keine Userverwaltung</li>
|
||||
<li><strong>User</strong> – Zugriff nur auf zugewiesene Standorte</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Das Admin Dashboard (<code>/admin/dashboard</code>) bietet eine Übersicht über das gesamte System:</p>
|
||||
<ul>
|
||||
<li><strong>Statistik-Cards:</strong> Anzahl Standorte, Screens, User, Admins und Superuser</li>
|
||||
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
|
||||
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
|
||||
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-moon me-1"></i>Dark Mode</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Der Dark Mode kann über den Mond-Button im Header umgeschaltet werden. Die Einstellung wird im Browser gespeichert (localStorage) und bleibt auch nach Seitenwechsel erhalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title"><i class="ti ti-key me-1"></i>Passwort ändern</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Das Passwort kann über das User-Menü (oben rechts) → <strong>Passwort ändern</strong> geändert werden. Nach einem Admin-Reset muss der User beim ersten Login ein neues Passwort vergeben.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "_footer.html" %}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,8 +32,10 @@
|
||||
<div class="container-xl">
|
||||
<div class="d-flex flex-wrap gap-2 py-2">
|
||||
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||
<a class="btn btn-white" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||
<a class="btn btn-white active" href="/admin/users/create"><i class="ti ti-user-plus me-1"></i>Neu anlegen</a>
|
||||
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,6 +55,16 @@
|
||||
<label class="form-label">E-Mail (Username)</label>
|
||||
<input type="email" name="email" class="form-control" placeholder="user@example.com" required>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Vorname</label>
|
||||
<input type="text" name="first_name" class="form-control" placeholder="Vorname">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nachname</label>
|
||||
<input type="text" name="last_name" class="form-control" placeholder="Nachname">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Passwort</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="Passwort" minlength="6" required>
|
||||
@@ -83,6 +95,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Abteilung</label>
|
||||
<input type="text" name="department" class="form-control" placeholder="z. B. Marketing">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bemerkung</label>
|
||||
<textarea name="notes" class="form-control" rows="2" placeholder="Interne Notiz"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">User anlegen</button>
|
||||
<a href="/admin/users" class="btn btn-outline-secondary ms-2">Abbrechen</a>
|
||||
</form>
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
<div class="container-xl">
|
||||
<div class="d-flex flex-wrap gap-2 py-2">
|
||||
<a class="btn btn-white" href="/admin"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||
<a class="btn btn-white" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||
<a class="btn btn-white" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||
<a class="btn btn-white active" href="#"><i class="ti ti-edit me-1"></i>Bearbeiten</a>
|
||||
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,6 +59,16 @@
|
||||
<label class="form-label">E-Mail (Username)</label>
|
||||
<input type="email" name="email" class="form-control" value="{{ user.email }}" required>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Vorname</label>
|
||||
<input type="text" name="first_name" class="form-control" value="{{ user.first_name }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Nachname</label>
|
||||
<input type="text" name="last_name" class="form-control" value="{{ user.last_name }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-select" id="role-select">
|
||||
@@ -91,6 +103,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Abteilung</label>
|
||||
<input type="text" name="department" class="form-control" value="{{ user.department }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bemerkung</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ user.notes }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
<a href="/admin/users" class="btn btn-outline-secondary ms-2">Abbrechen</a>
|
||||
</form>
|
||||
|
||||
@@ -36,8 +36,10 @@
|
||||
<div class="container-xl">
|
||||
<div class="d-flex flex-wrap gap-2 py-2">
|
||||
<a class="btn btn-white" href="/admin/{{ current_site }}"><i class="ti ti-arrow-left me-1"></i>Zurück</a>
|
||||
<a class="btn btn-white" href="/admin/dashboard"><i class="ti ti-dashboard me-1"></i>Admin Dashboard</a>
|
||||
<a class="btn btn-white active" href="/admin/users"><i class="ti ti-users me-1"></i>Userliste</a>
|
||||
<a class="btn btn-white" href="/admin/users/create"><i class="ti ti-user-plus me-1"></i>Neu anlegen</a>
|
||||
<a class="btn btn-white" href="/admin/help"><i class="ti ti-help me-1"></i>Hilfe</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,9 +71,11 @@
|
||||
<table class="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Standorte</th>
|
||||
<th>Abteilung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
@@ -79,6 +83,7 @@
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ u.last_name }}{% if u.last_name and u.first_name %}, {% endif %}{{ u.first_name }}</td>
|
||||
<td class="text-nowrap">{{ u.email }}</td>
|
||||
<td>
|
||||
{% if u.role == "admin" %}
|
||||
@@ -100,6 +105,7 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ u.department }}</td>
|
||||
<td>
|
||||
{% if u.must_change_password %}
|
||||
<span class="badge bg-warning text-white">Passwort-Änderung erforderlich</span>
|
||||
@@ -107,7 +113,7 @@
|
||||
<span class="badge bg-green text-green-fg">Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td class="text-nowrap">
|
||||
<div class="d-flex gap-1">
|
||||
{% if u.email != current_user.email %}
|
||||
<form action="/admin/users/delete/{{ u.email }}" method="post"
|
||||
|
||||
24
users.json
24
users.json
@@ -4,5 +4,29 @@
|
||||
"role": "admin",
|
||||
"sites": [],
|
||||
"must_change_password": false
|
||||
},
|
||||
"erik.thiele@cancom.de": {
|
||||
"password_hash": "scrypt:32768:8:1$gyBs4dA7DhH9wA0u$c80f25262388095545733c95dd4e569eee610fa849a11dd28d4f504d09e23d3768adad26d4ed671975bd3dcbae5dc43bd982f4d475e6bc9d2ede088cb0b6c025",
|
||||
"role": "admin",
|
||||
"sites": [],
|
||||
"must_change_password": false,
|
||||
"first_name": "Erik",
|
||||
"last_name": "Thiele",
|
||||
"department": "CSO",
|
||||
"notes": "Standort Stuttgart"
|
||||
},
|
||||
"test1@test.de": {
|
||||
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
||||
"role": "user",
|
||||
"sites": [
|
||||
"dva",
|
||||
"karlsruhe",
|
||||
"stuttgart"
|
||||
],
|
||||
"must_change_password": true,
|
||||
"first_name": "test",
|
||||
"last_name": "tester",
|
||||
"department": "addff",
|
||||
"notes": "asdasdasd"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user