initial feature/react-migration commit

This commit is contained in:
2025-06-22 20:57:21 +00:00
parent 6653f3cf72
commit 76f6baf533
66 changed files with 12038 additions and 91 deletions

View File

@@ -0,0 +1,38 @@
# dashboard/Dockerfile
# Produktions-Dockerfile für die Dash-Applikation
# --- Basis-Image ---
FROM python:3.13-slim
# --- Arbeitsverzeichnis im Container ---
WORKDIR /app
# --- Systemabhängigkeiten installieren (falls benötigt) ---
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential git \
&& rm -rf /var/lib/apt/lists/*
# --- Python-Abhängigkeiten kopieren und installieren ---
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# --- Applikationscode kopieren ---
COPY dashboard/ /app
# --- Non-Root-User anlegen und Rechte setzen ---
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} \
--shell /bin/bash --create-home infoscreen_taa \
&& chown -R infoscreen_taa:infoscreen_taa /app
USER infoscreen_taa
# --- Port für Dash exposed ---
EXPOSE 8050
# --- Startbefehl: Gunicorn mit Dash-Server ---
# "app.py" enthält: app = dash.Dash(...); server = app.server
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"]

View File

@@ -0,0 +1,51 @@
# dashboard/Dockerfile.dev
# Entwicklungs-Dockerfile für das Dash-Dashboard
FROM python:3.13-slim
# Build args für UID/GID
ARG USER_ID=1000
ARG GROUP_ID=1000
# Systemabhängigkeiten (falls nötig)
RUN apt-get update \
&& apt-get install -y --no-install-recommends locales curl \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
&& locale-gen \
&& rm -rf /var/lib/apt/lists/*
# Locale setzen
ENV LANG=de_DE.UTF-8 \
LANGUAGE=de_DE:de \
LC_ALL=de_DE.UTF-8
# Non-root User anlegen
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
# Arbeitsverzeichnis
WORKDIR /app
# Kopiere nur Requirements für schnellen Rebuild
COPY dash_using_fullcalendar-0.1.0.tar.gz ./
COPY requirements.txt ./
COPY requirements-dev.txt ./
# Installiere Abhängigkeiten
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir -r requirements-dev.txt
# Setze Entwicklungs-Modus
ENV DASH_DEBUG_MODE=True
ENV API_URL=http://server:8000/api
# Ports für Dash und optional Live-Reload
EXPOSE 8050
EXPOSE 5678
# Wechsle zum non-root User
USER infoscreen_taa
# Dev-Start: Dash mit Hot-Reload
CMD ["python", "-m", "debugpy", "--listen", "0.0.0.0:5637", "app.py"]

View File

View File

@@ -0,0 +1,79 @@
# dashboard/app.py
import sys
sys.path.append('/workspace')
from dash import Dash, html, dcc, page_container
from flask import Flask
import dash_bootstrap_components as dbc
import dash_mantine_components as dmc
from components.header import Header
import callbacks.ui_callbacks
import dashboard.callbacks.overview_callbacks
import dashboard.callbacks.appointments_callbacks
import dashboard.callbacks.appointment_modal_callbacks
from config import SECRET_KEY, ENV
import os
import threading
import logging
# Logging konfigurieren
logging.basicConfig(
level=logging.DEBUG if ENV == "development" else logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
server = Flask(__name__)
server.secret_key = SECRET_KEY
# Flask's eigene Logs aktivieren
if ENV == "development":
logging.getLogger('werkzeug').setLevel(logging.INFO)
app = Dash(
__name__,
server=server,
use_pages=True,
external_stylesheets=[dbc.themes.BOOTSTRAP],
suppress_callback_exceptions=True,
serve_locally=True,
meta_tags=[
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
{"charset": "utf-8"}
]
)
app.layout = dmc.MantineProvider([
Header(),
html.Div([
html.Div(id="sidebar"),
html.Div(page_container, className="page-content"),
dcc.Store(id="sidebar-state", data={"collapsed": False}),
], style={"display": "flex"}),
])
# def open_browser():
# """Öffnet die HTTPS-URL im Standardbrowser."""
# os.system('$BROWSER https://localhost:8050') # Entferne das "&", um sicherzustellen, dass der Browser korrekt geöffnet wird
print("Testausgabe: Debug-Print funktioniert!") # Testausgabe
if __name__ == "__main__":
debug_mode = ENV == "development"
logger.info(f"Starting application in {'DEBUG' if debug_mode else 'PRODUCTION'} mode")
logger.info(f"Environment: {ENV}")
logger.info("🔧 Debug features: print(), logging, hot reload all active")
logger.info("🚀 Dashboard starting up...")
# Browser nur einmal öffnen, nicht bei Reload-Prozessen
# if debug_mode and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
# threading.Timer(1.0, open_browser).start()
app.run(
host="0.0.0.0",
port=8050,
debug=debug_mode,
ssl_context=("/workspace/certs/dev.crt", "/workspace/certs/dev.key"),
use_reloader=False # Verhindert doppeltes Öffnen durch Dash
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,249 @@
/* ==========================
Allgemeines Layout
========================== */
:root {
--mb-z-index: 2000 !important;
--mantine-z-index-popover: 2100 !important;
--mantine-z-index-overlay: 2999 !important;
--mantine-z-index-dropdown: 2100 !important;
--mantine-z-index-max: 9999 !important;
--mantine-z-index-modal: 3000 !important;
}
body {
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
padding-top: 60px; /* Platz für den fixen Header schaffen */
}
/* page-content (rechts neben der Sidebar) */
.page-content {
flex: 1 1 0%;
padding: 20px;
min-width: 0; /* verhindert Überlauf bei zu breitem Inhalt */
transition: margin-left 0.3s ease;
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
margin-left: 220px; /* <--- Ergänzen */
}
/* Wenn Sidebar collapsed ist, reduziere margin-left */
.sidebar.collapsed ~ .page-content {
margin-left: 70px;
}
/* ==========================
Header
========================== */
.app-header {
position: fixed;
top: 0;
left: 0;
height: 60px;
width: 100%;
background-color: #e4d5c1;
color: #7c5617;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 1100;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.logo {
height: 40px;
margin-right: 10px;
}
.app-title {
font-size: 1.5rem;
font-weight: bold;
}
.org-name {
font-size: 1rem;
color: #7c5617;
}
.header-right {
display: flex;
align-items: center;
}
/* ==========================
Sidebar
========================== */
.sidebar {
width: 220px;
transition: width 0.3s ease;
background-color: #e4d5c1;
color: black;
height: calc(100vh - 60px); /* Höhe minus Header */
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
left: 0;
z-index: 1000;
position: fixed; /* <--- Ändere das von relative zu fixed */
overflow-x: hidden;
overflow-y: auto;
}
.sidebar.collapsed {
width: 70px;
}
/* Sidebar Toggle Button (Burger-Icon) */
.sidebar-toggle {
text-align: right;
padding: 5px 10px;
}
.toggle-button {
background: none;
border: none;
color: #7c5617;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.1s ease, background-color 0.2s ease;
padding: 8px;
border-radius: 4px;
}
.toggle-button:hover {
background-color: #7c5617;
color: #e4d5c1;
transform: scale(1.05);
}
.toggle-button:active {
transform: scale(0.95);
}
/* Navigation in der Sidebar */
.sidebar-nav .nav-link {
color: #7c5617;
padding: 10px 15px;
display: flex;
align-items: center;
border-radius: 4px;
margin: 2px 8px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.sidebar-nav .nav-link:hover {
background-color: #7c5617;
color: #e4d5c1;
}
.sidebar-nav .nav-link.active {
background-color: #7c5617;
color: #e4d5c1;
}
/* Text neben Icons */
.sidebar-label {
display: inline-block;
margin-left: 10px;
white-space: nowrap;
transition: opacity 0.3s ease, width 0.3s ease;
}
/* Wenn Sidebar collapsed ist, blendet das Label aus */
.sidebar.collapsed .sidebar-label {
opacity: 0;
width: 0;
overflow: hidden;
}
/* Tooltips (Bootstrap-Tooltips) */
.tooltip {
z-index: 2000;
background-color: #7c5617;
color: #e4d5c1;
}
/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */
/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert,
kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht.
Dieser Block ist nur ein Zusatz das Haupt-Show/Hiding erfolgt per
is_open-Callback. */
.sidebar:not(.collapsed) ~ .tooltip {
display: none !important;
}
/* ==========================
Responsive (bei Bedarf)
========================== */
/* @media (max-width: 768px) {
body {
padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */
/* }
.sidebar {
position: fixed;
height: calc(100vh - 60px);
z-index: 1050;
}
.page-content {
margin-left: 0;
}
.sidebar.collapsed {
width: 0;
}
.sidebar.collapsed ~ .page-content {
margin-left: 0;
}
} */
.mantine-Modal-modal {
z-index: var(--mantine-z-index-modal, 3000) !important;
}
/* Modalbox */
.mantine-Modal-inner,
.mantine-Modal-content {
z-index: 4000 !important;
}
/* Popups (Dropdowns, Datepicker, Autocomplete, Menüs) innerhalb der Modalbox */
.mantine-Popover-dropdown,
.mantine-Select-dropdown,
.mantine-DatePicker-dropdown,
.mantine-Autocomplete-dropdown,
.mantine-Menu-dropdown {
z-index: 4100 !important;
}
/* Optional: Overlay für Popups noch höher, falls benötigt */
.mantine-Popover-root,
.mantine-Select-root,
.mantine-DatePicker-root,
.mantine-Autocomplete-root,
.mantine-Menu-root {
z-index: 4101 !important;
}
/* Sidebar collapsed: Icon-Farbe normal */
.sidebar.collapsed .sidebar-item-collapsed svg {
color: #7c5617; /* Icon-Linie/Text */
fill: #e4d5c1; /* Icon-Fläche */
width: 24px;
height: 24px;
margin: 0 auto;
display: block;
transition: color 0.2s, fill 0.2s;
}
/* Sidebar collapsed: Hintergrund und Icon invertieren bei Hover/Active */
.sidebar.collapsed .nav-link:hover,
.sidebar.collapsed .nav-link.active {
background-color: #7c5617 !important;
}
.sidebar.collapsed .nav-link:hover svg,
.sidebar.collapsed .nav-link.active svg {
color: #e4d5c1; /* Icon-Linie/Text invertiert */
fill: #7c5617; /* Icon-Fläche invertiert */
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,370 @@
from dash import Input, Output, State, callback, html, dcc, no_update, ctx
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import dash_quill
from datetime import datetime, date, timedelta
import re
# --- Typ-spezifische Felder anzeigen ---
@callback(
Output('type-specific-fields', 'children'),
Input('type-input', 'value'),
prevent_initial_call=True
)
def show_type_specific_fields(event_type):
if not event_type:
return html.Div()
if event_type == "presentation":
return dmc.Stack([
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='presentation-upload',
children=dmc.Button(
"Datei hochladen",
leftSection=DashIconify(icon="mdi:upload"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Präsentationslink",
placeholder="https://...",
leftSection=DashIconify(icon="mdi:link"),
id="presentation-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
html.Div(id="presentation-upload-status")
], gap="sm")
elif event_type == "video":
return dmc.Stack([
dmc.Divider(label="Video-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='video-upload',
children=dmc.Button(
"Video hochladen",
leftSection=DashIconify(icon="mdi:video-plus"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Videolink",
placeholder="https://youtube.com/...",
leftSection=DashIconify(icon="mdi:youtube"),
id="video-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
dmc.Group([
dmc.Checkbox(
label="Endlos wiederholen",
id="video-endless",
checked=True,
style={"marginRight": 20}
),
dmc.NumberInput(
label="Wiederholungen",
id="video-repeats",
value=1,
min=1,
max=99,
step=1,
disabled=True,
style={"width": 150}
),
dmc.Slider(
label="Lautstärke",
id="video-volume",
value=70,
min=0,
max=100,
step=5,
marks=[
{"value": 0, "label": "0%"},
{"value": 50, "label": "50%"},
{"value": 100, "label": "100%"}
],
style={"flex": 1, "marginLeft": 20}
)
], grow=True, align="flex-end"),
html.Div(id="video-upload-status")
], gap="sm")
elif event_type == "website":
return dmc.Stack([
dmc.Divider(label="Website-Details", labelPosition="center"),
dmc.TextInput(
label="Website-URL",
placeholder="https://example.com",
leftSection=DashIconify(icon="mdi:web"),
id="website-url",
required=True,
style={"flex": 1}
)
], gap="sm")
elif event_type == "message":
return dmc.Stack([
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
dash_quill.Quill(
id="message-content",
value="",
),
dmc.Group([
dmc.Select(
label="Schriftgröße",
data=[
{"value": "small", "label": "Klein"},
{"value": "medium", "label": "Normal"},
{"value": "large", "label": "Groß"},
{"value": "xlarge", "label": "Sehr groß"}
],
id="message-font-size",
value="medium",
style={"flex": 1}
),
dmc.ColorPicker(
id="message-color",
value="#000000",
format="hex",
swatches=[
"#000000", "#ffffff", "#ff0000", "#00ff00",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
]
)
], grow=True, align="flex-end")
], gap="sm")
return html.Div()
# --- Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video ---
@callback(
Output("video-repeats", "disabled"),
Input("video-endless", "checked"),
prevent_initial_call=True
)
def toggle_video_repeats(endless_checked):
return endless_checked
# --- Upload-Status für Präsentation ---
@callback(
Output('presentation-upload-status', 'children'),
Input('presentation-upload', 'contents'),
State('presentation-upload', 'filename'),
prevent_initial_call=True
)
def update_presentation_upload_status(contents, filename):
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Datei '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# --- Upload-Status für Video ---
@callback(
Output('video-upload-status', 'children'),
Input('video-upload', 'contents'),
State('video-upload', 'filename'),
prevent_initial_call=True
)
def update_video_upload_status(contents, filename):
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Video '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# --- Wiederholungsoptionen aktivieren/deaktivieren ---
@callback(
[
Output('weekdays-select', 'disabled'),
Output('repeat-until-date', 'disabled'),
Output('skip-holidays-checkbox', 'disabled'),
Output('weekdays-select', 'value'),
Output('repeat-until-date', 'value'),
Output('skip-holidays-checkbox', 'checked')
],
Input('repeat-checkbox', 'checked'),
prevent_initial_call=True
)
def toggle_repeat_options(is_repeat):
if is_repeat:
next_month = datetime.now().date() + timedelta(weeks=4)
return (
False, # weekdays-select enabled
False, # repeat-until-date enabled
False, # skip-holidays-checkbox enabled
None, # weekdays value
next_month, # repeat-until-date value
False # skip-holidays-checkbox checked
)
else:
return (
True, # weekdays-select disabled
True, # repeat-until-date disabled
True, # skip-holidays-checkbox disabled
None, # weekdays value
None, # repeat-until-date value
False # skip-holidays-checkbox checked
)
# --- Dynamische Zeitoptionen für Startzeit ---
def validate_and_format_time(time_str):
if not time_str:
return None, "Keine Zeit angegeben"
if re.match(r'^\d{2}:\d{2}$', time_str):
try:
h, m = map(int, time_str.split(':'))
if 0 <= h <= 23 and 0 <= m <= 59:
return time_str, "Gültige Zeit"
except:
pass
patterns = [
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
]
for pattern, extractor in patterns:
match = re.match(pattern, time_str.strip())
if match:
try:
hours, minutes = extractor(match)
if 0 <= hours <= 23 and 0 <= minutes <= 59:
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
except:
continue
return None, "Ungültiges Zeitformat"
@callback(
[
Output('time-start', 'data'),
Output('start-time-feedback', 'children')
],
Input('time-start', 'searchValue'),
prevent_initial_call=True
)
def update_start_time_options(search_value):
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# --- Dynamische Zeitoptionen für Endzeit ---
@callback(
[
Output('time-end', 'data'),
Output('end-time-feedback', 'children')
],
Input('time-end', 'searchValue'),
prevent_initial_call=True
)
def update_end_time_options(search_value):
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# --- Automatische Endzeit-Berechnung mit Validation ---
@callback(
Output('time-end', 'value'),
[
Input('time-start', 'value'),
Input('btn-reset', 'n_clicks')
],
State('time-end', 'value'),
prevent_initial_call=True
)
def handle_end_time(start_time, reset_clicks, current_end_time):
if not ctx.triggered:
return no_update
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
if trigger_id == 'btn-reset' and reset_clicks:
return None
if trigger_id == 'time-start' and start_time:
if current_end_time:
return no_update
try:
validated_start, _ = validate_and_format_time(start_time)
if validated_start:
start_dt = datetime.strptime(validated_start, "%H:%M")
end_dt = start_dt + timedelta(hours=1, minutes=30)
if end_dt.hour >= 24:
end_dt = end_dt.replace(hour=23, minute=59)
return end_dt.strftime("%H:%M")
except:
pass
return no_update
# --- Reset-Funktion erweitert ---
@callback(
[
Output('title-input', 'value'),
Output('start-date-input', 'value', allow_duplicate=True),
Output('time-start', 'value'),
Output('type-input', 'value'),
Output('description-input', 'value'),
Output('repeat-checkbox', 'checked'),
Output('weekdays-select', 'value', allow_duplicate=True),
Output('repeat-until-date', 'value', allow_duplicate=True),
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
],
Input('btn-reset', 'n_clicks'),
prevent_initial_call=True
)
def reset_form(n_clicks):
if n_clicks:
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
return no_update
# --- Speichern-Funktion (Demo) ---
@callback(
Output('save-feedback', 'children'),
Input('btn-save', 'n_clicks'),
prevent_initial_call=True
)
def save_appointments_demo(n_clicks):
if not n_clicks:
return no_update
return dmc.Alert(
"Demo: Termine würden hier gespeichert werden",
color="blue",
title="Speichern (Demo)"
)

View File

@@ -0,0 +1,168 @@
import logging
from math import fabs
logger = logging.getLogger(__name__)
# dashboard/callbacks/appointments_callbacks.py
import requests
import json
from flask import session
from dash import Input, Output, State, callback, ctx, dash, no_update
import os
import sys
from datetime import datetime, timedelta
# This message will now appear in the terminal during startup
logger.debug("Registering appointments page...")
# --- Modalbox öffnen: jetzt auch auf Kalenderklick reagieren ---
sys.path.append('/workspace')
print("appointments_callbacks.py geladen")
API_BASE_URL = os.getenv("API_BASE_URL", "http://192.168.43.100")
ENV = os.getenv("ENV", "development")
@callback(
dash.Output('output', 'children'),
dash.Input('calendar', 'lastDateClick')
)
def display_date(date_str):
if date_str:
return f"Letzter Klick auf: {date_str}"
return "Klicke auf ein Datum im Kalender!"
@callback(
dash.Output('event-output', 'children'),
dash.Input('calendar', 'lastEventClick')
)
def display_event(event_id):
if event_id:
return f"Letztes Event geklickt: {event_id}"
return "Klicke auf ein Event im Kalender!"
@callback(
dash.Output('select-output', 'children'),
dash.Input('calendar', 'lastSelect')
)
def display_select(select_info):
if select_info:
return f"Markiert: {select_info['start']} bis {select_info['end']} (ganztägig: {select_info['allDay']})"
return "Markiere einen Bereich im Kalender!"
@callback(
dash.Output('calendar', 'events'),
dash.Input('calendar', 'lastNavClick'),
)
def load_events(view_dates):
logger.info(f"Lade Events für Zeitraum: {view_dates}")
if not view_dates or "start" not in view_dates or "end" not in view_dates:
return []
start = view_dates["start"]
end = view_dates["end"]
try:
verify_ssl = True if ENV == "production" else False
resp = requests.get(
f"{API_BASE_URL}/api/events",
params={"start": start, "end": end},
verify=verify_ssl
)
resp.raise_for_status()
events = resp.json()
return events
except Exception as e:
logger.info(f"Fehler beim Laden der Events: {e}")
return []
# --- Modalbox öffnen ---
@callback(
[
Output("appointment-modal", "opened"),
Output("start-date-input", "value", allow_duplicate=True),
Output("time-start", "value", allow_duplicate=True),
Output("time-end", "value", allow_duplicate=True),
],
[
Input("calendar", "lastDateClick"),
Input("calendar", "lastSelect"),
Input("open-appointment-modal-btn", "n_clicks"),
Input("close-appointment-modal-btn", "n_clicks"),
],
State("appointment-modal", "opened"),
prevent_initial_call=True
)
def open_modal(date_click, select, open_click, close_click, is_open):
trigger = ctx.triggered_id
# Bereichsauswahl (lastSelect)
if trigger == "calendar" and select:
try:
start_dt = datetime.fromisoformat(select["start"])
end_dt = datetime.fromisoformat(select["end"])
return (
True,
start_dt.date().isoformat(),
start_dt.strftime("%H:%M"),
end_dt.strftime("%H:%M"),
)
except Exception as e:
print("Fehler beim Parsen von select:", e)
return no_update, no_update, no_update, no_update
# Einzelklick (lastDateClick)
if trigger == "calendar" and date_click:
try:
dt = datetime.fromisoformat(date_click)
# Versuche, die Slotlänge aus dem Kalender zu übernehmen (optional)
# Hier als Beispiel 30 Minuten aufaddieren, falls keine Endzeit vorhanden
end_dt = dt + timedelta(minutes=30)
return (
True,
dt.date().isoformat(),
dt.strftime("%H:%M"),
end_dt.strftime("%H:%M"),
)
except Exception as e:
print("Fehler beim Parsen von date_click:", e)
return no_update, no_update, no_update, no_update
# Modal öffnen per Button
if trigger == "open-appointment-modal-btn" and open_click:
now = datetime.now()
end_dt = now + timedelta(minutes=30)
return True, now.date().isoformat(), now.strftime("%H:%M"), end_dt.strftime("%H:%M")
# Modal schließen
if trigger == "close-appointment-modal-btn" and close_click:
return False, no_update, no_update, no_update
return is_open, no_update, no_update, no_update
# @callback(
# Output("time-end", "value", allow_duplicate=True),
# Input("time-start", "value"),
# prevent_initial_call=True
# )
# def handle_end_time(start_time, duration="00:30"):
# trigger = ctx.triggered_id
# if trigger == "time-start" and start_time and duration:
# try:
# # Beispiel für start_time: "09:00"
# start_dt = datetime.strptime(start_time, "%H:%M")
# # Dauer in Stunden und Minuten, z.B. "01:30"
# hours, minutes = map(int, duration.split(":"))
# # Endzeit berechnen: Dauer addieren!
# end_dt = start_dt + timedelta(hours=hours, minutes=minutes)
# return end_dt.strftime("%H:%M")
# except Exception as e:
# print("Fehler bei der Berechnung der Endzeit:", e)
# return no_update

View File

@@ -0,0 +1,31 @@
# dashboard/callbacks/auth_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.auth import check_password, get_user_role
from config import ENV
from utils.db import execute_query
@dash.callback(
Output("login-feedback", "children"),
Output("header-right", "children"),
Input("btn-login", "n_clicks"),
State("input-user", "value"),
State("input-pass", "value"),
prevent_initial_call=True
)
def login_user(n_clicks, username, password):
if ENV == "development":
# DevBypass: setze immer AdminSession und leite weiter
session["username"] = "dev_admin"
session["role"] = "admin"
return dcc.Location(href="/overview", id="redirect-dev"), None
# ProduktionsLogin: User in DB suchen
user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True)
if user and check_password(password, user["pwd_hash"]):
session["username"] = user["username"]
session["role"] = user["role"]
return dcc.Location(href="/overview", id="redirect-ok"), None
else:
return "Ungültige Zugangsdaten.", None

View File

@@ -0,0 +1,139 @@
# dashboard/callbacks/overview_callbacks.py
import sys
sys.path.append('/workspace')
import threading
import dash
import requests
from dash import Input, Output, State, MATCH, html, dcc
from flask import session
from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern!
from utils.mqtt_client import publish, start_loop
from config import ENV
import dash_bootstrap_components as dbc
import os
import time
import pytz
from datetime import datetime
print("overview_callbacks.py geladen")
API_BASE_URL = os.getenv("API_BASE_URL", "https://192.168.43.100")
mqtt_thread_started = False
SCREENSHOT_DIR = "received-screenshots"
def ensure_mqtt_running():
global mqtt_thread_started
if not mqtt_thread_started:
thread = threading.Thread(target=start_loop, daemon=True)
thread.start()
mqtt_thread_started = True
def get_latest_screenshot(client_uuid):
cache_buster = int(time.time()) # aktuelle Unix-Zeit in Sekunden
# TODO: Hier genau im Produkitv-Modus die IPs testen!
# Wenn API_BASE_URL auf "http" beginnt, absolute URL verwenden (z.B. im lokalen Dev)
if API_BASE_URL.startswith("http"):
return f"{API_BASE_URL}/screenshots/{client_uuid}?t={cache_buster}"
# Sonst relative URL (nginx-Proxy übernimmt das Routing)
return f"/screenshots/{client_uuid}?t={cache_buster}"
def fetch_clients():
try:
verify_ssl = True if ENV == "production" else False
resp = requests.get(
f"{API_BASE_URL}/api/clients",
verify=verify_ssl
)
resp.raise_for_status()
return resp.json()
except Exception as e:
print("Fehler beim Abrufen der Clients:", e)
return []
@dash.callback(
Output("clients-cards-container", "children"),
Input("interval-update", "n_intervals")
)
def update_clients(n):
# ... Session-Handling wie gehabt ...
ensure_mqtt_running()
clients = fetch_clients()
cards = []
for client in clients:
uuid = client["uuid"]
screenshot = get_latest_screenshot(uuid)
last_alive_utc = client.get("last_alive")
if last_alive_utc:
try:
# Unterstützt sowohl "2024-06-08T12:34:56Z" als auch "2024-06-08T12:34:56"
if last_alive_utc.endswith("Z"):
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%SZ")
else:
dt_utc = datetime.strptime(last_alive_utc, "%Y-%m-%dT%H:%M:%S")
dt_utc = dt_utc.replace(tzinfo=pytz.UTC)
# Lokale Zeitzone fest angeben, z.B. Europe/Berlin
local_tz = pytz.timezone("Europe/Berlin")
dt_local = dt_utc.astimezone(local_tz)
last_alive_str = dt_local.strftime("%d.%m.%Y %H:%M:%S")
except Exception:
last_alive_str = last_alive_utc
else:
last_alive_str = "-"
card = dbc.Card(
[
dbc.CardHeader(client["location"]),
dbc.CardBody([
html.Img(
src=screenshot,
style={
"width": "240px",
"height": "135px",
"object-fit": "cover",
"display": "block",
"margin-left": "auto",
"margin-right": "auto"
},
),
html.P(f"IP: {client['ip_address'] or '-'}", className="card-text"),
html.P(f"Letzte Aktivität: {last_alive_str}", className="card-text"),
dbc.ButtonGroup([
dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0),
dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0),
], className="mt-2"),
html.Div(id={"type": "restart-feedback", "index": uuid}),
html.Div(id={"type": "reload-feedback", "index": uuid}),
]),
],
className="mb-4",
style={"width": "18rem"},
)
cards.append(dbc.Col(card, width=4))
return dbc.Row(cards)
@dash.callback(
Output({"type": "restart-feedback", "index": MATCH}, "children"),
Input({"type": "btn-restart", "index": MATCH}, "n_clicks"),
State({"type": "btn-restart", "index": MATCH}, "id")
)
def on_restart(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "restart"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""
@dash.callback(
Output({"type": "reload-feedback", "index": MATCH}, "children"),
Input({"type": "btn-reload", "index": MATCH}, "n_clicks"),
State({"type": "btn-reload", "index": MATCH}, "id")
)
def on_reload(n_clicks, btn_id):
if n_clicks and n_clicks > 0:
cid = btn_id["index"]
payload = '{"command": "reload"}'
ok = publish(f"clients/{cid}/control", payload)
return "Befehl gesendet." if ok else "Fehler beim Senden."
return ""

View File

@@ -0,0 +1,20 @@
# dashboard/callbacks/settings_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
@dash.callback(
Output("settings-feedback", "children"),
Input("btn-save-settings", "n_clicks"),
State("input-default-volume", "value"),
prevent_initial_call=True
)
def save_settings(n_clicks, volume):
if "role" not in session:
return dcc.Location(href="/login")
if n_clicks and n_clicks > 0:
sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'"
rc = execute_non_query(sql, (volume,))
return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen."
return ""

View File

@@ -0,0 +1,26 @@
# dashboard/callbacks/ui_callbacks.py
from dash import Input, Output, State, callback
from components.sidebar import Sidebar
@callback(
Output("sidebar", "children"),
Output("sidebar", "className"),
Input("sidebar-state", "data"),
)
def render_sidebar(data):
collapsed = data.get("collapsed", False)
return Sidebar(collapsed=collapsed), f"sidebar{' collapsed' if collapsed else ''}"
@callback(
Output("sidebar-state", "data"),
Input("btn-toggle-sidebar", "n_clicks"),
State("sidebar-state", "data"),
prevent_initial_call=True,
)
def toggle_sidebar(n, data):
if n is None:
# Kein Klick, nichts ändern!
return data
collapsed = not data.get("collapsed", False)
return {"collapsed": collapsed}

View File

@@ -0,0 +1,24 @@
# dashboard/callbacks/users_callbacks.py
import dash
from dash import Input, Output, State, dcc
from flask import session
from utils.db import execute_query, execute_non_query
from utils.auth import hash_password
@dash.callback(
Output("users-feedback", "children"),
Input("btn-new-user", "n_clicks"),
State("input-new-username", "value"),
State("input-new-password", "value"),
State("input-new-role", "value"),
prevent_initial_call=True
)
def create_user(n_clicks, uname, pwd, role):
if session.get("role") != "admin":
return "Keine Berechtigung."
if n_clicks and n_clicks > 0:
pwd_hash = hash_password(pwd)
sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)"
rc = execute_non_query(sql, (uname, pwd_hash, role))
return "Benutzer erstellt." if rc else "Fehler beim Erstellen."
return ""

View File

@@ -0,0 +1,258 @@
from dash import html, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import dash_quill
def create_input_with_tooltip_full(component, tooltip_text):
return dmc.Stack([
dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
], gap=0)
def create_input_with_tooltip_time(component, tooltip_text):
return dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
def get_appointment_modal():
weekday_options = [
{"value": "0", "label": "Montag"},
{"value": "1", "label": "Dienstag"},
{"value": "2", "label": "Mittwoch"},
{"value": "3", "label": "Donnerstag"},
{"value": "4", "label": "Freitag"},
{"value": "5", "label": "Samstag"},
{"value": "6", "label": "Sonntag"}
]
time_options = [
{"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"}
for h in range(6, 24) for m in [0, 30]
]
return dmc.Modal(
id="appointment-modal",
title="Neuen Termin anlegen",
centered=True,
size="auto", # oder "80vw"
children=[
dmc.Container([
dmc.Grid([
dmc.GridCol([
dmc.Paper([
dmc.Title("Termindetails", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.TextInput(
label="Titel",
placeholder="Terminbezeichnung eingeben",
leftSection=DashIconify(icon="mdi:calendar-text"),
id="title-input",
required=True,
style={"flex": 1}
),
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Startdatum",
id="start-date-input",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Datum auswählen",
leftSection=DashIconify(icon="mdi:calendar"),
clearable=False,
style={"flex": 1}
),
"Wählen Sie das Datum für den Termin aus dem Kalender"
),
dmc.Grid([
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Startzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-start",
value="09:00",
required=True,
style={"flex": 1}
),
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
),
html.Div(id="start-time-feedback")
], gap="xs")
], span=6),
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Endzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-end",
required=True,
style={"flex": 1}
),
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
),
html.Div(id="end-time-feedback")
], gap="xs")
], span=6)
]),
create_input_with_tooltip_full(
dmc.Select(
label="Termintyp",
placeholder="Typ auswählen",
data=[
{"value": "presentation", "label": "Präsentation"},
{"value": "website", "label": "Website"},
{"value": "video", "label": "Video"},
{"value": "message", "label": "Nachricht"},
{"value": "webuntis", "label": "WebUntis"},
{"value": "other", "label": "Sonstiges"}
],
id="type-input",
required=True,
style={"flex": 1}
),
"Wählen Sie die Art der Präsentation aus."
),
html.Div(id="type-specific-fields"),
create_input_with_tooltip_full(
dmc.Textarea(
label="Beschreibung",
placeholder="Zusätzliche Informationen...",
minRows=3,
autosize=True,
id="description-input",
style={"flex": 1}
),
"Optionale Beschreibung mit weiteren Details zum Termin"
)
], gap="md")
], p="md", shadow="sm")
], span=6),
dmc.GridCol([
dmc.Paper([
dmc.Title("Wiederholung", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.Checkbox(
label="Wiederholender Termin",
id="repeat-checkbox",
description="Aktivieren für wöchentliche Wiederholung"
),
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
),
html.Div(id="repeat-options", children=[
create_input_with_tooltip_full(
dmc.MultiSelect(
label="Wochentage",
placeholder="Wochentage auswählen",
data=weekday_options,
id="weekdays-select",
description="An welchen Wochentagen soll der Termin stattfinden?",
disabled=True,
style={"flex": 1}
),
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
),
dmc.Space(h="md"),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Wiederholung bis",
id="repeat-until-date",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Enddatum auswählen",
leftSection=DashIconify(icon="mdi:calendar-end"),
description="Letzter Tag der Wiederholung",
disabled=True,
clearable=True,
style={"flex": 1}
),
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
),
dmc.Space(h="lg"),
create_input_with_tooltip_full(
dmc.Checkbox(
label="Ferientage berücksichtigen",
id="skip-holidays-checkbox",
description="Termine an Feiertagen und in Schulferien auslassen",
disabled=True
),
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
)
])
], gap="md")
], p="md", shadow="sm"),
dmc.Paper([
dmc.Title("Aktionen", order=3, className="mb-3"),
dmc.Stack([
dmc.Button(
"Termin(e) speichern",
color="green",
leftSection=DashIconify(icon="mdi:content-save"),
id="btn-save",
size="lg",
fullWidth=True
),
dmc.Button(
"Zurücksetzen",
color="gray",
variant="outline",
leftSection=DashIconify(icon="mdi:refresh"),
id="btn-reset",
fullWidth=True
),
dmc.Button(
"Schließen",
id="close-appointment-modal-btn",
color="red", # oder "danger"
leftSection=DashIconify(icon="mdi:close"),
variant="filled",
style={"marginBottom": 10}
),
html.Div(id="save-feedback", className="mt-3")
], gap="md")
], p="md", shadow="sm", className="mt-3")
], span=6)
])
], size="lg")
]
)

