feat: rewrite busylight as FastAPI web application with SQLite

Replace the standalone Python script + config.cfg with a full web
application featuring:
- FastAPI backend with REST API
- SQLite database (config, state, activity log separated)
- Bootstrap 5 web interface for configuration
- Pages: Dashboard, Users, Devices, Assignments, Color Rules, Settings, Log
- APScheduler background polling for Teams presence + AIDA status
- systemd service file for Linux deployment
- Migration script to import existing config.cfg data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 20:46:33 +02:00
co-authored by Claude Opus 4.6
commit 5267baf0b4
18 changed files with 1985 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Zuordnungen - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Zuordnungen (Benutzer &rarr; WLED-Segment)</h4>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Benutzer</th>
<th>WLED-Geraet</th>
<th>Segment</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td>{{ a.display_name }}</td>
<td>
<code>{{ a.hostname }}</code>
{% if a.device_name and a.device_name != a.hostname %}
<small class="text-muted">({{ a.device_name }})</small>
{% endif %}
</td>
<td>
<span class="badge bg-primary">Segment {{ a.segment }}</span>
</td>
<td>
<form method="post" action="/api/assignments/{{ a.id }}/delete" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
{% if not assignments %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Noch keine Zuordnungen vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Zuordnung</h6>
</div>
<div class="card-body">
<form method="post" action="/api/assignments">
<div class="mb-3">
<label class="form-label">Benutzer</label>
<select name="user_azure_id" class="form-select" required>
<option value="">-- Benutzer waehlen --</option>
{% for u in users %}
<option value="{{ u.azure_id }}">{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">WLED-Geraet</label>
<select name="wled_device_id" class="form-select" id="device-select" required>
<option value="">-- Geraet waehlen --</option>
{% for d in devices %}
<option value="{{ d.id }}" data-segments="{{ d.segment_count }}">
{{ d.name or d.hostname }} ({{ d.hostname }})
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Segment</label>
<select name="segment" class="form-select" id="segment-select" required>
<option value="0">Segment 0 (oben)</option>
<option value="1">Segment 1 (unten)</option>
</select>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-link-45deg"></i> Zuordnen
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('device-select').addEventListener('change', function() {
const opt = this.selectedOptions[0];
const count = parseInt(opt.dataset.segments || '2');
const segSelect = document.getElementById('segment-select');
segSelect.innerHTML = '';
for (let i = 0; i < count; i++) {
const label = i === 0 ? 'oben' : i === 1 ? 'unten' : i.toString();
segSelect.innerHTML += `<option value="${i}">Segment ${i} (${label})</option>`;
}
});
</script>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Busylight Manager{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
{% block head %}{% endblock %}
</head>
<body>
<div class="d-flex">
<nav class="sidebar bg-dark text-white d-flex flex-column">
<div class="p-3 border-bottom border-secondary">
<h5 class="mb-0"><i class="bi bi-lightbulb-fill text-warning"></i> Busylight</h5>
<small class="text-secondary">WLED Controller</small>
</div>
<ul class="nav flex-column mt-2 flex-grow-1">
<li class="nav-item">
<a class="nav-link text-white {% if page == 'dashboard' %}active{% endif %}" href="/">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users">
<i class="bi bi-people"></i> Benutzer
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'devices' %}active{% endif %}" href="/devices">
<i class="bi bi-router"></i> WLED-Geraete
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'assignments' %}active{% endif %}" href="/assignments">
<i class="bi bi-link-45deg"></i> Zuordnungen
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'colors' %}active{% endif %}" href="/colors">
<i class="bi bi-palette"></i> Farb-Regeln
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'settings' %}active{% endif %}" href="/settings">
<i class="bi bi-gear"></i> Einstellungen
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white {% if page == 'log' %}active{% endif %}" href="/log">
<i class="bi bi-journal-text"></i> Protokoll
</a>
</li>
</ul>
<div class="p-3 border-top border-secondary">
{% if authenticated %}
<small class="text-success"><i class="bi bi-check-circle"></i> Azure verbunden</small>
{% else %}
<small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small>
{% endif %}
</div>
</nav>
<main class="main-content flex-grow-1">
{% block content %}{% endblock %}
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Farb-Regeln - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Farb-Regeln</h4>
{% if request.query_params.get('saved') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Farben gespeichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" action="/api/colors">
<div class="row g-3">
{% for rule in rules %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="card busylight-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>{{ rule.status }}</strong>
<input type="hidden" name="status" value="{{ rule.status }}">
</div>
<div class="color-preview"
style="background: rgb({{ rule.r }}, {{ rule.g }}, {{ rule.b }});"
title="Aktuelle Farbe"></div>
</div>
<div class="mb-2">
<label class="form-label small text-muted mb-1">Farbe</label>
<input type="color" name="color_{{ rule.status }}" class="form-control form-control-color w-100"
value="#{{ '%02x%02x%02x' % (rule.r, rule.g, rule.b) | default('000000') }}">
</div>
<div>
<label class="form-label small text-muted mb-1">Effekt (optional)</label>
<input type="text" name="effect_{{ rule.status }}" class="form-control form-control-sm"
value="{{ rule.effect or '' }}" placeholder="z.B. /win&A=255">
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Alle Farben speichern
</button>
</div>
</form>
<hr class="my-4">
<div class="card" style="max-width: 400px;">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Farb-Regel</h6>
</div>
<div class="card-body">
<form method="post" action="/api/colors/add">
<div class="mb-3">
<label class="form-label">Status-Name</label>
<input type="text" name="status" class="form-control"
placeholder="z.B. teams_focusing" required>
</div>
<div class="mb-3">
<label class="form-label">Farbe</label>
<input type="color" name="color" class="form-control form-control-color w-100"
value="#00ff00">
</div>
<div class="mb-3">
<label class="form-label">Effekt (optional)</label>
<input type="text" name="effect" class="form-control"
placeholder="z.B. /win&A=255">
</div>
<button type="submit" class="btn btn-outline-primary w-100">Hinzufuegen</button>
</form>
</div>
</div>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Dashboard - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Dashboard</h4>
<div>
<form method="post" action="/api/poll" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-clockwise"></i> Jetzt abfragen
</button>
</form>
<span class="text-muted ms-2" id="last-update"></span>
</div>
</div>
{% if not authenticated %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und
<code>python app.py --auth</code> ausfuehren.
</div>
{% endif %}
{% if devices %}
<div class="row g-3">
{% for device in devices %}
<div class="col-md-6 col-lg-4 col-xl-3">
<div class="card busylight-card h-100">
<div class="card-header bg-white border-0 pb-0">
<div class="d-flex justify-content-between align-items-center">
<strong>{{ device.name }}</strong>
<small class="text-muted">{{ device.hostname }}</small>
</div>
</div>
<div class="card-body pt-2">
{% for seg in device.segments %}
<div class="mb-2">
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
{% if seg.user %}
<div class="segment-bar" style="background: rgba({{ seg.r }}, {{ seg.g }}, {{ seg.b }}, 0.3); border-color: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});">
<span class="status-dot me-2" style="background: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});"></span>
{{ seg.user }}
</div>
<small class="text-muted">{{ seg.aida }} / {{ seg.teams }}</small>
{% else %}
<div class="segment-bar empty">
<i class="bi bi-dash-circle me-2"></i> Nicht zugeordnet
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-lightbulb display-1 text-muted"></i>
<p class="text-muted mt-3">Noch keine WLED-Geraete konfiguriert.</p>
<a href="/devices" class="btn btn-primary">Geraete hinzufuegen</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh every 10 seconds
setTimeout(() => location.reload(), 10000);
document.getElementById('last-update').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
</script>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}WLED-Geraete - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">WLED-Geraete</h4>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Hostname / IP</th>
<th>Name</th>
<th>Segmente</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>
<code>{{ device.hostname }}</code>
</td>
<td>{{ device.name }}</td>
<td>{{ device.segment_count }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#edit-{{ device.id }}">
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="/api/devices/{{ device.id }}/delete"
class="d-inline"
onsubmit="return confirm('Geraet wirklich loeschen?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Edit Modal -->
<div class="modal fade" id="edit-{{ device.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/api/devices/{{ device.id }}/update">
<div class="modal-header">
<h5 class="modal-title">Geraet bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Hostname / IP</label>
<input type="text" name="hostname" class="form-control"
value="{{ device.hostname }}" required>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control"
value="{{ device.name }}">
</div>
<div class="mb-3">
<label class="form-label">Anzahl Segmente</label>
<input type="number" name="segment_count" class="form-control"
value="{{ device.segment_count }}" min="1" max="10">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
{% if not devices %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Noch keine Geraete vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neues Geraet</h6>
</div>
<div class="card-body">
<form method="post" action="/api/devices">
<div class="mb-3">
<label class="form-label">Hostname / IP</label>
<input type="text" name="hostname" class="form-control"
placeholder="doorlight0027.intra.example.de" required>
</div>
<div class="mb-3">
<label class="form-label">Name (optional)</label>
<input type="text" name="name" class="form-control"
placeholder="Buero 027">
</div>
<div class="mb-3">
<label class="form-label">Anzahl Segmente</label>
<input type="number" name="segment_count" class="form-control"
value="2" min="1" max="10">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus"></i> Hinzufuegen
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Protokoll - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Protokoll</h4>
<span class="text-muted" id="last-update"></span>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover log-table mb-0">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Benutzer</th>
<th>AIDA-Status</th>
<th>Teams-Status</th>
<th>WLED-URL</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="text-nowrap">{{ log.timestamp }}</td>
<td>{{ log.user_name }}</td>
<td>
<span class="badge {% if log.aida_status == 'anwesend' %}bg-success{% elif log.aida_status == 'abwesend' %}bg-secondary{% elif log.aida_status == 'homeoffice' %}bg-warning text-dark{% else %}bg-light text-dark{% endif %}">
{{ log.aida_status or '-' }}
</span>
</td>
<td>
<span class="badge bg-info">{{ log.teams_status or '-' }}</span>
</td>
<td><small class="font-monospace">{{ log.wled_url }}</small></td>
<td>
{% if log.result and '200' in log.result %}
<span class="text-success"><i class="bi bi-check-circle"></i> {{ log.result }}</span>
{% else %}
<span class="text-danger"><i class="bi bi-x-circle"></i> {{ log.result }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not logs %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
Noch keine Protokoll-Eintraege vorhanden.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
setTimeout(() => location.reload(), 15000);
document.getElementById('last-update').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
</script>
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Einstellungen - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Einstellungen</h4>
{% if request.query_params.get('saved') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Einstellungen gespeichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" action="/api/settings">
<div class="row g-4">
<!-- Azure AD -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-microsoft"></i> Azure AD / Microsoft Graph</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Client ID</label>
<input type="text" name="azure_client_id" class="form-control font-monospace"
value="{{ settings.azure_client_id }}">
</div>
<div class="mb-3">
<label class="form-label">Client Secret</label>
<input type="password" name="azure_client_secret" class="form-control font-monospace"
value="{{ settings.azure_client_secret }}">
</div>
<div class="mb-3">
<label class="form-label">Tenant ID</label>
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
value="{{ settings.azure_tenant_id }}">
</div>
<div class="mb-3">
<label class="form-label">Auth Tenant</label>
<input type="text" name="azure_auth_tenant" class="form-control"
value="{{ settings.azure_auth_tenant }}">
</div>
<div class="mb-3">
<label class="form-label">Graph Scopes</label>
<input type="text" name="azure_scopes" class="form-control"
value="{{ settings.azure_scopes }}">
</div>
<div class="border rounded p-3 bg-light">
{% if authenticated %}
<i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Authentifiziert</strong>
{% else %}
<i class="bi bi-x-circle text-danger"></i>
<strong class="text-danger">Nicht authentifiziert</strong>
<p class="small text-muted mt-2 mb-0">
Fuehre auf dem Server aus:<br>
<code>python app.py --auth</code>
</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Polling & AIDA -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-clock"></i> Polling</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Abfrage-Intervall (Sekunden)</label>
<input type="number" name="poll_interval" class="form-control"
value="{{ settings.poll_interval }}" min="5" max="300">
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">CSV-Pfad</label>
<input type="text" name="aida_csv_path" class="form-control font-monospace"
value="{{ settings.aida_csv_path }}"
placeholder="/mnt/zeiterfassung/Status.csv">
<small class="text-muted">Auf Linux z.B. via SMB-Mount</small>
</div>
<div class="mb-3">
<label class="form-label">Status: Anwesend</label>
<input type="text" name="aida_status_anwesend" class="form-control"
value="{{ settings.aida_status_anwesend }}">
</div>
<div class="mb-3">
<label class="form-label">Status: Abwesend</label>
<input type="text" name="aida_status_abwesend" class="form-control"
value="{{ settings.aida_status_abwesend }}">
</div>
<div class="mb-3">
<label class="form-label">Status: Homeoffice</label>
<input type="text" name="aida_status_homeoffice" class="form-control"
value="{{ settings.aida_status_homeoffice }}">
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Einstellungen speichern
</button>
</div>
</form>
{% endblock %}
+94
View File
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Benutzer - Busylight{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">Benutzer</h4>
<form method="post" action="/api/users/sync">
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download"></i> Aus Azure synchronisieren
</button>
</form>
</div>
{% if request.query_params.get('synced') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> {{ request.query_params.get('synced') }} neue Benutzer synchronisiert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% if request.query_params.get('error') == 'not_authenticated' %}
<div class="alert alert-danger alert-dismissible fade show">
<i class="bi bi-x-circle"></i> Azure nicht verbunden. Bitte zuerst authentifizieren.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Status</th>
<th>Name</th>
<th>E-Mail</th>
<th>Personal-Nr (AIDA)</th>
<th>Zuordnung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{% if not user.active %}table-light text-muted{% endif %}">
<td>
{% if user.active %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-secondary">Inaktiv</span>
{% endif %}
</td>
<td>{{ user.display_name }}</td>
<td><small>{{ user.email or '-' }}</small></td>
<td>
<form method="post" action="/api/users/{{ user.azure_id }}/update" class="d-flex gap-1">
<input type="text" name="personal_nr" value="{{ user.personal_nr or '' }}"
class="form-control form-control-sm" style="width: 120px;"
placeholder="~~00000~~">
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-check"></i>
</button>
</form>
</td>
<td>
{% if user.assignment_id %}
<span class="badge bg-info"><i class="bi bi-link-45deg"></i> Zugeordnet</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<form method="post" action="/api/users/{{ user.azure_id }}/toggle" class="d-inline">
<button type="submit" class="btn btn-sm {% if user.active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
{% if user.active %}
<i class="bi bi-pause-circle"></i>
{% else %}
<i class="bi bi-play-circle"></i>
{% endif %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-3 text-muted">
<small>{{ users|length }} Benutzer insgesamt</small>
</div>
{% endblock %}