initial commit
This commit is contained in:
68
.gitignore
vendored
Normal file
68
.gitignore
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Python-related
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.pdb
|
||||||
|
*.egg-info/
|
||||||
|
*.eggs/
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
.env/
|
||||||
|
|
||||||
|
# Logs and databases
|
||||||
|
*.log
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Docker-related
|
||||||
|
*.pid
|
||||||
|
*.tar
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.override.*.yml
|
||||||
|
docker-compose.override.*.yaml
|
||||||
|
|
||||||
|
# Node.js-related
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Dash and Flask cache
|
||||||
|
*.cache
|
||||||
|
*.pytest_cache/
|
||||||
|
instance/
|
||||||
|
*.mypy_cache/
|
||||||
|
*.hypothesis/
|
||||||
|
*.coverage
|
||||||
|
.coverage.*
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# OS-generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Devcontainer-related
|
||||||
|
.devcontainer/
|
||||||
|
|
||||||
|
received_screenshots/
|
||||||
|
mosquitto/
|
||||||
|
alte/
|
||||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
```dockerfile
|
||||||
|
# Use a stable Python base image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Build arguments for host user mapping
|
||||||
|
ARG USER_ID=1000
|
||||||
|
ARG GROUP_ID=1000
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN groupadd -g ${GROUP_ID} infoscreen_taa \
|
||||||
|
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa
|
||||||
|
|
||||||
|
# Ensure user exists
|
||||||
|
RUN getent passwd infoscreen_taa
|
||||||
|
|
||||||
|
# Install locale dependencies and generate UTF-8 locale
|
||||||
|
RUN apt-get update && apt-get install -y locales \
|
||||||
|
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
|
||||||
|
&& locale-gen
|
||||||
|
|
||||||
|
# Set environment variables for locale
|
||||||
|
ENV LANG=de_DE.UTF-8 \
|
||||||
|
LANGUAGE=de_DE:de \
|
||||||
|
LC_ALL=de_DE.UTF-8
|
||||||
|
|
||||||
|
# Enable Dash debug during development
|
||||||
|
ENV DASH_DEBUG_MODE=True
|
||||||
|
|
||||||
|
# Working directory inside container
|
||||||
|
WORKDIR /app # entspricht mount in devcontainer.json
|
||||||
|
|
||||||
|
# Copy only requirements first for efficient caching
|
||||||
|
COPY server/requirements-dev.txt ./
|
||||||
|
|
||||||
|
# Install dev dependencies under the non-root user
|
||||||
|
USER infoscreen_taa
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install --user -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Switch back to root to copy source files and fix permissions
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Copy the server application code into /app
|
||||||
|
COPY server/ /app
|
||||||
|
RUN chown -R infoscreen_taa:infoscreen_taa /app
|
||||||
|
|
||||||
|
# Create config directory under the non-root user's home
|
||||||
|
RUN mkdir -p /home/infoscreen_taa/.config/Infoscreen-Server \
|
||||||
|
&& chown -R infoscreen_taa:infoscreen_taa /home/infoscreen_taa/.config/Infoscreen-Server
|
||||||
|
|
||||||
|
# Expose development ports
|
||||||
|
EXPOSE 8000 8050
|
||||||
|
|
||||||
|
# Use a long-running process so the container stays alive
|
||||||
|
CMD ["tail", "-f", "/dev/null"]
|
||||||
|
```
|
||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
17
create_init_files.py
Normal file
17
create_init_files.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
folders = [
|
||||||
|
"server",
|
||||||
|
"dashboard",
|
||||||
|
"dashboard/callbacks",
|
||||||
|
"dashboard/utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
path = os.path.join(os.getcwd(), folder, "__init__.py")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
with open(path, "w") as f:
|
||||||
|
pass # Leere Datei anlegen
|
||||||
|
print(f"Angelegt: {path}")
|
||||||
|
else:
|
||||||
|
print(f"Existiert bereits: {path}")
|
||||||
37
dashboard/Dockerfile
Normal file
37
dashboard/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# 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 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# --- Python-Abhängigkeiten kopieren und installieren ---
|
||||||
|
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"]
|
||||||
47
dashboard/Dockerfile.dev
Normal file
47
dashboard/Dockerfile.dev
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 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 requirements.txt ./
|
||||||
|
|
||||||
|
# Installiere Abhängigkeiten
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -r requirements.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
|
||||||
|
|
||||||
|
# Wechsle zum non-root User
|
||||||
|
USER infoscreen_taa
|
||||||
|
|
||||||
|
# Dev-Start: Dash mit Hot-Reload
|
||||||
|
CMD ["tail", "-f", "/dev/null"]
|
||||||
0
dashboard/__init__.py
Normal file
0
dashboard/__init__.py
Normal file
32
dashboard/app.py
Normal file
32
dashboard/app.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# dashboard/app.py
|
||||||
|
|
||||||
|
from dash import Dash, html, dcc, page_container
|
||||||
|
from flask import Flask
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from components.header import Header
|
||||||
|
# from components.sidebar import Sidebar
|
||||||
|
import callbacks.ui_callbacks # wichtig!
|
||||||
|
import dashboard.callbacks.overview_callbacks # <-- Das registriert die Callbacks
|
||||||
|
from config import SECRET_KEY, ENV
|
||||||
|
|
||||||
|
server = Flask(__name__)
|
||||||
|
server.secret_key = SECRET_KEY
|
||||||
|
|
||||||
|
app = Dash(
|
||||||
|
__name__,
|
||||||
|
server=server,
|
||||||
|
use_pages=True,
|
||||||
|
external_stylesheets=[dbc.themes.BOOTSTRAP],
|
||||||
|
suppress_callback_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
app.layout = html.Div([
|
||||||
|
Header(),
|
||||||
|
dcc.Store(id="sidebar-state", data={"collapsed": False}),
|
||||||
|
html.Div(id="sidebar"), # Sidebar wird dynamisch gerendert
|
||||||
|
html.Div(page_container, className="page-content"),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8050, debug=(ENV=="development"))
|
||||||
BIN
dashboard/assets/TAA_Logo.png
Normal file
BIN
dashboard/assets/TAA_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
188
dashboard/assets/custom.css
Normal file
188
dashboard/assets/custom.css
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/* ==========================
|
||||||
|
Allgemeines Layout
|
||||||
|
========================== */
|
||||||
|
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 {
|
||||||
|
margin-left: 220px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: margin-left 0.3s ease;
|
||||||
|
min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
||||||
|
position: fixed;
|
||||||
|
top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dashboard/assets/logo.png
Normal file
BIN
dashboard/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 225 KiB |
0
dashboard/callbacks/__init__.py
Normal file
0
dashboard/callbacks/__init__.py
Normal file
27
dashboard/callbacks/appoinments_callbacks.py
Normal file
27
dashboard/callbacks/appoinments_callbacks.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# dashboard/callbacks/appointments_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("appointments-feedback", "children"),
|
||||||
|
Input("btn-add-appointment", "n_clicks"),
|
||||||
|
State("input-client-id", "value"),
|
||||||
|
State("input-appointment-date", "date"),
|
||||||
|
State("input-appointment-time", "value"),
|
||||||
|
State("input-appointment-desc", "value"),
|
||||||
|
prevent_initial_call=True
|
||||||
|
)
|
||||||
|
def add_appointment(n_clicks, client_id, date, time_str, desc):
|
||||||
|
if "role" not in session:
|
||||||
|
return dcc.Location(href="/login")
|
||||||
|
if n_clicks and n_clicks > 0:
|
||||||
|
datetime_str = f"{date} {time_str}:00"
|
||||||
|
sql = """
|
||||||
|
INSERT INTO appointments (client_id, start_datetime, description)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
"""
|
||||||
|
rc = execute_non_query(sql, (client_id, datetime_str, desc))
|
||||||
|
return "Erstellt." if rc else "Fehler beim Anlegen."
|
||||||
|
return ""
|
||||||
31
dashboard/callbacks/auth_callbacks.py
Normal file
31
dashboard/callbacks/auth_callbacks.py
Normal 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":
|
||||||
|
# Dev‐Bypass: setze immer Admin‐Session und leite weiter
|
||||||
|
session["username"] = "dev_admin"
|
||||||
|
session["role"] = "admin"
|
||||||
|
return dcc.Location(href="/overview", id="redirect-dev"), None
|
||||||
|
|
||||||
|
# Produktions‐Login: 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
|
||||||
104
dashboard/callbacks/overview_callbacks.py
Normal file
104
dashboard/callbacks/overview_callbacks.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# dashboard/callbacks/overview_callbacks.py
|
||||||
|
import sys
|
||||||
|
print(sys.path)
|
||||||
|
import threading
|
||||||
|
import dash
|
||||||
|
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
|
||||||
|
from server.models import Client
|
||||||
|
|
||||||
|
mqtt_thread_started = False
|
||||||
|
SCREENSHOT_DIR = "/workspace/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):
|
||||||
|
prefix = f"client_{client_uuid}_"
|
||||||
|
try:
|
||||||
|
files = [f for f in os.listdir(SCREENSHOT_DIR) if f.startswith(prefix)]
|
||||||
|
if not files:
|
||||||
|
return "/assets/placeholder.png"
|
||||||
|
latest = max(files, key=lambda x: os.path.getmtime(os.path.join(SCREENSHOT_DIR, x)))
|
||||||
|
return f"/received-screenshots/{latest}"
|
||||||
|
except Exception:
|
||||||
|
return "/assets/placeholder.png"
|
||||||
|
|
||||||
|
@dash.callback(
|
||||||
|
Output("clients-cards-container", "children"),
|
||||||
|
Input("interval-update", "n_intervals")
|
||||||
|
)
|
||||||
|
def update_clients(n):
|
||||||
|
# Auto-Login im Development-Modus
|
||||||
|
if "role" not in session:
|
||||||
|
if ENV == "development":
|
||||||
|
session["role"] = "admin"
|
||||||
|
else:
|
||||||
|
return dcc.Location(id="redirect-login", href="/login")
|
||||||
|
|
||||||
|
ensure_mqtt_running()
|
||||||
|
session_db = get_session()
|
||||||
|
clients = session_db.query(Client).all()
|
||||||
|
session_db.close()
|
||||||
|
cards = []
|
||||||
|
for client in clients:
|
||||||
|
uuid = client.uuid
|
||||||
|
screenshot = get_latest_screenshot(uuid)
|
||||||
|
card = dbc.Card(
|
||||||
|
[
|
||||||
|
dbc.CardHeader(client.location or client.hardware_hash),
|
||||||
|
dbc.CardBody([
|
||||||
|
html.Img(
|
||||||
|
src=screenshot,
|
||||||
|
style={"width": "160px", "height": "90px", "object-fit": "cover"},
|
||||||
|
),
|
||||||
|
html.P(f"IP: {client.ip_address or '-'}", className="card-text"),
|
||||||
|
html.P(f"Letzte Aktivität: {client.last_alive or '-'}", 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 ""
|
||||||
20
dashboard/callbacks/settings_callbacks.py
Normal file
20
dashboard/callbacks/settings_callbacks.py
Normal 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 ""
|
||||||
31
dashboard/callbacks/ui_callbacks.py
Normal file
31
dashboard/callbacks/ui_callbacks.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# dashboard/callbacks/ui_callbacks.py
|
||||||
|
|
||||||
|
from dash import Input, Output, State, callback
|
||||||
|
from components.sidebar import Sidebar
|
||||||
|
|
||||||
|
# 1) Toggle-Callback: Umschalten von collapsed = False ↔ True
|
||||||
|
@callback(
|
||||||
|
Output("sidebar-state", "data"),
|
||||||
|
Input("btn-toggle-sidebar", "n_clicks"),
|
||||||
|
State("sidebar-state", "data"),
|
||||||
|
prevent_initial_call=True
|
||||||
|
)
|
||||||
|
def toggle_sidebar(n_clicks, state):
|
||||||
|
# Wenn der Button geklickt wurde, invertiere den collapsed-Wert
|
||||||
|
collapsed = state.get("collapsed", False)
|
||||||
|
return {"collapsed": not collapsed}
|
||||||
|
|
||||||
|
|
||||||
|
# 2) Render-Callback: Zeichnet die Sidebar neu und setzt die CSS-Klasse
|
||||||
|
@callback(
|
||||||
|
[Output("sidebar", "children"), Output("sidebar", "className")],
|
||||||
|
Input("sidebar-state", "data")
|
||||||
|
)
|
||||||
|
def render_sidebar(state):
|
||||||
|
collapsed = state.get("collapsed", False)
|
||||||
|
sidebar_class = "sidebar collapsed" if collapsed else "sidebar"
|
||||||
|
|
||||||
|
# Sidebar() gibt jetzt nur den Inhalt zurück
|
||||||
|
sidebar_content = Sidebar(collapsed=collapsed)
|
||||||
|
|
||||||
|
return sidebar_content, sidebar_class
|
||||||
24
dashboard/callbacks/users_callbacks.py
Normal file
24
dashboard/callbacks/users_callbacks.py
Normal 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 ""
|
||||||
13
dashboard/components/header.py
Normal file
13
dashboard/components/header.py
Normal 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/Profil‐Button
|
||||||
|
]
|
||||||
|
)
|
||||||
70
dashboard/components/sidebar.py
Normal file
70
dashboard/components/sidebar.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# dashboard/components/sidebar.py
|
||||||
|
|
||||||
|
from dash import html
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
|
from dash_iconify import DashIconify
|
||||||
|
from typing import List, Any
|
||||||
|
|
||||||
|
def Sidebar(collapsed: bool = False) -> List[Any]:
|
||||||
|
"""
|
||||||
|
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": "Einstellungen","href": "/settings", "icon": "mdi:cog"},
|
||||||
|
{"label": "Benutzer", "href": "/users", "icon": "mdi:account"},
|
||||||
|
]
|
||||||
|
|
||||||
|
nav_links = []
|
||||||
|
|
||||||
|
for item in nav_items:
|
||||||
|
# Die ID muss in den Callbacks exakt so referenziert werden:
|
||||||
|
link_id = {"type": "nav-item", "index": item["label"]}
|
||||||
|
|
||||||
|
# ✅ NavLink erstellen
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Conditional List Construction - keine append() nötig
|
||||||
|
if collapsed:
|
||||||
|
tooltip = dbc.Tooltip(
|
||||||
|
item["label"],
|
||||||
|
target=link_id,
|
||||||
|
placement="right",
|
||||||
|
id=f"tooltip-{item['label']}",
|
||||||
|
)
|
||||||
|
link_components = [nav_link, tooltip]
|
||||||
|
else:
|
||||||
|
link_components = [nav_link]
|
||||||
|
|
||||||
|
# ✅ Alle Komponenten in einem div-Container
|
||||||
|
nav_links.append(
|
||||||
|
html.Div(
|
||||||
|
children=link_components,
|
||||||
|
className="nav-item-container"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gib nur den Inhalt zurück, nicht das äußere div
|
||||||
|
return [
|
||||||
|
html.Div(
|
||||||
|
className="sidebar-toggle",
|
||||||
|
children=html.Button(
|
||||||
|
DashIconify(icon="mdi:menu", width=28),
|
||||||
|
id="btn-toggle-sidebar",
|
||||||
|
className="toggle-button",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dbc.Nav(nav_links, vertical=True, pills=True, className="sidebar-nav")
|
||||||
|
]
|
||||||
28
dashboard/config.py
Normal file
28
dashboard/config.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# dashboard/config.py
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env aus Root‐Verzeichnis laden
|
||||||
|
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
load_dotenv(os.path.join(base_dir, ".env"))
|
||||||
|
|
||||||
|
# DB‐Einstellungen
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# MQTT‐Einstellungen
|
||||||
|
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")
|
||||||
402
dashboard/fullcalendar_test.py
Normal file
402
dashboard/fullcalendar_test.py
Normal 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)
|
||||||
83
dashboard/infoscreen_server.py
Normal file
83
dashboard/infoscreen_server.py
Normal 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)
|
||||||
16
dashboard/pages/appointments.py
Normal file
16
dashboard/pages/appointments.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# dashboard/pages/appointments.py
|
||||||
|
from dash import html
|
||||||
|
import dash
|
||||||
|
|
||||||
|
dash.register_page(__name__, path="/appointments", name="Termine")
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
className="appointments-page",
|
||||||
|
children=[
|
||||||
|
html.H3("Terminverwaltung"),
|
||||||
|
html.Div(id="calendar-container"),
|
||||||
|
html.Button("Neuen Termin anlegen", id="btn-add-appointment"),
|
||||||
|
html.Div(id="appointments-feedback"),
|
||||||
|
# Hier später das Modal oder das FullCalendar‐Element einfügen
|
||||||
|
]
|
||||||
|
)
|
||||||
16
dashboard/pages/login.py
Normal file
16
dashboard/pages/login.py
Normal 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")
|
||||||
|
]
|
||||||
|
)
|
||||||
13
dashboard/pages/overview.py
Normal file
13
dashboard/pages/overview.py
Normal 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")
|
||||||
|
]
|
||||||
|
)
|
||||||
13
dashboard/pages/settings.py
Normal file
13
dashboard/pages/settings.py
Normal 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
|
||||||
|
]
|
||||||
|
)
|
||||||
15
dashboard/pages/users.py
Normal file
15
dashboard/pages/users.py
Normal 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")
|
||||||
|
]
|
||||||
|
)
|
||||||
10
dashboard/requirements.txt
Normal file
10
dashboard/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
0
dashboard/utils/__init__.py
Normal file
0
dashboard/utils/__init__.py
Normal file
12
dashboard/utils/auth.py
Normal file
12
dashboard/utils/auth.py
Normal 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
|
||||||
46
dashboard/utils/db.py
Normal file
46
dashboard/utils/db.py
Normal 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]
|
||||||
121
dashboard/utils/mqtt_client.py
Normal file
121
dashboard/utils/mqtt_client.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# dashboard/utils/mqtt_client.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
# 1. Laden der Umgebungsvariablen aus .env
|
||||||
|
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||||
|
|
||||||
|
# 2. Lese MQTT‐Einstellungen
|
||||||
|
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"))
|
||||||
|
MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"dash-{int(time.time())}")
|
||||||
|
|
||||||
|
# 3. Erstelle eine globale Client‐Instanz
|
||||||
|
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. Callback‐Stubs (kannst du bei Bedarf anpassen)
|
||||||
|
def _on_connect(client, userdata, flags, rc):
|
||||||
|
if rc == 0:
|
||||||
|
print(f"[mqtt_client.py] Erfolgreich mit MQTT‐Broker 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 Callback‐Funktion wird aufgerufen, sobald eine Nachricht auf einem
|
||||||
|
Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue
|
||||||
|
füllen oder direkt eine Datenbank‐Funktion 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 Live‐Statusdaten 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 Endlos‐Schleife, in der der Client auf eingehende
|
||||||
|
MQTT‐Nachrichten hört und automatisch reconnectet.
|
||||||
|
Muss idealerweise in einem eigenen Thread laufen, damit Dash‐Callbacks
|
||||||
|
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 MQTT‐Broker herstellen: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_loop():
|
||||||
|
"""
|
||||||
|
Stoppt die MQTT‐Loop und trennt die Verbindung.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client.loop_stop()
|
||||||
|
client.disconnect()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[mqtt_client.py] Fehler beim Stoppen der MQTT‐Schleife: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Verschickt eine MQTT‐Nachricht:
|
||||||
|
- 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 MQTT‐Topic, 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] Subscribe‐Fehler für Topic {topic}, rc={result}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[mqtt_client.py] Exception beim Subscribe: {e}")
|
||||||
|
return False
|
||||||
111
docker-compose.yml
Normal file
111
docker-compose.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
networks:
|
||||||
|
infoscreen-net:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.4.7
|
||||||
|
container_name: infoscreen-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
|
MYSQL_USER: ${DB_USER}
|
||||||
|
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
networks:
|
||||||
|
- infoscreen-net
|
||||||
|
# ✅ HINZUGEFÜGT: Healthcheck für MariaDB
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2.0.21
|
||||||
|
container_name: infoscreen-mqtt
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/config:/mosquitto/config
|
||||||
|
- ./mosquitto/data:/mosquitto/data
|
||||||
|
- ./mosquitto/log:/mosquitto/log
|
||||||
|
# ✅ HINZUGEFÜGT: MQTT-Ports explizit exponiert
|
||||||
|
ports:
|
||||||
|
- "1883:1883" # Standard MQTT
|
||||||
|
- "9001:9001" # WebSocket (falls benötigt)
|
||||||
|
networks:
|
||||||
|
- infoscreen-net
|
||||||
|
# ✅ HINZUGEFÜGT: Healthcheck für MQTT
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: infoscreen-api:latest
|
||||||
|
container_name: infoscreen-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# ✅ GEÄNDERT: Erweiterte depends_on mit Healthcheck-Conditions
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
mqtt:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}"
|
||||||
|
FLASK_ENV: ${FLASK_ENV}
|
||||||
|
ENV_FILE: ${ENV_FILE}
|
||||||
|
MQTT_BROKER_URL: ${MQTT_BROKER_URL}
|
||||||
|
MQTT_USER: ${MQTT_USER}
|
||||||
|
MQTT_PASSWORD: ${MQTT_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
networks:
|
||||||
|
- infoscreen-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
dashboard:
|
||||||
|
build:
|
||||||
|
context: ./dashboard
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: infoscreen-dashboard:latest
|
||||||
|
container_name: infoscreen-dashboard
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
# ✅ GEÄNDERT: Healthcheck-Condition für Server
|
||||||
|
server:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
API_URL: ${API_URL}
|
||||||
|
ports:
|
||||||
|
- "8050:8050"
|
||||||
|
networks:
|
||||||
|
- infoscreen-net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8050/_alive"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 45s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
# mosquitto-conf:
|
||||||
|
# mosquitto-data:
|
||||||
|
# # ✅ HINZUGEFÜGT: Log-Volume für MQTT
|
||||||
|
# mosquitto-logs:
|
||||||
0
helpers/__init__.py
Normal file
0
helpers/__init__.py
Normal file
56
helpers/check_folder.py
Normal file
56
helpers/check_folder.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def ensure_folder_exists(folder_path):
|
||||||
|
"""
|
||||||
|
Check if a folder exists and create it if it doesn't.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path (str or Path): Path to the folder to check/create
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if folder was created, False if it already existed
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If folder creation fails due to permissions or other issues
|
||||||
|
"""
|
||||||
|
folder_path = Path(folder_path)
|
||||||
|
|
||||||
|
if folder_path.exists():
|
||||||
|
if folder_path.is_dir():
|
||||||
|
return False # Folder already exists
|
||||||
|
else:
|
||||||
|
raise OSError(f"Path '{folder_path}' exists but is not a directory")
|
||||||
|
|
||||||
|
try:
|
||||||
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return True # Folder was created
|
||||||
|
except OSError as e:
|
||||||
|
raise OSError(f"Failed to create folder '{folder_path}': {e}")
|
||||||
|
|
||||||
|
# Alternative simpler version using os module
|
||||||
|
def ensure_folder_exists_simple(folder_path):
|
||||||
|
"""
|
||||||
|
Simple version using os.makedirs with exist_ok parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_path (str): Path to the folder to check/create
|
||||||
|
"""
|
||||||
|
os.makedirs(folder_path, exist_ok=True)
|
||||||
|
|
||||||
|
# Usage examples
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Example 1: Create a single folder
|
||||||
|
folder_created = ensure_folder_exists("my_new_folder")
|
||||||
|
print(f"Folder created: {folder_created}")
|
||||||
|
|
||||||
|
# Example 2: Create nested folders
|
||||||
|
ensure_folder_exists("data/processed/results")
|
||||||
|
|
||||||
|
# Example 3: Using the simple version
|
||||||
|
ensure_folder_exists_simple("logs/2024")
|
||||||
|
|
||||||
|
# Example 4: Using with absolute path
|
||||||
|
import tempfile
|
||||||
|
temp_dir = tempfile.gettempdir()
|
||||||
|
ensure_folder_exists(os.path.join(temp_dir, "my_app", "cache"))
|
||||||
42
server/Dockerfile
Normal file
42
server/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# server/Dockerfile
|
||||||
|
# Produktions-Dockerfile für die Flask-API mit Gunicorn
|
||||||
|
|
||||||
|
# --- Basisimage ---
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# --- Arbeitsverzeichnis ---
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# --- Systemabhängigkeiten für MariaDB-Client & Locale ---
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libmariadb-dev-compat libmariadb-dev locales \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# --- Locale konfigurieren ---
|
||||||
|
RUN sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \
|
||||||
|
&& locale-gen
|
||||||
|
ENV LANG=de_DE.UTF-8 \
|
||||||
|
LC_ALL=de_DE.UTF-8
|
||||||
|
|
||||||
|
# --- Python-Abhängigkeiten ---
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# --- Applikationscode ---
|
||||||
|
COPY server/ /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 die API exposed ---
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# --- Startbefehl für Gunicorn ---
|
||||||
|
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "wsgi:app"]
|
||||||
46
server/Dockerfile.dev
Normal file
46
server/Dockerfile.dev
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Datei: server/Dockerfile.dev
|
||||||
|
# Entwicklungs-Dockerfile für die API (Flask + SQLAlchemy)
|
||||||
|
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Build args für UID/GID
|
||||||
|
ARG USER_ID=1000
|
||||||
|
ARG GROUP_ID=1000
|
||||||
|
|
||||||
|
# Erstelle non-root User
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends locales curl \
|
||||||
|
&& groupadd -g ${GROUP_ID} infoscreen_taa \
|
||||||
|
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \
|
||||||
|
&& 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/*
|
||||||
|
|
||||||
|
ENV LANG=de_DE.UTF-8 \
|
||||||
|
LANGUAGE=de_DE:de \
|
||||||
|
LC_ALL=de_DE.UTF-8
|
||||||
|
|
||||||
|
# Arbeitsverzeichnis
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Kopiere nur Requirements für schnellen Rebuild
|
||||||
|
COPY requirements.txt ./
|
||||||
|
COPY requirements-dev.txt ./
|
||||||
|
|
||||||
|
# Installiere Python-Abhängigkeiten (Prod + Dev)
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -r requirements.txt \
|
||||||
|
&& pip install --no-cache-dir -r requirements-dev.txt
|
||||||
|
|
||||||
|
# Expose Ports für Flask API
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Setze Env für Dev
|
||||||
|
ENV FLASK_ENV=development
|
||||||
|
ENV ENV_FILE=.env
|
||||||
|
|
||||||
|
# Wechsle zum non-root User
|
||||||
|
USER infoscreen_taa
|
||||||
|
|
||||||
|
# Default Command: Flask Development Server
|
||||||
|
CMD ["flask", "run", "--host=0.0.0.0", "--port=8000"]
|
||||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
67
server/init_database.py
Normal file
67
server/init_database.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, func, text
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
import enum
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# Basis-Klasse für unsere Modelle
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
# Enum zur Definition der möglichen Rollen
|
||||||
|
class UserRole(enum.Enum):
|
||||||
|
user = "user"
|
||||||
|
admin = "admin"
|
||||||
|
superadmin = "superadmin"
|
||||||
|
|
||||||
|
# Definition des User Models (Tabelle)
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
|
password_hash = Column(String(60), nullable=False) # bcrypt erzeugt einen 60-Zeichen-Hash
|
||||||
|
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.current_timestamp())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
# Definition des Client Models (Tabelle)
|
||||||
|
class Client(Base):
|
||||||
|
__tablename__ = 'clients'
|
||||||
|
|
||||||
|
# Die UUID wird vom Client erzeugt und muss daher übermittelt werden (kein Default)
|
||||||
|
uuid = Column(String(36), primary_key=True, nullable=False)
|
||||||
|
# Der Hardware-Hash wird ebenfalls direkt vom Client geliefert
|
||||||
|
hardware_hash = Column(String(64), nullable=False)
|
||||||
|
# Spalte für den Standort, der vom Benutzer bei der Anmeldung eingegeben wird
|
||||||
|
location = Column(String(100), nullable=True)
|
||||||
|
# Spalte für die IP-Adresse, die vom Server beim Kontakt ermittelt wird
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
# Speicherung des Registrierungszeitpunkts, wird automatisch gesetzt
|
||||||
|
registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False)
|
||||||
|
# Speicherung des Zeitpunkts der letzten Rückmeldung (Alive-Signal)
|
||||||
|
last_alive = Column(
|
||||||
|
TIMESTAMP,
|
||||||
|
server_default=func.current_timestamp(),
|
||||||
|
onupdate=func.current_timestamp(),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Zuerst Verbindung zur MariaDB ohne spezifische Datenbank, um die Datenbank anzulegen.
|
||||||
|
admin_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa'
|
||||||
|
admin_engine = create_engine(admin_connection_str, echo=True)
|
||||||
|
|
||||||
|
# Datenbank "infoscreen_by_taa" anlegen, falls sie noch nicht existiert.
|
||||||
|
with admin_engine.connect() as conn:
|
||||||
|
conn.execute(text("CREATE DATABASE IF NOT EXISTS infoscreen_by_taa CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))
|
||||||
|
|
||||||
|
# Verbindung zur spezifischen Datenbank herstellen.
|
||||||
|
db_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa'
|
||||||
|
engine = create_engine(db_connection_str, echo=True)
|
||||||
|
|
||||||
|
# Erstelle die Tabellen 'users' und 'clients'
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
print("Die Tabellen 'users' und 'clients' wurden in der Datenbank 'infoscreen_by_taa' erstellt.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
57
server/init_db.py
Normal file
57
server/init_db.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from models import Base, User, UserRole
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# .env laden
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Konfiguration aus .env
|
||||||
|
DB_USER = os.getenv("DB_USER")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||||
|
DB_HOST = os.getenv("DB_HOST")
|
||||||
|
DB_NAME = os.getenv("DB_NAME")
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME")
|
||||||
|
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
# Erst ohne DB verbinden, um sie ggf. zu erstellen
|
||||||
|
admin_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/"
|
||||||
|
admin_engine = create_engine(admin_conn_str, echo=True)
|
||||||
|
|
||||||
|
with admin_engine.connect() as conn:
|
||||||
|
conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))
|
||||||
|
|
||||||
|
# Jetzt mit Datenbank verbinden
|
||||||
|
db_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
|
||||||
|
engine = create_engine(db_conn_str, echo=True)
|
||||||
|
|
||||||
|
# Tabellen anlegen
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Session erstellen
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
# Prüfen, ob der User bereits existiert
|
||||||
|
existing_user = session.query(User).filter_by(username=DEFAULT_ADMIN_USERNAME).first()
|
||||||
|
|
||||||
|
if not existing_user:
|
||||||
|
# Passwort hashen
|
||||||
|
hashed_pw = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
|
||||||
|
|
||||||
|
# Neuen User anlegen
|
||||||
|
admin_user = User(
|
||||||
|
username=DEFAULT_ADMIN_USERNAME,
|
||||||
|
password_hash=hashed_pw.decode('utf-8'),
|
||||||
|
role=UserRole.admin
|
||||||
|
)
|
||||||
|
session.add(admin_user)
|
||||||
|
session.commit()
|
||||||
|
print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' wurde erfolgreich angelegt.")
|
||||||
|
else:
|
||||||
|
print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' existiert bereits.")
|
||||||
|
|
||||||
|
session.close()
|
||||||
36
server/init_mariadb.py
Normal file
36
server/init_mariadb.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# .env-Datei laden
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Schritt 1: Verbindung zur MariaDB herstellen, OHNE eine bestimmte Datenbank
|
||||||
|
DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_ROOT_PASSWORD')}@{os.getenv('DB_HOST')}:3306"
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, isolation_level="AUTOCOMMIT", echo=True)
|
||||||
|
|
||||||
|
db_name = os.getenv("DB_NAME")
|
||||||
|
db_user = os.getenv("DB_USER")
|
||||||
|
db_password = os.getenv("DB_PASSWORD")
|
||||||
|
|
||||||
|
with engine.connect() as connection:
|
||||||
|
# Datenbank erstellen
|
||||||
|
connection.execute(
|
||||||
|
text(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Benutzer erstellen
|
||||||
|
connection.execute(
|
||||||
|
text(f"CREATE USER IF NOT EXISTS '{db_user}'@'%' IDENTIFIED BY '{db_password}'")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Berechtigungen vergeben
|
||||||
|
connection.execute(
|
||||||
|
text(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'%'")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Berechtigungen neu laden
|
||||||
|
connection.execute(text("FLUSH PRIVILEGES"))
|
||||||
|
|
||||||
|
print("✅ Datenbank und Benutzer erfolgreich erstellt.")
|
||||||
28
server/models.py
Normal file
28
server/models.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Enum, TIMESTAMP, func
|
||||||
|
from sqlalchemy.orm import declarative_base
|
||||||
|
import enum
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
class UserRole(enum.Enum):
|
||||||
|
user = "user"
|
||||||
|
admin = "admin"
|
||||||
|
superadmin = "superadmin"
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username = Column(String(50), unique=True, nullable=False)
|
||||||
|
password_hash = Column(String(60), nullable=False)
|
||||||
|
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.current_timestamp())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
class Client(Base):
|
||||||
|
__tablename__ = 'clients'
|
||||||
|
uuid = Column(String(36), primary_key=True, nullable=False)
|
||||||
|
hardware_hash = Column(String(64), nullable=False)
|
||||||
|
location = Column(String(100), nullable=True)
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False)
|
||||||
|
last_alive = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), nullable=False)
|
||||||
148
server/mqtt_multitopic_receiver.py
Normal file
148
server/mqtt_multitopic_receiver.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import glob
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
# import paho.mqtt.client as mqtt
|
||||||
|
from paho.mqtt import client as mqtt_client
|
||||||
|
import pytz
|
||||||
|
from sqlalchemy import create_engine, func
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from models import Client, Base
|
||||||
|
from helpers.check_folder import ensure_folder_exists
|
||||||
|
|
||||||
|
# Konfiguration
|
||||||
|
MQTT_BROKER = os.getenv("MQTT_BROKER_HOST", "localhost")
|
||||||
|
MQTT_PORT = int(os.getenv("MQTT_BROKER_PORT", 1883))
|
||||||
|
MQTT_USER = os.getenv("MQTT_USER")
|
||||||
|
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD")
|
||||||
|
MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE"))
|
||||||
|
DB_USER = os.getenv("DB_USER")
|
||||||
|
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
||||||
|
DB_HOST = os.getenv("DB_HOST")
|
||||||
|
DB_NAME = os.getenv("DB_NAME")
|
||||||
|
|
||||||
|
topics = [
|
||||||
|
("infoscreen/screenshot", 0),
|
||||||
|
("infoscreen/heartbeat", 0),
|
||||||
|
# ... weitere Topics hier
|
||||||
|
]
|
||||||
|
SAVE_DIR = "received_screenshots"
|
||||||
|
MAX_PER_CLIENT = 20
|
||||||
|
|
||||||
|
# Ordner für empfangene Screenshots anlegen
|
||||||
|
ensure_folder_exists(SAVE_DIR)
|
||||||
|
|
||||||
|
# Datenbank konfigurieren (MariaDB)
|
||||||
|
# Ersetze user, password, host und datenbankname entsprechend.
|
||||||
|
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"
|
||||||
|
engine = create_engine(DB_URL, echo=False)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
# Falls Tabellen noch nicht existieren
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_screenshots(client_id: str):
|
||||||
|
"""Löscht alte Screenshots, wenn mehr als MAX_PER_CLIENT vorhanden sind."""
|
||||||
|
pattern = os.path.join(SAVE_DIR, f"{client_id}_*.jpg")
|
||||||
|
files = sorted(glob.glob(pattern), key=os.path.getmtime)
|
||||||
|
while len(files) > MAX_PER_CLIENT:
|
||||||
|
oldest = files.pop(0)
|
||||||
|
try:
|
||||||
|
os.remove(oldest)
|
||||||
|
print(f"Altes Bild gelöscht: {oldest}")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Fehler beim Löschen von {oldest}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_screenshot(msg):
|
||||||
|
"""Verarbeitet eingehende Screenshot-Payloads."""
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode("utf-8"))
|
||||||
|
client_id = payload.get("client_id", "unknown")
|
||||||
|
ts = datetime.fromtimestamp(
|
||||||
|
payload.get("timestamp", datetime.now().timestamp())
|
||||||
|
)
|
||||||
|
b64_str = payload["screenshot"]
|
||||||
|
img_data = base64.b64decode(b64_str)
|
||||||
|
|
||||||
|
# Dateiname mit Client-ID und Zeitstempel
|
||||||
|
filename = ts.strftime(f"{client_id}_%Y%m%d_%H%M%S.jpg")
|
||||||
|
filepath = os.path.join(SAVE_DIR, filename)
|
||||||
|
|
||||||
|
# Bild speichern
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(img_data)
|
||||||
|
print(f"Bild gespeichert: {filepath}")
|
||||||
|
|
||||||
|
# Alte Screenshots beschneiden
|
||||||
|
prune_old_screenshots(client_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("Fehler beim Verarbeiten der Screenshot-Nachricht:", e)
|
||||||
|
|
||||||
|
def handle_heartbeat(msg):
|
||||||
|
"""Verarbeitet Heartbeat und aktualisiert oder legt Clients an."""
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode("utf-8"))
|
||||||
|
uuid = payload.get("client_id")
|
||||||
|
hardware_hash = payload.get("hardware_hash")
|
||||||
|
ip_address = payload.get("ip_address")
|
||||||
|
# Versuche, Client zu finden
|
||||||
|
client = session.query(Client).filter_by(uuid=uuid).first()
|
||||||
|
if client:
|
||||||
|
# Bekannter Client: last_alive und IP aktualisieren
|
||||||
|
client.ip_address = ip_address
|
||||||
|
client.last_alive = func.now()
|
||||||
|
session.commit()
|
||||||
|
print(f"Heartbeat aktualisiert für Client {uuid}")
|
||||||
|
else:
|
||||||
|
# Neuer Client: Location per input abfragen
|
||||||
|
location = input(f"Neuer Client {uuid} gefunden. Bitte Standort eingeben: ")
|
||||||
|
# ip_address = msg._sock.getpeername()[0]
|
||||||
|
new_client = Client(
|
||||||
|
uuid=uuid,
|
||||||
|
hardware_hash=hardware_hash,
|
||||||
|
location=location,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
session.add(new_client)
|
||||||
|
session.commit()
|
||||||
|
print(f"Neuer Client {uuid} angelegt mit Standort {location}")
|
||||||
|
except Exception as e:
|
||||||
|
print("Fehler beim Verarbeiten der Heartbeat-Nachricht:", e)
|
||||||
|
session.rollback()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping von Topics auf Handler-Funktionen
|
||||||
|
handlers = {
|
||||||
|
"infoscreen/screenshot": handle_screenshot,
|
||||||
|
"infoscreen/heartbeat": handle_heartbeat,
|
||||||
|
# ... weitere Zuordnungen hier
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, rc, properties):
|
||||||
|
print("Verbunden mit Code:", rc)
|
||||||
|
client.subscribe(topics)
|
||||||
|
|
||||||
|
|
||||||
|
def on_message(client, userdata, msg):
|
||||||
|
topic = msg.topic
|
||||||
|
if topic in handlers:
|
||||||
|
handlers[topic](msg)
|
||||||
|
else:
|
||||||
|
print(f"Unbekanntes Topic '{topic}', keine Verarbeitung definiert.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = mqtt_client.Client(callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2)
|
||||||
|
client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=MQTT_KEEPALIVE)
|
||||||
|
client.loop_forever()
|
||||||
57
server/mqtt_receiver.py
Normal file
57
server/mqtt_receiver.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
# MQTT-Konfiguration
|
||||||
|
MQTT_BROKER = "mqtt_broker"
|
||||||
|
MQTT_PORT = 1883
|
||||||
|
MQTT_USER = "infoscreen_taa_user"
|
||||||
|
MQTT_PASSWORD = "infoscreen_taa_MQTT25!"
|
||||||
|
TOPIC_SCREENSHOTS = "infoscreen/screenshot"
|
||||||
|
SAVE_DIR = "received_screenshots"
|
||||||
|
topics = [
|
||||||
|
("infoscreen/screenshot", 0),
|
||||||
|
("infoscreen/heartbeat", 0),
|
||||||
|
# ... weitere Topics hier
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ordner für empfangene Screenshots anlegen
|
||||||
|
os.makedirs(SAVE_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Callback, wenn eine Nachricht eintrifft
|
||||||
|
def on_message(client, userdata, msg):
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode('utf-8'))
|
||||||
|
b64_str = payload["screenshot"]
|
||||||
|
img_data = base64.b64decode(b64_str)
|
||||||
|
|
||||||
|
# Dateiname mit Zeitstempel
|
||||||
|
ts = datetime.fromtimestamp(payload.get("timestamp", datetime.now().timestamp()))
|
||||||
|
filename = ts.strftime("screenshot_%Y%m%d_%H%M%S.jpg")
|
||||||
|
filepath = os.path.join(SAVE_DIR, filename)
|
||||||
|
|
||||||
|
# Bild speichern
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(img_data)
|
||||||
|
print(f"Bild gespeichert: {filepath}")
|
||||||
|
except Exception as e:
|
||||||
|
print("Fehler beim Verarbeiten der Nachricht:", e)
|
||||||
|
|
||||||
|
# Callback bei erfolgreicher Verbindung
|
||||||
|
def on_connect(client, userdata, flags, rc, properties):
|
||||||
|
if rc == 0:
|
||||||
|
print("Mit MQTT-Server verbunden.")
|
||||||
|
client.subscribe(TOPIC_SCREENSHOTS, qos=1)
|
||||||
|
else:
|
||||||
|
print(f"Verbindung fehlgeschlagen (Code {rc})")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||||
|
client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||||
|
client.loop_forever()
|
||||||
16
server/nano.7258.save
Normal file
16
server/nano.7258.save
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# server/wsgi.py
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(status="ok")
|
||||||
|
|
||||||
|
# Optional: Test-Route
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return "Hello from Infoscreen‐API!"
|
||||||
|
|
||||||
|
# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher)
|
||||||
1
server/requirements-dev.txt
Normal file
1
server/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-dotenv>=1.1.0
|
||||||
6
server/requirements.txt
Normal file
6
server/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
bcrypt>=4.3.0
|
||||||
|
paho-mqtt>=2.1.0
|
||||||
|
PyMySQL>=1.1.1
|
||||||
|
python-dotenv>=1.1.0
|
||||||
|
SQLAlchemy>=2.0.41
|
||||||
|
flask
|
||||||
26
server/test_sql.py
Normal file
26
server/test_sql.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
try:
|
||||||
|
connection = pymysql.connect(
|
||||||
|
host='localhost',
|
||||||
|
port=3306,
|
||||||
|
user=os.getenv('DB_USER'),
|
||||||
|
password=os.getenv('DB_PASSWORD'),
|
||||||
|
database=os.getenv('DB_NAME')
|
||||||
|
)
|
||||||
|
print("✅ Verbindung erfolgreich!")
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("SELECT VERSION()")
|
||||||
|
version = cursor.fetchone()
|
||||||
|
print(f"MariaDB Version: {version[0]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Verbindungsfehler: {e}")
|
||||||
|
finally:
|
||||||
|
if 'connection' in locals():
|
||||||
|
connection.close()
|
||||||
16
server/wsgi.py
Normal file
16
server/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# server/wsgi.py
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return jsonify(status="ok")
|
||||||
|
|
||||||
|
# Optional: Test-Route
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return "Hello from Infoscreen‐API!"
|
||||||
|
|
||||||
|
# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher)
|
||||||
Reference in New Issue
Block a user