View File

@@ -0,0 +1,13 @@
# dashboard/components/header.py
from dash import html
def Header():
return html.Div(
className="app-header",
children=[
html.Img(src="/assets/logo.png", className="logo"),
html.Span("Infoscreen-Manager", className="app-title"),
html.Span(" Organisationsname", className="org-name"),
html.Div(id="header-right", className="header-right") # Platzhalter für Login/ProfilButton
]
)

View File

@@ -0,0 +1,72 @@
# dashboard/components/sidebar.py
from dash import html
import dash_bootstrap_components as dbc
from dash_iconify import DashIconify
def Sidebar(collapsed: bool = False):
"""
Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar").
Das äußere div wird bereits in app.py definiert.
"""
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
if collapsed:
nav_links = [
dbc.NavLink(
DashIconify(icon=item["icon"], width=24),
href=item["href"],
active="exact",
className="sidebar-item-collapsed",
id={"type": "nav-item", "index": item["label"]},
)
for item in nav_items
]
else:
nav_links = [
dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id={"type": "nav-item", "index": item["label"]},
)
for item in nav_items
]
return [
html.Div(
className="sidebar-toggle",
children=html.Button(
DashIconify(icon="mdi:menu", width=28),
id="btn-toggle-sidebar",
className="toggle-button",
)
),
dbc.Collapse(
dbc.Nav(
nav_links,
vertical=True,
pills=True,
className="sidebar-nav",
),
is_open=not collapsed,
className="sidebar-nav",
) if not collapsed else
dbc.Nav(
nav_links,
vertical=True,
pills=True,
className="sidebar-nav-collapsed",
),
]

