Complete Redesign of Backend Handling for Client Group Assignments
This commit is contained in:
@@ -1,21 +1,20 @@
|
|||||||
{
|
{
|
||||||
"appName": "Infoscreen-Management",
|
"appName": "Infoscreen-Management",
|
||||||
"version": "2025.1.0-alpha.4",
|
"version": "2025.1.0-alpha.5",
|
||||||
"copyright": "© 2025 Third-Age-Applications",
|
"copyright": "© 2025 Third-Age-Applications",
|
||||||
"supportContact": "support@third-age-applications.com",
|
"supportContact": "support@third-age-applications.com",
|
||||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
"frontend": "React, Vite, TypeScript, Tailwind CSS",
|
"Frontend": "React, Vite, TypeScript",
|
||||||
"backend": "Python (Flask), SQLAlchemy",
|
"Backend": "Python (Flask), SQLAlchemy",
|
||||||
"database": "MariaDB",
|
"Database": "MariaDB",
|
||||||
"realtime": "Mosquitto (MQTT)",
|
"Realtime": "Mosquitto (MQTT)",
|
||||||
"containerization": "Docker"
|
"Containerization": "Docker"
|
||||||
},
|
},
|
||||||
"openSourceComponents": {
|
"openSourceComponents": {
|
||||||
"frontend": [
|
"frontend": [
|
||||||
{ "name": "React", "license": "MIT" },
|
{ "name": "React", "license": "MIT" },
|
||||||
{ "name": "Vite", "license": "MIT" },
|
{ "name": "Vite", "license": "MIT" },
|
||||||
{ "name": "Tailwind CSS", "license": "MIT" },
|
|
||||||
{ "name": "Lucide Icons", "license": "ISC" },
|
{ "name": "Lucide Icons", "license": "ISC" },
|
||||||
{ "name": "Syncfusion UI Components", "license": "Kommerziell / Community" }
|
{ "name": "Syncfusion UI Components", "license": "Kommerziell / Community" }
|
||||||
],
|
],
|
||||||
@@ -31,6 +30,13 @@
|
|||||||
"commitId": "a1b2c3d4e5f6"
|
"commitId": "a1b2c3d4e5f6"
|
||||||
},
|
},
|
||||||
"changelog": [
|
"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",
|
"version": "2025.1.0-alpha.4",
|
||||||
"date": "2025-09-01",
|
"date": "2025-09-01",
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ export async function setClientDescription(uuid: string, description: string) {
|
|||||||
return await res.json();
|
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', {
|
const res = await fetch('/api/clients/group', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren der Clients');
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ L10n.load({
|
|||||||
const Infoscreen_groups: React.FC = () => {
|
const Infoscreen_groups: React.FC = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [clients, setClients] = useState<KanbanClient[]>([]);
|
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 [showDialog, setShowDialog] = useState(false);
|
||||||
const [newGroupName, setNewGroupName] = useState('');
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
const [draggedCard, setDraggedCard] = useState<{ id: string; fromColumn: string } | null>(null);
|
||||||
@@ -130,7 +130,10 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
timeOut: 5000,
|
timeOut: 5000,
|
||||||
showCloseButton: false,
|
showCloseButton: false,
|
||||||
});
|
});
|
||||||
setGroups([...groups, { keyField: newGroup.name, headerText: newGroup.name }]);
|
setGroups([
|
||||||
|
...groups,
|
||||||
|
{ keyField: newGroup.name, headerText: newGroup.name, id: newGroup.id },
|
||||||
|
]);
|
||||||
setNewGroupName('');
|
setNewGroupName('');
|
||||||
setShowDialog(false);
|
setShowDialog(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -149,9 +152,12 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
// Clients der Gruppe in "Nicht zugeordnet" verschieben
|
||||||
const groupClients = clients.filter(c => c.Status === groupName);
|
const groupClients = clients.filter(c => c.Status === groupName);
|
||||||
if (groupClients.length > 0) {
|
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(
|
await updateClientGroup(
|
||||||
groupClients.map(c => c.Id),
|
groupClients.map(c => c.Id),
|
||||||
'Nicht zugeordnet'
|
target.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deleteGroup(groupName);
|
await deleteGroup(groupName);
|
||||||
@@ -271,7 +277,10 @@ const Infoscreen_groups: React.FC = () => {
|
|||||||
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
const clientIds = dropped.map((card: KanbanClient) => card.Id);
|
||||||
|
|
||||||
try {
|
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[]) => {
|
fetchGroups().then((groupData: Group[]) => {
|
||||||
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
const groupMap = Object.fromEntries(groupData.map(g => [g.id, g.name]));
|
||||||
setGroups(
|
setGroups(
|
||||||
|
|||||||
4
dashboard/src/types/json.d.ts
vendored
4
dashboard/src/types/json.d.ts
vendored
@@ -1,4 +1,4 @@
|
|||||||
declare module "*.json" {
|
declare module '*.json' {
|
||||||
const value: any;
|
const value: unknown;
|
||||||
export default value;
|
export default value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ WORKDIR /app
|
|||||||
COPY listener/requirements.txt ./
|
COPY listener/requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r 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 listener/ ./listener
|
||||||
COPY models/ ./models
|
COPY models/ ./models
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import datetime
|
import datetime
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -27,12 +25,11 @@ logging.basicConfig(level=logging.DEBUG,
|
|||||||
engine = create_engine(DB_URL)
|
engine = create_engine(DB_URL)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
|
|
||||||
# MQTT-Callback
|
|
||||||
|
|
||||||
|
|
||||||
def on_message(client, userdata, msg):
|
def on_message(client, userdata, msg):
|
||||||
topic = msg.topic
|
topic = msg.topic
|
||||||
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
|
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Heartbeat-Handling
|
# Heartbeat-Handling
|
||||||
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
|
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
|
||||||
@@ -48,12 +45,15 @@ def on_message(client, userdata, msg):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Discovery-Handling
|
# Discovery-Handling
|
||||||
|
if topic == "infoscreen/discovery":
|
||||||
payload = json.loads(msg.payload.decode())
|
payload = json.loads(msg.payload.decode())
|
||||||
logging.info(f"Discovery empfangen: {payload}")
|
logging.info(f"Discovery empfangen: {payload}")
|
||||||
|
|
||||||
if "uuid" in payload:
|
if "uuid" in payload:
|
||||||
uuid = payload["uuid"]
|
uuid = payload["uuid"]
|
||||||
session = Session()
|
session = Session()
|
||||||
existing = session.query(Client).filter_by(uuid=uuid).first()
|
existing = session.query(Client).filter_by(uuid=uuid).first()
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
new_client = Client(
|
new_client = Client(
|
||||||
uuid=uuid,
|
uuid=uuid,
|
||||||
@@ -72,26 +72,19 @@ def on_message(client, userdata, msg):
|
|||||||
logging.info(f"Neuer Client registriert: {uuid}")
|
logging.info(f"Neuer Client registriert: {uuid}")
|
||||||
else:
|
else:
|
||||||
logging.info(f"Client bereits bekannt: {uuid}")
|
logging.info(f"Client bereits bekannt: {uuid}")
|
||||||
|
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
# Discovery-ACK senden
|
# Discovery-ACK senden
|
||||||
ack_topic = f"infoscreen/{uuid}/discovery_ack"
|
ack_topic = f"infoscreen/{uuid}/discovery_ack"
|
||||||
client.publish(ack_topic, json.dumps({"status": "ok"}))
|
client.publish(ack_topic, json.dumps({"status": "ok"}))
|
||||||
logging.info(f"Discovery-ACK gesendet an {ack_topic}")
|
logging.info(f"Discovery-ACK gesendet an {ack_topic}")
|
||||||
else:
|
else:
|
||||||
logging.warning("Discovery ohne UUID empfangen, ignoriert.")
|
logging.warning("Discovery ohne UUID empfangen, ignoriert.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Fehler bei Verarbeitung: {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():
|
def main():
|
||||||
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2)
|
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2)
|
||||||
@@ -99,9 +92,9 @@ def main():
|
|||||||
mqtt_client.connect("mqtt", 1883)
|
mqtt_client.connect("mqtt", 1883)
|
||||||
mqtt_client.subscribe("infoscreen/discovery")
|
mqtt_client.subscribe("infoscreen/discovery")
|
||||||
mqtt_client.subscribe("infoscreen/+/heartbeat")
|
mqtt_client.subscribe("infoscreen/+/heartbeat")
|
||||||
mqtt_client.subscribe("infoscreen/request_group_id/#")
|
|
||||||
logging.info(
|
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()
|
mqtt_client.loop_forever()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,32 @@ logging.basicConfig(
|
|||||||
def main():
|
def main():
|
||||||
# Fix für die veraltete API - explizit callback_api_version setzen
|
# Fix für die veraltete API - explizit callback_api_version setzen
|
||||||
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
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.connect("mqtt", 1883)
|
||||||
client.loop_start()
|
client.loop_start()
|
||||||
|
|
||||||
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
|
|
||||||
last_payloads = {} # group_id -> payload
|
|
||||||
while True:
|
while True:
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
# Hole alle aktiven Events (Vergleich mit UTC)
|
# Hole alle aktiven Events (Vergleich mit UTC)
|
||||||
events = get_active_events(now, now)
|
events = get_active_events(now, now)
|
||||||
|
|
||||||
# Gruppiere Events nach group_id
|
# Gruppiere Events nach group_id
|
||||||
groups = {}
|
groups = {}
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -54,20 +71,32 @@ def main():
|
|||||||
"end": str(getattr(event, "end", "")),
|
"end": str(getattr(event, "end", "")),
|
||||||
"group_id": gid,
|
"group_id": gid,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
|
# Sende pro Gruppe die Eventliste als retained Message, nur bei Änderung
|
||||||
for gid, event_list in groups.items():
|
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}"
|
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)
|
result = client.publish(topic, payload, retain=True)
|
||||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}")
|
f"Fehler beim Publish für Gruppe {gid}: {mqtt.error_string(result.rc)}")
|
||||||
else:
|
else:
|
||||||
logging.info(
|
logging.info(f"Events für Gruppe {gid} gesendet")
|
||||||
f"Events für Gruppe {gid} gesendet: {payload}")
|
|
||||||
last_payloads[gid] = payload
|
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()):
|
for gid in list(last_payloads.keys()):
|
||||||
if gid not in groups:
|
if gid not in groups:
|
||||||
topic = f"infoscreen/events/{gid}"
|
topic = f"infoscreen/events/{gid}"
|
||||||
@@ -79,6 +108,8 @@ def main():
|
|||||||
logging.info(
|
logging.info(
|
||||||
f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)")
|
f"Events für Gruppe {gid} entfernt (leere retained Message gesendet)")
|
||||||
del last_payloads[gid]
|
del last_payloads[gid]
|
||||||
|
last_published_at.pop(gid, None)
|
||||||
|
|
||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
142
server/mqtt_helper.py
Normal file
142
server/mqtt_helper.py
Normal 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
|
||||||
@@ -1,12 +1,49 @@
|
|||||||
from server.database import Session
|
from server.database import Session
|
||||||
from models.models import Client, ClientGroup
|
from models.models import Client, ClientGroup
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from server.mqtt_helper import publish_client_group, delete_client_group_message, publish_multiple_client_groups
|
||||||
import sys
|
import sys
|
||||||
sys.path.append('/workspace')
|
sys.path.append('/workspace')
|
||||||
|
|
||||||
clients_bp = Blueprint("clients", __name__, url_prefix="/api/clients")
|
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"])
|
@clients_bp.route("/without_description", methods=["GET"])
|
||||||
def get_clients_without_description():
|
def get_clients_without_description():
|
||||||
session = Session()
|
session = Session()
|
||||||
@@ -46,10 +83,20 @@ def set_client_description(uuid):
|
|||||||
if not client:
|
if not client:
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"error": "Client nicht gefunden"}), 404
|
return jsonify({"error": "Client nicht gefunden"}), 404
|
||||||
|
|
||||||
client.description = description
|
client.description = description
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# MQTT: Gruppenzuordnung publizieren (wichtig für neue Clients aus SetupMode)
|
||||||
|
mqtt_success = publish_client_group(client.uuid, client.group_id)
|
||||||
|
|
||||||
session.close()
|
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"])
|
@clients_bp.route("", methods=["GET"])
|
||||||
@@ -83,18 +130,51 @@ def get_clients():
|
|||||||
def update_clients_group():
|
def update_clients_group():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
client_ids = data.get("client_ids", [])
|
client_ids = data.get("client_ids", [])
|
||||||
|
group_id = data.get("group_id")
|
||||||
group_name = data.get("group_name")
|
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()
|
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()
|
group = session.query(ClientGroup).filter_by(name=group_name).first()
|
||||||
if not group:
|
if not group:
|
||||||
session.close()
|
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(
|
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.commit()
|
||||||
session.close()
|
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"])
|
@clients_bp.route("/<uuid>", methods=["PATCH"])
|
||||||
@@ -194,7 +274,16 @@ def delete_client(uuid):
|
|||||||
if not client:
|
if not client:
|
||||||
session.close()
|
session.close()
|
||||||
return jsonify({"error": "Client nicht gefunden"}), 404
|
return jsonify({"error": "Client nicht gefunden"}), 404
|
||||||
|
|
||||||
session.delete(client)
|
session.delete(client)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.close()
|
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)
|
||||||
|
|||||||
54
server/sync_existing_clients.py
Normal file
54
server/sync_existing_clients.py
Normal 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()
|
||||||
Reference in New Issue
Block a user