Complete Redesign of Backend Handling for Client Group Assignments

This commit is contained in:
2025-09-14 05:20:49 +00:00
parent c5a8571e97
commit e8d71b8349
10 changed files with 407 additions and 80 deletions

View File

@@ -1,21 +1,20 @@
{
"appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.4",
"version": "2025.1.0-alpha.5",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
"techStack": {
"frontend": "React, Vite, TypeScript, Tailwind CSS",
"backend": "Python (Flask), SQLAlchemy",
"database": "MariaDB",
"realtime": "Mosquitto (MQTT)",
"containerization": "Docker"
"Frontend": "React, Vite, TypeScript",
"Backend": "Python (Flask), SQLAlchemy",
"Database": "MariaDB",
"Realtime": "Mosquitto (MQTT)",
"Containerization": "Docker"
},
"openSourceComponents": {
"frontend": [
{ "name": "React", "license": "MIT" },
{ "name": "Vite", "license": "MIT" },
{ "name": "Tailwind CSS", "license": "MIT" },
{ "name": "Lucide Icons", "license": "ISC" },
{ "name": "Syncfusion UI Components", "license": "Kommerziell / Community" }
],
@@ -31,6 +30,13 @@
"commitId": "a1b2c3d4e5f6"
},
"changelog": [
{
"version": "2025.1.0-alpha.5",
"date": "2025-09-14",
"changes": [
"Komplettes Redesign des Backend-Handlings der Gruppenzuordnungen von neuen Clients und der Schritte bei Änderung der Gruppenzuordnung."
]
},
{
"version": "2025.1.0-alpha.4",
"date": "2025-09-01",

View File

@@ -59,11 +59,11 @@ export async function setClientDescription(uuid: string, description: string) {
return await res.json();
}
export async function updateClientGroup(clientIds: string[], groupName: string) {
export async function updateClientGroup(clientIds: string[], groupId: number) {
const res = await fetch('/api/clients/group', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_ids: clientIds, group_name: groupName }),
body: JSON.stringify({ client_ids: clientIds, group_id: groupId }),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
return await res.json();

View File

@@ -72,7 +72,7 @@ L10n.load({
const Infoscreen_groups: React.FC = () => {
const toast = useToast();
const [clients, setClients] = useState<KanbanClient[]>([]);
const [groups, setGroups] = useState<{ keyField: string; headerText: string }[]>([]);
const [groups, setGroups] = useState<{ keyField: string; headerText: string; id?: number }[]>([]);
const [showDialog, setShowDialog] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
@@ -130,7 +130,10 @@ const Infoscreen_groups: React.FC = () => {
timeOut: 5000,
showCloseButton: false,
});
setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]);
setGroups([
...groups,
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
]);
setNewGroupName('');
setShowDialog(false);
} catch (err) {
@@ -149,9 +152,12 @@ const Infoscreen_groups: React.FC = () => {
// Clients der Gruppe in "Nicht zugeordnet" verschieben
const groupClients = clients.filter(c => c.Status === groupName);
if (groupClients.length > 0) {
// Ermittle die ID der Zielgruppe "Nicht zugeordnet"
const target = groups.find(g => g.headerText === 'Nicht zugeordnet');
if (!target || !target.id) throw new Error('Zielgruppe "Nicht zugeordnet" nicht gefunden');
await updateClientGroup(
groupClients.map(c => c.Id),
'Nicht zugeordnet'
target.id
);
}
await deleteGroup(groupName);
@@ -271,7 +277,10 @@ const Infoscreen_groups: React.FC = () => {
const clientIds = dropped.map((card: KanbanClient) => card.Id);
try {
await updateClientGroup(clientIds, targetGroupName);
// Ermittle Zielgruppen-ID anhand des Namens
const target = groups.find(g => g.headerText === targetGroupName);
if (!target || !target.id) throw new Error('Zielgruppe nicht gefunden');
await updateClientGroup(clientIds, target.id);
fetchGroups().then((groupData: Group[]) => {
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
setGroups(

View File

@@ -1,4 +1,4 @@
declare module "*.json" {
const value: any;
declare module '*.json' {
const value: unknown;
export default value;
}

View File

@@ -6,6 +6,9 @@ WORKDIR /app
COPY listener/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Mosquitto-Tools für MQTT-Tests installieren
RUN apt-get update && apt-get install -y --no-install-recommends mosquitto-clients && rm -rf /var/lib/apt/lists/*
COPY listener/ ./listener
COPY models/ ./models

View File

@@ -1,8 +1,6 @@
import os
import json
import logging
import threading
import time
import datetime
import paho.mqtt.client as mqtt
from sqlalchemy import create_engine
@@ -27,12 +25,11 @@ logging.basicConfig(level=logging.DEBUG,
engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine)
# MQTT-Callback
def on_message(client, userdata, msg):
topic = msg.topic
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
try:
# Heartbeat-Handling
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
@@ -48,12 +45,15 @@ def on_message(client, userdata, msg):
return
# Discovery-Handling
if topic == "infoscreen/discovery":
payload = json.loads(msg.payload.decode())
logging.info(f"Discovery empfangen: {payload}")
if "uuid" in payload:
uuid = payload["uuid"]
session = Session()
existing = session.query(Client).filter_by(uuid=uuid).first()
if not existing:
new_client = Client(
uuid=uuid,
@@ -72,26 +72,19 @@ def on_message(client, userdata, msg):
logging.info(f"Neuer Client registriert: {uuid}")
else:
logging.info(f"Client bereits bekannt: {uuid}")
session.close()
# Discovery-ACK senden
ack_topic = f"infoscreen/{uuid}/discovery_ack"
client.publish(ack_topic, json.dumps({"status": "ok"}))
logging.info(f"Discovery-ACK gesendet an {ack_topic}")
else:
logging.warning("Discovery ohne UUID empfangen, ignoriert.")
except Exception as e:
logging.error(f"Fehler bei Verarbeitung: {e}")
topic_parts = msg.topic.split('/')
if len(topic_parts) == 3 and topic_parts[0] == "infoscreen" and topic_parts[1] == "request_group_id":
client_id = topic_parts[2]
session = Session()
client_obj = session.query(Client).filter_by(uuid=client_id).first()
group_id = client_obj.group_id if client_obj else None
session.close()
response_topic = f"infoscreen/response_group_id/{client_id}"
client.publish(response_topic, json.dumps({"group_id": group_id}))
def main():
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2)
@@ -99,9 +92,9 @@ def main():
mqtt_client.connect("mqtt", 1883)
mqtt_client.subscribe("infoscreen/discovery")
mqtt_client.subscribe("infoscreen/+/heartbeat")
mqtt_client.subscribe("infoscreen/request_group_id/#")
logging.info(
"Listener gestartet und abonniert auf infoscreen/discovery, infoscreen/+/heartbeat und infoscreen/request_group_id/#")
"Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat")
mqtt_client.loop_forever()

View File

@@ -32,15 +32,32 @@ logging.basicConfig(
def main():
# Fix für die veraltete API - explizit callback_api_version setzen
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
client.reconnect_delay_set(min_delay=1, max_delay=30)
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
# 0 = aus; z.B. 600 für alle 10 Min
REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0"))
last_payloads = {} # group_id -> payload
last_published_at = {} # group_id -> epoch seconds
# Beim (Re-)Connect alle bekannten retained Payloads erneut senden
def on_connect(client, userdata, flags, reasonCode, properties=None):
logging.info(
f"MQTT connected (reasonCode={reasonCode}) - republishing {len(last_payloads)} groups")
for gid, payload in last_payloads.items():
topic = f"infoscreen/events/{gid}"
client.publish(topic, payload, retain=True)
client.on_connect = on_connect
client.connect("mqtt", 1883)
client.loop_start()
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
last_payloads = {} # group_id -> payload
while True:
now = datetime.datetime.now(datetime.timezone.utc)
# Hole alle aktiven Events (Vergleich mit UTC)
events = get_active_events(now, now)
# Gruppiere Events nach group_id
groups = {}
for event in events:
@@ -54,20 +71,32 @@ def main():
"end": str(getattr(event, "end", "")),
"group_id": gid,
})
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
for gid, event_list in groups.items():
# stabile Reihenfolge, um unnötige Publishes zu vermeiden
event_list.sort(key=lambda e: (e.get("start"), e.get("id")))
payload = json.dumps(
event_list, sort_keys=True, separators=(",", ":"))
topic = f"infoscreen/events/{gid}"
payload = json.dumps(event_list)
if last_payloads.get(gid) != payload:
should_send = (last_payloads.get(gid) != payload)
if not should_send and REFRESH_SECONDS:
last_ts = last_published_at.get(gid, 0)
if time.time() - last_ts >= REFRESH_SECONDS:
should_send = True
if should_send:
result = client.publish(topic, payload, retain=True)
if result.rc != mqtt.MQTT_ERR_SUCCESS:
logging.error(
f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}")
else:
logging.info(
f"Events für Gruppe {gid} gesendet: {payload}")
logging.info(f"Events für Gruppe {gid} gesendet")
last_payloads[gid] = payload
# Entferne Gruppen, die nicht mehr existieren (optional: retained Message löschen)
last_published_at[gid] = time.time()
# Entferne Gruppen, die nicht mehr existieren (leere retained Message senden)
for gid in list(last_payloads.keys()):
if gid not in groups:
topic = f"infoscreen/events/{gid}"
@@ -79,6 +108,8 @@ def main():
logging.info(
f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)")
del last_payloads[gid]
last_published_at.pop(gid, None)
time.sleep(POLL_INTERVAL)

142
server/mqtt_helper.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Einfache MQTT-Hilfsfunktion für Client-Gruppenzuordnungen
"""
import os
import json
import logging
import paho.mqtt.client as mqtt
logger = logging.getLogger(__name__)
def publish_client_group(client_uuid: str, group_id: int) -> bool:
"""
Publiziert die Gruppenzuordnung eines Clients als retained message
Args:
client_uuid: UUID des Clients
group_id: ID der Gruppe
Returns:
bool: True bei Erfolg, False bei Fehler
"""
try:
# MQTT-Konfiguration aus .env
broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt")
broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883))
username = os.getenv("MQTT_USER")
password = os.getenv("MQTT_PASSWORD")
# Topic und Payload
topic = f"infoscreen/{client_uuid}/group_id"
payload = json.dumps({
"group_id": group_id,
"client_uuid": client_uuid
})
# MQTT-Client erstellen und verbinden
client = mqtt.Client()
if username and password:
client.username_pw_set(username, password)
client.connect(broker_host, broker_port, 60)
# Retained message publizieren
result = client.publish(topic, payload, qos=1, retain=True)
result.wait_for_publish(timeout=5.0)
client.disconnect()
logger.info(
f"Group assignment published for client {client_uuid}: group_id={group_id}")
return True
except Exception as e:
logger.error(
f"Error publishing group assignment for client {client_uuid}: {e}")
return False
def publish_multiple_client_groups(client_group_mappings: dict) -> tuple[int, int]:
"""
Publiziert Gruppenzuordnungen für mehrere Clients in einer Verbindung
Args:
client_group_mappings: Dict mit {client_uuid: group_id}
Returns:
tuple: (success_count, failed_count)
"""
try:
broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt")
broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883))
username = os.getenv("MQTT_USER")
password = os.getenv("MQTT_PASSWORD")
client = mqtt.Client()
if username and password:
client.username_pw_set(username, password)
client.connect(broker_host, broker_port, 60)
success_count = 0
failed_count = 0
for client_uuid, group_id in client_group_mappings.items():
try:
topic = f"infoscreen/{client_uuid}/group_id"
payload = json.dumps({
"group_id": group_id,
"client_uuid": client_uuid
})
result = client.publish(topic, payload, qos=1, retain=True)
result.wait_for_publish(timeout=5.0)
success_count += 1
except Exception as e:
logger.error(f"Failed to publish for {client_uuid}: {e}")
failed_count += 1
client.disconnect()
logger.info(
f"Bulk publish completed: {success_count} success, {failed_count} failed")
return success_count, failed_count
except Exception as e:
logger.error(f"Error in bulk publish: {e}")
return 0, len(client_group_mappings)
def delete_client_group_message(client_uuid: str) -> bool:
"""
Löscht die retained message für einen Client (bei Client-Löschung)
"""
try:
broker_host = os.getenv("MQTT_BROKER_HOST", "mqtt")
broker_port = int(os.getenv("MQTT_BROKER_PORT", 1883))
username = os.getenv("MQTT_USER")
password = os.getenv("MQTT_PASSWORD")
topic = f"infoscreen/{client_uuid}/group_id"
client = mqtt.Client()
if username and password:
client.username_pw_set(username, password)
client.connect(broker_host, broker_port, 60)
# Leere retained message löscht die vorherige
result = client.publish(topic, "", qos=1, retain=True)
result.wait_for_publish(timeout=5.0)
client.disconnect()
logger.info(f"Deleted retained group message for client {client_uuid}")
return True
except Exception as e:
logger.error(
f"Error deleting group message for client {client_uuid}: {e}")
return False

View File

@@ -1,12 +1,49 @@
from server.database import Session
from models.models import Client, ClientGroup
from flask import Blueprint, request, jsonify
from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups
import sys
sys.path.append('/workspace')
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
@clients_bp.route("/sync-all-groups", methods=["POST"])
def sync_all_client_groups():
"""
Administrative Route: Synchronisiert alle bestehenden Client-Gruppenzuordnungen mit MQTT
Nützlich für die einmalige Migration bestehender Clients
"""
session = Session()
try:
# Alle aktiven Clients abrufen
clients = session.query(Client).filter(Client.is_active == True).all()
if not clients:
session.close()
return jsonify({"message": "Keine aktiven Clients gefunden", "synced": 0})
# Alle Clients synchronisieren
client_group_mappings = {
client.uuid: client.group_id for client in clients}
success_count, failed_count = publish_multiple_client_groups(
client_group_mappings)
session.close()
return jsonify({
"success": True,
"message": f"Synchronisation abgeschlossen",
"synced": success_count,
"failed": failed_count,
"total": len(clients)
})
except Exception as e:
session.close()
return jsonify({"error": f"Fehler bei der Synchronisation: {str(e)}"}), 500
@clients_bp.route("/without_description", methods=["GET"])
def get_clients_without_description():
session = Session()
@@ -46,10 +83,20 @@ def set_client_description(uuid):
if not client:
session.close()
return jsonify({"error": "Client nicht gefunden"}), 404
client.description = description
session.commit()
# MQTT: Gruppenzuordnung publizieren (wichtig für neue Clients aus SetupMode)
mqtt_success = publish_client_group(client.uuid, client.group_id)
session.close()
return jsonify({"success": True})
response = {"success": True}
if not mqtt_success:
response["warning"] = "Beschreibung gespeichert, aber MQTT-Publishing fehlgeschlagen"
return jsonify(response)
@clients_bp.route("", methods=["GET"])
@@ -83,18 +130,51 @@ def get_clients():
def update_clients_group():
data = request.get_json()
client_ids = data.get("client_ids", [])
group_id = data.get("group_id")
group_name = data.get("group_name")
if not isinstance(client_ids, list) or len(client_ids) == 0:
return jsonify({"error": "client_ids muss eine nicht-leere Liste sein"}), 400
session = Session()
# Bestimme Ziel-Gruppe: Priorität hat group_id, ansonsten group_name
group = None
if group_id is not None:
group = session.query(ClientGroup).filter_by(id=group_id).first()
if not group:
session.close()
return jsonify({"error": f"Gruppe mit id={group_id} nicht gefunden"}), 404
elif group_name:
group = session.query(ClientGroup).filter_by(name=group_name).first()
if not group:
session.close()
return jsonify({"error": "Gruppe nicht gefunden"}), 404
return jsonify({"error": f"Gruppe '{group_name}' nicht gefunden"}), 404
else:
session.close()
return jsonify({"error": "Entweder group_id oder group_name ist erforderlich"}), 400
# WICHTIG: group.id vor dem Schließen puffern, um DetachedInstanceError zu vermeiden
target_group_id = group.id
session.query(Client).filter(Client.uuid.in_(client_ids)).update(
{Client.group_id: group.id}, synchronize_session=False
{Client.group_id: target_group_id}, synchronize_session=False
)
session.commit()
session.close()
return jsonify({"success": True})
# MQTT: Gruppenzuordnungen für alle betroffenen Clients publizieren (nutzt gecachten target_group_id)
client_group_mappings = {
client_id: target_group_id for client_id in client_ids}
success_count, failed_count = publish_multiple_client_groups(
client_group_mappings)
response = {"success": True}
if failed_count > 0:
response[
"warning"] = f"Gruppenzuordnung gespeichert, aber {failed_count} MQTT-Publishing(s) fehlgeschlagen"
return jsonify(response)
@clients_bp.route("/<uuid>", methods=["PATCH"])
@@ -194,7 +274,16 @@ def delete_client(uuid):
if not client:
session.close()
return jsonify({"error": "Client nicht gefunden"}), 404
session.delete(client)
session.commit()
session.close()
return jsonify({"success": True})
# MQTT: Retained message für gelöschten Client entfernen
mqtt_success = delete_client_group_message(uuid)
response = {"success": True}
if not mqtt_success:
response["warning"] = "Client gelöscht, aber MQTT-Message-Löschung fehlgeschlagen"
return jsonify(response)

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
Einmaliges Skript zur Synchronisation aller bestehenden Client-Gruppenzuordnungen
Verwendung: python sync_existing_clients.py
"""
from server.mqtt_helper import publish_multiple_client_groups
from models.models import Client
from server.database import Session
import sys
import os
sys.path.append('/workspace')
def main():
print("Synchronisiere bestehende Client-Gruppenzuordnungen mit MQTT...")
session = Session()
try:
# Alle aktiven Clients abrufen
clients = session.query(Client).filter(Client.is_active == True).all()
if not clients:
print("Keine aktiven Clients gefunden.")
return
print(f"Gefunden: {len(clients)} aktive Clients")
# Mapping erstellen
client_group_mappings = {
client.uuid: client.group_id for client in clients}
# Alle auf einmal publizieren
success_count, failed_count = publish_multiple_client_groups(
client_group_mappings)
print(f"Synchronisation abgeschlossen:")
print(f" Erfolgreich: {success_count}")
print(f" Fehlgeschlagen: {failed_count}")
print(f" Gesamt: {len(clients)}")
if failed_count == 0:
print("✅ Alle Clients erfolgreich synchronisiert!")
else:
print(
f"⚠️ {failed_count} Clients konnten nicht synchronisiert werden.")
except Exception as e:
print(f"Fehler: {e}")
finally:
session.close()
if __name__ == "__main__":
main()