View File

@@ -0,0 +1,28 @@
# dashboard/config.py
import os
from dotenv import load_dotenv
# .env aus RootVerzeichnis laden
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
load_dotenv(os.path.join(base_dir, ".env"))
# DBEinstellungen
DB_HOST = os.getenv("DB_HOST")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_NAME = os.getenv("DB_NAME")
DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool")
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
# MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME")
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID")
# Sonstige Einstellungen
SECRET_KEY = os.getenv("SECRET_KEY", "changeme")
ENV = os.getenv("ENV", "development")

View File

@@ -0,0 +1,402 @@
import dash
import full_calendar_component as fcc
from dash import *
import dash_mantine_components as dmc
from dash.exceptions import PreventUpdate
from datetime import datetime, date, timedelta
import dash_quill
# dash._dash_renderer._set_react_version('18.2.0')
app = Dash(__name__, prevent_initial_callbacks=True)
quill_mods = [
[{"header": "1"}, {"header": "2"}, {"font": []}],
[{"size": []}],
["bold", "italic", "underline", "strike", "blockquote"],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
["link", "image"],
]
# Get today's date
today = datetime.now()
# Format the date
formatted_date = today.strftime("%Y-%m-%d")
app.layout = html.Div(
[
fcc.FullCalendarComponent(
id="calendar", # Unique ID for the component
initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek,
# dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek
headerToolbar={
"left": "prev,next today",
"center": "",
"right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth",
}, # Calendar header
initialDate=f"{formatted_date}", # Start date for calendar
editable=True, # Allow events to be edited
selectable=True, # Allow dates to be selected
events=[],
nowIndicator=True, # Show current time indicator
navLinks=True, # Allow navigation to other dates
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="modal",
size="xl",
title="Event Details",
zIndex=10000,
children=[
html.Div(id="modal_event_display_context"),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal-close-button",
),
],
pos="right",
),
],
)
],
),
dmc.MantineProvider(
theme={"colorScheme": "dark"},
children=[
dmc.Modal(
id="add_modal",
title="New Event",
size="xl",
children=[
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="start_date",
label="Start Date",
value=datetime.now().date(),
styles={"width": "100%"},
disabled=True,
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="Start Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="start_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
html.Div(
dmc.DatePickerInput(
id="end_date",
label="End Date",
value=datetime.now().date(),
styles={"width": "100%"},
),
style={"width": "100%"},
),
span=6,
),
dmc.GridCol(
html.Div(
dmc.TimeInput(
label="End Time",
withSeconds=True,
value=datetime.now(),
# format="12",
id="end_time",
),
style={"width": "100%"},
),
span=6,
),
],
gutter="xl",
),
dmc.Grid(
children=[
dmc.GridCol(
span=6,
children=[
dmc.TextInput(
label="Event Title:",
style={"width": "100%"},
id="event_name_input",
required=True,
)
],
),
dmc.GridCol(
span=6,
children=[
dmc.Select(
label="Select event color",
placeholder="Select one",
id="event_color_select",
value="ng",
data=[
{
"value": "bg-gradient-primary",
"label": "bg-gradient-primary",
},
{
"value": "bg-gradient-secondary",
"label": "bg-gradient-secondary",
},
{
"value": "bg-gradient-success",
"label": "bg-gradient-success",
},
{
"value": "bg-gradient-info",
"label": "bg-gradient-info",
},
{
"value": "bg-gradient-warning",
"label": "bg-gradient-warning",
},
{
"value": "bg-gradient-danger",
"label": "bg-gradient-danger",
},
{
"value": "bg-gradient-light",
"label": "bg-gradient-light",
},
{
"value": "bg-gradient-dark",
"label": "bg-gradient-dark",
},
{
"value": "bg-gradient-white",
"label": "bg-gradient-white",
},
],
style={"width": "100%", "marginBottom": 10},
required=True,
)
],
),
]
),
dash_quill.Quill(
id="rich_text_input",
modules={
"toolbar": quill_mods,
"clipboard": {
"matchVisual": False,
},
},
),
dmc.Accordion(
children=[
dmc.AccordionItem(
[
dmc.AccordionControl("Raw HTML"),
dmc.AccordionPanel(
html.Div(
id="rich_text_output",
style={
"height": "300px",
"overflowY": "scroll",
},
)
),
],
value="raw_html",
),
],
),
dmc.Space(h=20),
dmc.Group(
[
dmc.Button(
"Submit",
id="modal_submit_new_event_button",
color="green",
),
dmc.Button(
"Close",
color="red",
variant="outline",
id="modal_close_new_event_button",
),
],
pos="right",
),
],
),
],
),
]
)
@app.callback(
Output("modal", "opened"),
Output("modal", "title"),
Output("modal_event_display_context", "children"),
Input("modal-close-button", "n_clicks"),
Input("calendar", "clickedEvent"),
State("modal", "opened"),
)
def open_event_modal(n, clickedEvent, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and clickedEvent is not None:
event_title = clickedEvent["title"]
event_context = clickedEvent["extendedProps"]["context"]
return (
True,
event_title,
html.Div(
dash_quill.Quill(
id="input3",
value=f"{event_context}",
modules={
"toolbar": False,
"clipboard": {
"matchVisual": False,
},
},
),
style={"width": "100%", "overflowY": "auto"},
),
)
elif button_id == "modal-close-button" and n is not None:
return False, dash.no_update, dash.no_update
return opened, dash.no_update
@app.callback(
Output("add_modal", "opened"),
Output("start_date", "value"),
Output("end_date", "value"),
Output("start_time", "value"),
Output("end_time", "value"),
Input("calendar", "dateClicked"),
Input("modal_close_new_event_button", "n_clicks"),
State("add_modal", "opened"),
)
def open_add_modal(dateClicked, close_clicks, opened):
ctx = callback_context
if not ctx.triggered:
raise PreventUpdate
else:
button_id = ctx.triggered[0]["prop_id"].split(".")[0]
if button_id == "calendar" and dateClicked is not None:
try:
start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
except ValueError:
start_time = datetime.now().time()
start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d")
start_date = start_date_obj.strftime("%Y-%m-%d")
end_date = start_date_obj.strftime("%Y-%m-%d")
end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1)
start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S")
return True, start_date, end_date, start_time_str, end_time_str
elif button_id == "modal_close_new_event_button" and close_clicks is not None:
return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update
return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update
@app.callback(
Output("calendar", "events"),
Output("add_modal", "opened", allow_duplicate=True),
Output("event_name_input", "value"),
Output("event_color_select", "value"),
Output("rich_text_input", "value"),
Input("modal_submit_new_event_button", "n_clicks"),
State("start_date", "value"),
State("start_time", "value"),
State("end_date", "value"),
State("end_time", "value"),
State("event_name_input", "value"),
State("event_color_select", "value"),
State("rich_text_output", "children"),
State("calendar", "events"),
)
def add_new_event(
n,
start_date,
start_time,
end_date,
end_time,
event_name,
event_color,
event_context,
current_events,
):
if n is None:
raise PreventUpdate
start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S")
end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S")
start_time_str = start_time_obj.strftime("%H:%M:%S")
end_time_str = end_time_obj.strftime("%H:%M:%S")
start_date = f"{start_date}T{start_time_str}"
end_date = f"{end_date}T{end_time_str}"
new_event = {
"title": event_name,
"start": start_date,
"end": end_date,
"className": event_color,
"context": event_context,
}
return current_events + [new_event], False, "", "bg-gradient-primary", ""
@app.callback(
Output("rich_text_output", "children"),
[Input("rich_text_input", "value")],
[State("rich_text_input", "charCount")],
)
def display_output(value, charCount):
return value
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -0,0 +1,83 @@
"""
Collapsible navbar on both desktop and mobile
"""
import dash_mantine_components as dmc
from dash import Dash, Input, Output, State, callback
from dash_iconify import DashIconify
app = Dash(external_stylesheets=dmc.styles.ALL)
logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302"
def get_icon(icon):
return DashIconify(icon=icon, height=16)
layout = dmc.AppShell(
[
dmc.AppShellHeader(
dmc.Group(
[
dmc.Burger(
id="mobile-burger",
size="sm",
hiddenFrom="sm",
opened=False,
),
dmc.Burger(
id="desktop-burger",
size="sm",
visibleFrom="sm",
opened=True,
),
dmc.Image(src=logo, h=40),
dmc.Title("Demo App", c="blue"),
],
h="100%",
px="md",
)
),
dmc.AppShellNavbar(
id="navbar",
children=[
"Navbar",
dmc.NavLink(
label="With icon",
leftSection=get_icon(icon="bi:house-door-fill"),
),
],
p="md",
),
dmc.AppShellMain("Main"),
],
header={"height": 60},
navbar={
"width": 300,
"breakpoint": "sm",
"collapsed": {"mobile": True, "desktop": False},
},
padding="md",
id="appshell",
)
app.layout = dmc.MantineProvider(layout)
@callback(
Output("appshell", "navbar"),
Input("mobile-burger", "opened"),
Input("desktop-burger", "opened"),
State("appshell", "navbar"),
)
def toggle_navbar(mobile_opened, desktop_opened, navbar):
navbar["collapsed"] = {
"mobile": not mobile_opened,
"desktop": not desktop_opened,
}
return navbar
if __name__ == "__main__":
app.run(host='0.0.0.0', debug=True, port=8050)

View File

@@ -0,0 +1,892 @@
import dash
from dash import html, Input, Output, State, callback, dcc
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from datetime import datetime, date, timedelta
import re
import base64
import dash_quill
app = dash.Dash(__name__, suppress_callback_exceptions=True) # Wichtig für dynamische IDs
# Deutsche Lokalisierung für Mantine
german_dates_provider_props = {
"settings": {
"locale": "de",
"firstDayOfWeek": 1,
"weekendDays": [0, 6],
"labels": {
"ok": "OK",
"cancel": "Abbrechen",
"clear": "Löschen",
"monthPickerControl": "Monat auswählen",
"yearPickerControl": "Jahr auswählen",
"nextMonth": "Nächster Monat",
"previousMonth": "Vorheriger Monat",
"nextYear": "Nächstes Jahr",
"previousYear": "Vorheriges Jahr",
"nextDecade": "Nächstes Jahrzehnt",
"previousDecade": "Vorheriges Jahrzehnt"
}
}
}
# Wochentage für Wiederholung
weekday_options = [
{"value": "0", "label": "Montag"},
{"value": "1", "label": "Dienstag"},
{"value": "2", "label": "Mittwoch"},
{"value": "3", "label": "Donnerstag"},
{"value": "4", "label": "Freitag"},
{"value": "5", "label": "Samstag"},
{"value": "6", "label": "Sonntag"}
]
# Deutsche Feiertage (vereinfacht, ohne Berechnung von Ostern etc.)
GERMAN_HOLIDAYS_2025 = [
date(2025, 1, 1), # Neujahr
date(2025, 1, 6), # Heilige Drei Könige
date(2025, 4, 18), # Karfreitag (Beispiel - muss berechnet werden)
date(2025, 4, 21), # Ostermontag (Beispiel - muss berechnet werden)
date(2025, 5, 1), # Tag der Arbeit
date(2025, 5, 29), # Christi Himmelfahrt (Beispiel - muss berechnet werden)
date(2025, 6, 9), # Pfingstmontag (Beispiel - muss berechnet werden)
date(2025, 10, 3), # Tag der Deutschen Einheit
date(2025, 12, 25), # 1. Weihnachtstag
date(2025, 12, 26), # 2. Weihnachtstag
]
# Schulferien (Beispiel für NRW 2025)
SCHOOL_HOLIDAYS_2025 = [
# Weihnachtsferien
(date(2024, 12, 23), date(2025, 1, 6)),
# Osterferien
(date(2025, 4, 14), date(2025, 4, 26)),
# Sommerferien
(date(2025, 7, 14), date(2025, 8, 26)),
# Herbstferien
(date(2025, 10, 14), date(2025, 10, 25)),
]
def is_holiday_or_vacation(check_date):
"""Prüft, ob ein Datum ein Feiertag oder in den Ferien liegt"""
# Feiertage prüfen
if check_date in GERMAN_HOLIDAYS_2025:
return True
# Schulferien prüfen
for start_vacation, end_vacation in SCHOOL_HOLIDAYS_2025:
if start_vacation <= check_date <= end_vacation:
return True
return False
# Zeitraster für 30-Minuten-Intervalle generieren
def generate_time_options():
options = []
# Basis: 30-Minuten-Raster
for h in range(6, 24): # Bis 23:30
for m in [0, 30]:
options.append({"value": f"{h:02d}:{m:02d}", "label": f"{h:02d}:{m:02d}"})
return options
time_options = generate_time_options()
# Hilfsfunktion für Input-Felder mit Tooltip - volle Breite
def create_input_with_tooltip_full(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon über die volle Breite"""
return dmc.Stack([
dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
], gap=0)
# Hilfsfunktion für Input-Felder mit Tooltip - für Zeit-Grid
def create_input_with_tooltip_time(component, tooltip_text):
"""Erstellt ein Input-Feld mit Tooltip-Icon für Zeit-Eingaben"""
return dmc.Group([
component,
dmc.Tooltip(
children=[
dmc.ActionIcon(
DashIconify(icon="mdi:help-circle-outline", width=16),
variant="subtle",
color="gray",
size="sm"
)
],
label=tooltip_text,
position="top",
multiline=True,
w=300
)
], gap="xs", align="flex-end")
app.layout = dmc.MantineProvider([
dmc.DatesProvider(**german_dates_provider_props, children=[
dmc.Container([
dmc.Title("Erweiterte Terminverwaltung", order=1, className="mb-4"),
dmc.Grid([
dmc.GridCol([
dmc.Paper([
dmc.Title("Termindetails", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.TextInput(
label="Titel",
placeholder="Terminbezeichnung eingeben",
leftSection=DashIconify(icon="mdi:calendar-text"),
id="title-input",
required=True,
style={"flex": 1}
),
"Geben Sie eine aussagekräftige Bezeichnung für den Termin ein"
),
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Startdatum",
value=datetime.now().date(),
id="start-date-input",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Datum auswählen",
leftSection=DashIconify(icon="mdi:calendar"),
clearable=False,
style={"flex": 1}
),
"Wählen Sie das Datum für den Termin aus dem Kalender"
),
# Zeitbereich - nebeneinander
dmc.Grid([
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Startzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-start",
value="09:00",
required=True,
style={"flex": 1}
),
"Eingabe: 10:25, 10.25, 1025 oder 10 (wird automatisch formatiert)"
),
html.Div(id="start-time-feedback")
], gap="xs")
], span=6),
dmc.GridCol([
dmc.Stack([
create_input_with_tooltip_time(
dmc.Select(
label="Endzeit",
placeholder="Zeit auswählen",
data=time_options,
searchable=True,
clearable=True,
id="time-end",
required=True,
style={"flex": 1}
),
"Endzeit muss nach der Startzeit am selben Tag liegen. Termine können nicht über Mitternacht hinausgehen."
),
html.Div(id="end-time-feedback")
], gap="xs")
], span=6)
]),
create_input_with_tooltip_full(
dmc.Select(
label="Termintyp",
placeholder="Typ auswählen",
data=[
{"value": "presentation", "label": "Präsentation"},
{"value": "website", "label": "Website"},
{"value": "video", "label": "Video"},
{"value": "message", "label": "Nachricht"},
{"value": "other", "label": "Sonstiges"}
],
id="type-input",
required=True,
style={"flex": 1}
),
"Wählen Sie den Typ des Termins für bessere Kategorisierung"
),
# Dynamische typ-spezifische Felder
html.Div(id="type-specific-fields"),
create_input_with_tooltip_full(
dmc.Textarea(
label="Beschreibung",
placeholder="Zusätzliche Informationen...",
minRows=3,
autosize=True,
id="description-input",
style={"flex": 1}
),
"Optionale Beschreibung mit weiteren Details zum Termin"
)
], gap="md")
], p="md", shadow="sm")
], span=6),
dmc.GridCol([
dmc.Paper([
dmc.Title("Wiederholung", order=3, className="mb-3"),
dmc.Stack([
create_input_with_tooltip_full(
dmc.Checkbox(
label="Wiederholender Termin",
id="repeat-checkbox",
description="Aktivieren für wöchentliche Wiederholung"
),
"Aktivieren Sie diese Option, um den Termin an mehreren Wochentagen zu wiederholen"
),
html.Div(id="repeat-options", children=[
create_input_with_tooltip_full(
dmc.MultiSelect(
label="Wochentage",
placeholder="Wochentage auswählen",
data=weekday_options,
id="weekdays-select",
description="An welchen Wochentagen soll der Termin stattfinden?",
disabled=True,
style={"flex": 1}
),
"Wählen Sie mindestens einen Wochentag für die Wiederholung aus"
),
dmc.Space(h="md"), # Abstand zwischen DatePicker und Ferientage
create_input_with_tooltip_full(
dmc.DatePickerInput(
label="Wiederholung bis",
id="repeat-until-date",
firstDayOfWeek=1,
weekendDays=[0, 6],
valueFormat="DD.MM.YYYY",
placeholder="Enddatum auswählen",
leftSection=DashIconify(icon="mdi:calendar-end"),
description="Letzter Tag der Wiederholung",
disabled=True,
clearable=True,
style={"flex": 1}
),
"Datum bis wann die Wiederholung stattfinden soll. Muss nach dem Startdatum liegen."
),
dmc.Space(h="lg"), # Größerer Abstand vor Ferientage
create_input_with_tooltip_full(
dmc.Checkbox(
label="Ferientage berücksichtigen",
id="skip-holidays-checkbox",
description="Termine an Feiertagen und in Schulferien auslassen",
disabled=True
),
"Aktivieren Sie diese Option, um Termine an deutschen Feiertagen und in Schulferien automatisch zu überspringen"
)
])
], gap="md")
], p="md", shadow="sm"),
dmc.Paper([
dmc.Title("Aktionen", order=3, className="mb-3"),
dmc.Stack([
dmc.Button(
"Termin(e) speichern",
color="green",
leftSection=DashIconify(icon="mdi:content-save"),
id="btn-save",
size="lg",
fullWidth=True
),
dmc.Button(
"Zurücksetzen",
color="gray",
variant="outline",
leftSection=DashIconify(icon="mdi:refresh"),
id="btn-reset",
fullWidth=True
),
html.Div(id="save-feedback", className="mt-3")
], gap="md")
], p="md", shadow="sm", className="mt-3")
], span=6)
]),
# Vorschau-Bereich
dmc.Paper([
dmc.Title("Vorschau", order=3, className="mb-3"),
html.Div(id="preview-area")
], p="md", shadow="sm", className="mt-4")
], size="lg")
])
])
# Zeit-Validierungsfunktion
def validate_and_format_time(time_str):
"""Validiert und formatiert Zeitangaben"""
if not time_str:
return None, "Keine Zeit angegeben"
# Bereits korrektes Format
if re.match(r'^\d{2}:\d{2}$', time_str):
try:
h, m = map(int, time_str.split(':'))
if 0 <= h <= 23 and 0 <= m <= 59:
return time_str, "Gültige Zeit"
except:
pass
# Verschiedene Eingabeformate versuchen
patterns = [
(r'^(\d{1,2}):(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})\.(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})(\d{2})$', lambda m: (int(m.group(1)), int(m.group(2)))),
(r'^(\d{1,2})$', lambda m: (int(m.group(1)), 0)),
]
for pattern, extractor in patterns:
match = re.match(pattern, time_str.strip())
if match:
try:
hours, minutes = extractor(match)
if 0 <= hours <= 23 and 0 <= minutes <= 59:
return f"{hours:02d}:{minutes:02d}", "Automatisch formatiert"
except:
continue
return None, "Ungültiges Zeitformat"
# Typ-spezifische Felder anzeigen
@callback(
Output('type-specific-fields', 'children'),
Input('type-input', 'value'),
prevent_initial_call=True
)
def show_type_specific_fields(event_type):
if not event_type:
return html.Div()
if event_type == "presentation":
return dmc.Stack([
dmc.Divider(label="Präsentations-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='presentation-upload',
children=dmc.Button(
"Datei hochladen",
leftSection=DashIconify(icon="mdi:upload"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Präsentationslink",
placeholder="https://...",
leftSection=DashIconify(icon="mdi:link"),
id="presentation-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
html.Div(id="presentation-upload-status")
], gap="sm")
elif event_type == "video":
return dmc.Stack([
dmc.Divider(label="Video-Details", labelPosition="center"),
dmc.Group([
dcc.Upload(
id='video-upload',
children=dmc.Button(
"Video hochladen",
leftSection=DashIconify(icon="mdi:video-plus"),
variant="outline"
),
style={'width': 'auto'}
),
dmc.TextInput(
label="Videolink",
placeholder="https://youtube.com/...",
leftSection=DashIconify(icon="mdi:youtube"),
id="video-link",
style={"minWidth": 0, "flex": 1, "marginBottom": 0}
)
], grow=True, align="flex-end", style={"marginBottom": 10}),
dmc.Group([
dmc.Checkbox(
label="Endlos wiederholen",
id="video-endless",
checked=True,
style={"marginRight": 20}
),
dmc.NumberInput(
label="Wiederholungen",
id="video-repeats",
value=1,
min=1,
max=99,
step=1,
disabled=True,
style={"width": 150}
),
dmc.Slider(
label="Lautstärke",
id="video-volume",
value=70,
min=0,
max=100,
step=5,
marks=[
{"value": 0, "label": "0%"},
{"value": 50, "label": "50%"},
{"value": 100, "label": "100%"}
],
style={"flex": 1, "marginLeft": 20}
)
], grow=True, align="flex-end"),
html.Div(id="video-upload-status")
], gap="sm")
elif event_type == "website":
return dmc.Stack([
dmc.Divider(label="Website-Details", labelPosition="center"),
dmc.TextInput(
label="Website-URL",
placeholder="https://example.com",
leftSection=DashIconify(icon="mdi:web"),
id="website-url",
required=True,
style={"flex": 1}
)
# Anzeigedauer entfernt!
], gap="sm")
elif event_type == "message":
return dmc.Stack([
dmc.Divider(label="Nachrichten-Details", labelPosition="center"),
dash_quill.Quill(
id="message-content",
value="",
# theme="snow",
# style={"height": "150px", "marginBottom": 10}
),
dmc.Group([
dmc.Select(
label="Schriftgröße",
data=[
{"value": "small", "label": "Klein"},
{"value": "medium", "label": "Normal"},
{"value": "large", "label": "Groß"},
{"value": "xlarge", "label": "Sehr groß"}
],
id="message-font-size",
value="medium",
style={"flex": 1}
),
dmc.ColorPicker(
id="message-color",
value="#000000",
format="hex",
swatches=[
"#000000", "#ffffff", "#ff0000", "#00ff00",
"#0000ff", "#ffff00", "#ff00ff", "#00ffff"
]
)
], grow=True, align="flex-end")
], gap="sm")
return html.Div()
# Callback zum Aktivieren/Deaktivieren des Wiederholungsfelds bei Video
@callback(
Output("video-repeats", "disabled"),
Input("video-endless", "checked"),
prevent_initial_call=True
)
def toggle_video_repeats(endless_checked):
return endless_checked
# Upload-Status für Präsentation
@callback(
Output('presentation-upload-status', 'children'),
Input('presentation-upload', 'contents'),
State('presentation-upload', 'filename'),
prevent_initial_call=True
)
def update_presentation_upload_status(contents, filename):
"""Zeigt Status des Präsentations-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Datei '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Upload-Status für Video
@callback(
Output('video-upload-status', 'children'),
Input('video-upload', 'contents'),
State('video-upload', 'filename'),
prevent_initial_call=True
)
def update_video_upload_status(contents, filename):
"""Zeigt Status des Video-Uploads"""
if contents is not None and filename is not None:
return dmc.Alert(
f"✓ Video '{filename}' erfolgreich hochgeladen",
color="green",
className="mt-2"
)
return html.Div()
# Wiederholungsoptionen aktivieren/deaktivieren
@callback(
[
Output('weekdays-select', 'disabled'),
Output('repeat-until-date', 'disabled'),
Output('skip-holidays-checkbox', 'disabled'),
Output('weekdays-select', 'value'),
Output('repeat-until-date', 'value'),
Output('skip-holidays-checkbox', 'checked')
],
Input('repeat-checkbox', 'checked'),
prevent_initial_call=True
)
def toggle_repeat_options(is_repeat):
"""Aktiviert/deaktiviert Wiederholungsoptionen"""
if is_repeat:
# Aktiviert und setzt Standardwerte
next_month = datetime.now().date() + timedelta(weeks=4) # 4 Wochen später
return (
False, # weekdays-select enabled
False, # repeat-until-date enabled
False, # skip-holidays-checkbox enabled
None, # weekdays value
next_month, # repeat-until-date value
False # skip-holidays-checkbox checked
)
else:
# Deaktiviert und löscht Werte
return (
True, # weekdays-select disabled
True, # repeat-until-date disabled
True, # skip-holidays-checkbox disabled
None, # weekdays value
None, # repeat-until-date value
False # skip-holidays-checkbox checked
)
# Dynamische Zeitoptionen für Startzeit
@callback(
[
Output('time-start', 'data'),
Output('start-time-feedback', 'children')
],
Input('time-start', 'searchValue'),
prevent_initial_call=True
)
def update_start_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Dynamische Zeitoptionen für Endzeit
@callback(
[
Output('time-end', 'data'),
Output('end-time-feedback', 'children')
],
Input('time-end', 'searchValue'),
prevent_initial_call=True
)
def update_end_time_options(search_value):
"""Erweitert Zeitoptionen basierend auf Sucheingabe"""
base_options = time_options.copy()
feedback = None
if search_value:
validated_time, status = validate_and_format_time(search_value)
if validated_time:
if not any(opt["value"] == validated_time for opt in base_options):
base_options.insert(0, {
"value": validated_time,
"label": f"{validated_time} (Ihre Eingabe)"
})
feedback = dmc.Text(f"{status}: {validated_time}", size="xs", c="green")
else:
feedback = dmc.Text(f"{status}", size="xs", c="red")
return base_options, feedback
# Automatische Endzeit-Berechnung mit Validation
@callback(
Output('time-end', 'value'),
[
Input('time-start', 'value'),
Input('btn-reset', 'n_clicks')
],
State('time-end', 'value'),
prevent_initial_call=True
)
def handle_end_time(start_time, reset_clicks, current_end_time):
"""Behandelt automatische Endzeit-Berechnung und Reset"""
ctx = dash.callback_context
if not ctx.triggered:
return dash.no_update
trigger_id = ctx.triggered[0]['prop_id'].split('.')[0]
if trigger_id == 'btn-reset' and reset_clicks:
return None
if trigger_id == 'time-start' and start_time:
if current_end_time:
return dash.no_update
try:
validated_start, _ = validate_and_format_time(start_time)
if validated_start:
start_dt = datetime.strptime(validated_start, "%H:%M")
# 1.5 Stunden später, aber maximal 23:59
end_dt = start_dt + timedelta(hours=1, minutes=30)
if end_dt.hour >= 24:
end_dt = end_dt.replace(hour=23, minute=59)
return end_dt.strftime("%H:%M")
except:
pass
return dash.no_update
# Hilfsfunktion für sichere Werte-Abfrage
def get_safe_value(ctx, prop_id):
"""Gibt den Wert einer Property zurück oder None, wenn sie nicht existiert"""
try:
return ctx.states.get(prop_id, {}).get('value')
except:
return None
# Vorschau-Bereich mit Ferientags-Berücksichtigung und typ-spezifischen Daten
@callback(
Output('preview-area', 'children'),
[
Input('title-input', 'value'),
Input('start-date-input', 'value'),
Input('time-start', 'value'),
Input('time-end', 'value'),
Input('type-input', 'value'),
Input('description-input', 'value'),
Input('repeat-checkbox', 'checked'),
Input('weekdays-select', 'value'),
Input('repeat-until-date', 'value'),
Input('skip-holidays-checkbox', 'checked')
],
prevent_initial_call=True
)
def update_preview(title, start_date, start_time, end_time, event_type, description,
is_repeat, weekdays, repeat_until, skip_holidays):
"""Zeigt Live-Vorschau der Termine mit typ-spezifischen Daten"""
validated_start, start_status = validate_and_format_time(start_time)
validated_end, end_status = validate_and_format_time(end_time)
# Zeitvalidierung
time_valid = True
time_error = ""
if validated_start and validated_end:
start_dt = datetime.strptime(validated_start, "%H:%M")
end_dt = datetime.strptime(validated_end, "%H:%M")
if end_dt <= start_dt:
time_valid = False
time_error = "Endzeit muss nach Startzeit liegen"
elif end_dt.hour < start_dt.hour: # Über Mitternacht
time_valid = False
time_error = "Termine dürfen nicht über Mitternacht hinausgehen"
# Typ-spezifische Details mit sicherer Abfrage
type_details = []
if event_type == "presentation":
# Hier würden wir normalerweise die Werte abfragen, aber da sie dynamisch sind,
# zeigen wir nur den Typ an
type_details.append(dmc.Text("🎯 Präsentationsdetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "video":
type_details.append(dmc.Text("📹 Videodetails werden nach Auswahl angezeigt", size="sm"))
elif event_type == "website":
type_details.append(dmc.Text("🌐 Website-Details werden nach Auswahl angezeigt", size="sm"))
elif event_type == "message":
type_details.append(dmc.Text("💬 Nachrichten-Details werden nach Auswahl angezeigt", size="sm"))
# Wiederholungslogik mit Ferientags-Berücksichtigung
if is_repeat and weekdays and start_date and repeat_until and time_valid:
weekday_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
selected_days = [weekday_names[int(day)] for day in weekdays]
# Termine berechnen
termine_count = 0
skipped_holidays = 0
# Sicherstellen, dass start_date ein date-Objekt ist
if isinstance(start_date, str):
try:
current_date = datetime.strptime(start_date, "%Y-%m-%d").date()
except:
current_date = datetime.now().date()
else:
current_date = start_date
# Sicherstellen, dass repeat_until ein date-Objekt ist
if isinstance(repeat_until, str):
try:
end_date = datetime.strptime(repeat_until, "%Y-%m-%d").date()
except:
end_date = current_date + timedelta(weeks=4)
else:
end_date = repeat_until
# Kopie für Iteration erstellen
iter_date = current_date
while iter_date <= end_date:
if str(iter_date.weekday()) in weekdays:
if skip_holidays and is_holiday_or_vacation(iter_date):
skipped_holidays += 1
else:
termine_count += 1
iter_date += timedelta(days=1)
holiday_info = []
if skip_holidays:
holiday_info = [
dmc.Text(f"🚫 Übersprungene Ferientage: {skipped_holidays}", size="sm", c="orange"),
dmc.Text(f"📅 Tatsächliche Termine: {termine_count}", size="sm", fw=500)
]
repeat_info = dmc.Stack([
dmc.Text(f"📅 Wiederholung: {', '.join(selected_days)}", size="sm"),
dmc.Text(f"📆 Zeitraum: {current_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}", size="sm"),
dmc.Text(f"🔢 Geplante Termine: {termine_count + skipped_holidays if skip_holidays else termine_count}", size="sm"),
*holiday_info
])
else:
repeat_info = dmc.Text("📅 Einzeltermin", size="sm")
# Datum formatieren
date_str = start_date.strftime('%d.%m.%Y') if isinstance(start_date, date) else (start_date or "Nicht gesetzt")
return dmc.Stack([
dmc.Title(title or "Unbenannter Termin", order=4),
dmc.Text(f"📅 Datum: {date_str}", size="sm"),
dmc.Text(f"🕐 Zeit: {validated_start or 'Nicht gesetzt'} - {validated_end or 'Nicht gesetzt'}", size="sm"),
dmc.Text(f"📋 Typ: {event_type or 'Nicht gesetzt'}", size="sm"),
# Typ-spezifische Details
*type_details,
dmc.Text(f"📝 Beschreibung: {description[:100] + '...' if description and len(description) > 100 else description or 'Keine'}", size="sm"),
dmc.Divider(className="my-2"),
repeat_info,
dmc.Divider(className="my-2"),
dmc.Stack([
dmc.Text("Validierung:", fw=500, size="xs"),
dmc.Text(f"Start: {start_status}", size="xs", c="green" if validated_start else "red"),
dmc.Text(f"Ende: {end_status}", size="xs", c="green" if validated_end else "red"),
dmc.Text(f"Zeitbereich: {'✓ Gültig' if time_valid else f'{time_error}'}",
size="xs", c="green" if time_valid else "red")
], gap="xs")
])
# Reset-Funktion erweitert
@callback(
[
Output('title-input', 'value'),
Output('start-date-input', 'value'),
Output('time-start', 'value'),
Output('type-input', 'value'),
Output('description-input', 'value'),
Output('repeat-checkbox', 'checked'),
Output('weekdays-select', 'value', allow_duplicate=True),
Output('repeat-until-date', 'value', allow_duplicate=True),
Output('skip-holidays-checkbox', 'checked', allow_duplicate=True)
],
Input('btn-reset', 'n_clicks'),
prevent_initial_call=True
)
def reset_form(n_clicks):
"""Setzt das komplette Formular zurück"""
if n_clicks:
return "", datetime.now().date(), "09:00", None, "", False, None, None, False
return dash.no_update
# Speichern-Funktion (vereinfacht für Demo)
@callback(
Output('save-feedback', 'children'),
Input('btn-save', 'n_clicks'),
prevent_initial_call=True
)
def save_appointments_demo(n_clicks):
"""Demo-Speicherfunktion"""
if not n_clicks:
return dash.no_update
return dmc.Alert(
"Demo: Termine würden hier gespeichert werden",
color="blue",
title="Speichern (Demo-Modus)"
)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=8051)

View File

@@ -0,0 +1,63 @@
# dashboard/pages/appointments.py
from dash import html, dcc
import dash
from dash_using_fullcalendar import DashUsingFullcalendar
import dash_bootstrap_components as dbc
from dashboard.components.appointment_modal import get_appointment_modal
dash.register_page(__name__, path="/appointments", name="Termine")
layout = dbc.Container([
dbc.Row([
dbc.Col(html.H2("Dash FullCalendar"))
]),
# Button zum Öffnen der Modalbox
dbc.Row([
dbc.Col(
dbc.Button(
"Neuen Termin anlegen",
id="open-appointment-modal-btn",
color="primary",
className="mb-3"
)
)
]),
dbc.Row([
dbc.Col(
DashUsingFullcalendar(
id='calendar',
events=[],
initialView="timeGridWeek",
headerToolbar={
"left": "prev,next today",
"center": "title",
# "right": "dayGridMonth,timeGridWeek,timeGridDay"
},
height=600,
locale="de",
slotDuration="00:30:00",
slotMinTime="00:00:00",
slotMaxTime="24:00:00",
scrollTime="07:00:00",
weekends=True,
allDaySlot=False,
firstDay=1,
# themeSystem kann auf "bootstrap5" gesetzt werden, wenn das Plugin eingebunden ist
# themeSystem="bootstrap5"
)
)
]),
dbc.Row([
dbc.Col(html.Div(id='output'))
]),
dbc.Row([
dbc.Col(html.Div(id='event-output'))
]),
dbc.Row([
dbc.Col(html.Div(id='select-output'))
]),
dbc.Row([
dbc.Col(html.Div(id='modal-output', children=get_appointment_modal()))
])
], fluid=True)

View File

@@ -0,0 +1,12 @@
# dashboard/pages/clients.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/clients", name="Bildschirme")
layout = html.Div(
className="clients-page",
children=[
html.H3("Bildschirme"),
]
)

View File

@@ -0,0 +1,16 @@
# dashboard/pages/login.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/login", name="Login")
layout = html.Div(
className="login-page",
children=[
html.H2("Bitte einloggen"),
dcc.Input(id="input-user", type="text", placeholder="Benutzername"),
dcc.Input(id="input-pass", type="password", placeholder="Passwort"),
html.Button("Einloggen", id="btn-login"),
html.Div(id="login-feedback", className="text-danger")
]
)

View File

@@ -0,0 +1,13 @@
# dashboard/pages/overview.py
from dash import html, dcc
import dash
dash.register_page(__name__, path="/overview", name="Übersicht")
layout = html.Div(
className="overview-page",
children=[
dcc.Interval(id="interval-update", interval=10_000, n_intervals=0),
html.Div(id="clients-cards-container")
]
)

View File

@@ -0,0 +1,13 @@
# dashboard/pages/settings.py
from dash import html
import dash
dash.register_page(__name__, path="/settings", name="Einstellungen")
layout = html.Div(
className="settings-page",
children=[
html.H3("Allgemeine Einstellungen"),
# Formularfelder / Tabs für globale Optionen
]
)

View File

@@ -0,0 +1,5 @@
import dash
from dash import html
dash.register_page(__name__, path="/test", name="Testseite")
layout = html.Div("Testseite funktioniert!")

View File

@@ -0,0 +1,15 @@
# dashboard/pages/users.py
from dash import html, dash_table, dcc
import dash
dash.register_page(__name__, path="/users", name="Benutzer")
layout = html.Div(
className="users-page",
children=[
html.H3("Benutzerverwaltung"),
html.Button("Neuen Benutzer anlegen", id="btn-new-user"),
html.Div(id="users-table-container"),
html.Div(id="users-feedback")
]
)

View File

@@ -0,0 +1 @@
debugpy

View File

@@ -0,0 +1,13 @@
bcrypt>=4.3.0
dash>=3.0.4
dash-bootstrap-components>=2.0.3
dash_iconify>=0.1.2
dash_mantine_components>=1.2.0
dash-quill>=0.0.4
full-calendar-component>=0.0.4
pandas>=2.2.3
paho-mqtt>=2.1.0
python-dotenv>=1.1.0
PyMySQL>=1.1.1
SQLAlchemy>=2.0.41
./dash_using_fullcalendar-0.1.0.tar.gz

View File

@@ -0,0 +1,193 @@
"""
This app creates a collapsible, responsive sidebar layout with
dash-bootstrap-components and some custom css with media queries.
When the screen is small, the sidebar moved to the top of the page, and the
links get hidden in a collapse element. We use a callback to toggle the
collapse when on a small screen, and the custom CSS to hide the toggle, and
force the collapse to stay open when the screen is large.
dcc.Location is used to track the current location, a callback uses the current
location to render the appropriate page content. The active prop of each
NavLink is set automatically according to the current pathname. To use this
feature you must install dash-bootstrap-components >= 0.11.0.
For more details on building multi-page Dash applications, check out the Dash
documentation: https://dash.plotly.com/urls
"""
import sys
sys.path.append('/workspace')
import dash
import dash_bootstrap_components as dbc
from dash import Input, Output, State, dcc, html, page_container
from dash_iconify import DashIconify
# import callbacks.ui_callbacks
import dashboard.callbacks.appointments_callbacks
import dashboard.callbacks.appointment_modal_callbacks
import dash_mantine_components as dmc
app = dash.Dash(
external_stylesheets=[dbc.themes.BOOTSTRAP],
# these meta_tags ensure content is scaled correctly on different devices
# see: https://www.w3schools.com/css/css_rwd_viewport.asp for more
meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}],
use_pages=True,
suppress_callback_exceptions=True,
)
nav_items = [
{"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"},
{"label": "Termine", "href": "/appointments","icon": "mdi:calendar"},
{"label": "Bildschirme", "href": "/clients", "icon": "mdi:monitor"},
{"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
]
nav_links = []
for item in nav_items:
# Create a NavLink for each item
link_id = {"type": "nav-item", "index": item["label"]}
nav_link = dbc.NavLink(
[
DashIconify(icon=item["icon"], width=24),
html.Span(item["label"], className="ms-2 sidebar-label"),
],
href=item["href"],
active="exact",
className="sidebar-item",
id=link_id,
)
nav_links.append(
html.Div(
children=nav_link,
className="nav-item-container"
)
)
# we use the Row and Col components to construct the sidebar header
# it consists of a title, and a toggle, the latter is hidden on large screens
sidebar_header = dbc.Row(
[
dbc.Col(html.H2("Sidebar", className="display-4")),
dbc.Col(
[
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="navbar-toggle",
),
html.Button(
# use the Bootstrap navbar-toggler classes to style
html.Span(className="navbar-toggler-icon"),
className="navbar-toggler",
# the navbar-toggler classes don't set color
style={
"color": "rgba(0,0,0,.5)",
"border-color": "rgba(0,0,0,.1)",
},
id="sidebar-toggle",
),
],
# the column containing the toggle will be only as wide as the
# toggle, resulting in the toggle being right aligned
width="auto",
# vertically align the toggle in the center
align="center",
),
]
)
sidebar = html.Div(
[
sidebar_header,
# we wrap the horizontal rule and short blurb in a div that can be
# hidden on a small screen
html.Div(
[
html.Hr(),
html.P(
"A responsive sidebar layout with collapsible navigation " "links.",
className="lead",
),
],
id="blurb",
),
# use the Collapse component to animate hiding / revealing links
dbc.Collapse(
dbc.Nav(
nav_links, # <-- Korrigiert: keine zusätzliche Liste
vertical=True,
pills=True,
),
id="collapse",
),
],
id="sidebar",
)
content = dmc.MantineProvider([
html.Div(
html.Div(page_container, className="page-content"),style={"flex": "1", "padding": "20px"}
)
])
app.layout = html.Div([dcc.Location(id="url"), sidebar, content])
# @app.callback(Output("page-content", "children"), [Input("url", "pathname")])
# def render_page_content(pathname):
# if pathname == "/":
# return html.P("This is the content of the home page!")
# elif pathname == "/page-1":
# return html.P("This is the content of page 1. Yay!")
# elif pathname == "/page-2":
# return html.P("Oh cool, this is page 2!")
# # If the user tries to reach a different page, return a 404 message
# return html.Div(
# [
# html.H1("404: Not found", className="text-danger"),
# html.Hr(),
# html.P(f"The pathname {pathname} was not recognised..."),
# ],
# className="p-3 bg-light rounded-3",
# )
@app.callback(
[Output("sidebar", "className"), Output("collapse", "is_open")],
[
Input("sidebar-toggle", "n_clicks"),
Input("navbar-toggle", "n_clicks"),
],
[
State("sidebar", "className"),
State("collapse", "is_open"),
],
)
def toggle_sidebar_and_collapse(sidebar_n, navbar_n, classname, is_open):
ctx = dash.callback_context
if not ctx.triggered:
return classname, is_open
trigger_id = ctx.triggered[0]["prop_id"].split(".")[0]
if trigger_id == "sidebar-toggle":
# Toggle sidebar collapse
if sidebar_n and classname == "":
return "collapsed", is_open
return "", is_open
elif trigger_id == "navbar-toggle":
# Toggle collapse
if navbar_n:
return classname, not is_open
return classname, is_open
return classname, is_open
if __name__ == "__main__":
app.run(port=8888, debug=True)

View File

View File

@@ -0,0 +1,12 @@
# dashboard/utils/auth.py
import bcrypt
def hash_password(plain_text: str) -> str:
return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def check_password(plain_text: str, hashed: str) -> bool:
return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8"))
def get_user_role(username: str) -> str:
# Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase)
pass

View File

@@ -0,0 +1,46 @@
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# .env laden
load_dotenv()
# Datenbank-Zugangsdaten aus .env
DB_USER = os.getenv("DB_USER")
DB_PASSWORD = os.getenv("DB_PASSWORD")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DB_NAME = os.getenv("DB_NAME")
# Pooling Parameter aus .env (optional mit Default-Werten)
POOL_SIZE = int(os.getenv("POOL_SIZE", 10))
MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20))
POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30))
POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800))
# Connection-String zusammenbauen
DATABASE_URL = (
f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
)
# Engine mit Pooling konfigurieren
engine = create_engine(
DATABASE_URL,
pool_size=POOL_SIZE,
max_overflow=MAX_OVERFLOW,
pool_timeout=POOL_TIMEOUT,
pool_recycle=POOL_RECYCLE,
echo=True, # für Debug, später False
)
# Session Factory
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_session():
return SessionLocal()
def execute_query(query):
with engine.connect() as connection:
result = connection.execute(text(query))
return [dict(row) for row in result]

View File

@@ -0,0 +1,124 @@
# dashboard/utils/mqtt_client.py
import os
import threading
import time
from dotenv import load_dotenv
import paho.mqtt.client as mqtt
import random
# 1. Laden der Umgebungsvariablen aus .env
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
# 2. Lese MQTTEinstellungen
MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost")
MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883"))
MQTT_USERNAME = os.getenv("MQTT_USERNAME", None)
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None)
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60"))
base_id = os.getenv("MQTT_CLIENT_ID", "dash")
unique_part = f"{os.getpid()}_{random.randint(1000,9999)}"
MQTT_CLIENT_ID = f"{base_id}-{unique_part}"
# 3. Erstelle eine globale ClientInstanz
client = mqtt.Client(client_id=MQTT_CLIENT_ID)
# Falls Nutzer/Passwort gesetzt sind, authentifizieren
if MQTT_USERNAME and MQTT_PASSWORD:
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
# 4. CallbackStubs (kannst du bei Bedarf anpassen)
def _on_connect(client, userdata, flags, rc):
if rc == 0:
print(f"[mqtt_client.py] Erfolgreich mit MQTTBroker verbunden (Code {rc})")
else:
print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}")
def _on_disconnect(client, userdata, rc):
print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …")
def _on_message(client, userdata, msg):
"""
Diese CallbackFunktion wird aufgerufen, sobald eine Nachricht auf einem
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
füllen oder direkt eine DatenbankFunktion aufrufen.
"""
topic = msg.topic
payload = msg.payload.decode("utf-8", errors="ignore")
print(f"[mqtt_client.py] Nachricht eingegangen Topic: {topic}, Payload: {payload}")
# Beispiel: Wenn du LiveStatusdaten in die Datenbank schreibst,
# könntest du hier utils/db.execute_non_query(...) aufrufen.
# 5. Setze die Callbacks
client.on_connect = _on_connect
client.on_disconnect = _on_disconnect
client.on_message = _on_message
def start_loop():
"""
Startet die EndlosSchleife, in der der Client auf eingehende
MQTTNachrichten hört und automatisch reconnectet.
Muss idealerweise in einem eigenen Thread laufen, damit DashCallbacks
nicht blockieren.
"""
try:
client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE)
client.loop_start()
except Exception as e:
print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTTBroker herstellen: {e}")
def stop_loop():
"""
Stoppt die MQTTLoop und trennt die Verbindung.
"""
try:
client.loop_stop()
client.disconnect()
except Exception as e:
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTTSchleife: {e}")
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
"""
Verschickt eine MQTTNachricht:
- topic: z. B. "clients/{client_id}/control"
- payload: z. B. '{"command":"restart"}'
- qos: 0, 1 oder 2
- retain: True/False
Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False.
"""
try:
result = client.publish(topic, payload, qos=qos, retain=retain)
status = result.rc # 0=Erfolg, sonst Fehler
if status == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Publish: {e}")
return False
def subscribe(topic: str, qos: int = 0) -> bool:
"""
Abonniert ein MQTTTopic, sodass _on_message gerufen wird, sobald Nachrichten
ankommen.
Rückgabe: True bei Erfolg, ansonsten False.
"""
try:
result, mid = client.subscribe(topic, qos=qos)
if result == mqtt.MQTT_ERR_SUCCESS:
return True
else:
print(f"[mqtt_client.py] SubscribeFehler für Topic {topic}, rc={result}")
return False
except Exception as e:
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
return False