feat(dashboard+api): card-based dashboard, camelCase API, UTC fixes
Dashboard: new Syncfusion card layout, global stats, filters, health bars, active event display, client details, bulk restart, 15s auto-refresh, manual refresh toasts API: standardized responses to camelCase; added serializers.py and updated events endpoints Time: ensured UTC storage; frontend appends 'Z' for parsing and displays local time Docs: updated copilot-instructions.md, README.md, TECH-CHANGELOG.md Program Info: bumped to 2025.1.0-alpha.12 with user-facing changelog BREAKING: external API consumers must migrate field names from PascalCase to camelCase.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from server.permissions import editor_or_higher
|
||||
from server.database import Session
|
||||
from server.serializers import dict_to_camel_case, dict_to_snake_case
|
||||
from models.models import Event, EventMedia, MediaType, EventException, SystemSetting
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import and_
|
||||
@@ -95,28 +96,29 @@ def get_events():
|
||||
recurrence_exception = ','.join(tokens)
|
||||
|
||||
base_payload = {
|
||||
"Id": str(e.id),
|
||||
"GroupId": e.group_id,
|
||||
"Subject": e.title,
|
||||
"Description": getattr(e, 'description', None),
|
||||
"StartTime": e.start.isoformat() if e.start else None,
|
||||
"EndTime": e.end.isoformat() if e.end else None,
|
||||
"IsAllDay": False,
|
||||
"MediaId": e.event_media_id,
|
||||
"Type": e.event_type.value if e.event_type else None, # <-- Enum zu String!
|
||||
"Icon": get_icon_for_type(e.event_type.value if e.event_type else None),
|
||||
"id": str(e.id),
|
||||
"group_id": e.group_id,
|
||||
"subject": e.title,
|
||||
"description": getattr(e, 'description', None),
|
||||
"start_time": e.start.isoformat() if e.start else None,
|
||||
"end_time": e.end.isoformat() if e.end else None,
|
||||
"is_all_day": False,
|
||||
"media_id": e.event_media_id,
|
||||
"type": e.event_type.value if e.event_type else None,
|
||||
"icon": get_icon_for_type(e.event_type.value if e.event_type else None),
|
||||
# Recurrence metadata
|
||||
"RecurrenceRule": e.recurrence_rule,
|
||||
"RecurrenceEnd": e.recurrence_end.isoformat() if e.recurrence_end else None,
|
||||
"RecurrenceException": recurrence_exception,
|
||||
"SkipHolidays": bool(getattr(e, 'skip_holidays', False)),
|
||||
"recurrence_rule": e.recurrence_rule,
|
||||
"recurrence_end": e.recurrence_end.isoformat() if e.recurrence_end else None,
|
||||
"recurrence_exception": recurrence_exception,
|
||||
"skip_holidays": bool(getattr(e, 'skip_holidays', False)),
|
||||
}
|
||||
result.append(base_payload)
|
||||
|
||||
# No need to emit synthetic override events anymore since detached occurrences
|
||||
# are now real Event rows that will be returned in the main query
|
||||
session.close()
|
||||
return jsonify(result)
|
||||
# Convert all keys to camelCase for frontend
|
||||
return jsonify(dict_to_camel_case(result))
|
||||
|
||||
|
||||
@events_bp.route("/<event_id>", methods=["GET"]) # get single event
|
||||
@@ -126,32 +128,32 @@ def get_event(event_id):
|
||||
event = session.query(Event).filter_by(id=event_id).first()
|
||||
if not event:
|
||||
return jsonify({"error": "Termin nicht gefunden"}), 404
|
||||
|
||||
# Convert event to dictionary with all necessary fields
|
||||
event_dict = {
|
||||
"Id": str(event.id),
|
||||
"Subject": event.title,
|
||||
"StartTime": event.start.isoformat() if event.start else None,
|
||||
"EndTime": event.end.isoformat() if event.end else None,
|
||||
"Description": event.description,
|
||||
"Type": event.event_type.value if event.event_type else "presentation",
|
||||
"IsAllDay": False, # Assuming events are not all-day by default
|
||||
"MediaId": str(event.event_media_id) if event.event_media_id else None,
|
||||
"SlideshowInterval": event.slideshow_interval,
|
||||
"PageProgress": event.page_progress,
|
||||
"AutoProgress": event.auto_progress,
|
||||
"WebsiteUrl": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
|
||||
"id": str(event.id),
|
||||
"subject": event.title,
|
||||
"start_time": event.start.isoformat() if event.start else None,
|
||||
"end_time": event.end.isoformat() if event.end else None,
|
||||
"description": event.description,
|
||||
"type": event.event_type.value if event.event_type else "presentation",
|
||||
"is_all_day": False, # Assuming events are not all-day by default
|
||||
"media_id": str(event.event_media_id) if event.event_media_id else None,
|
||||
"slideshow_interval": event.slideshow_interval,
|
||||
"page_progress": event.page_progress,
|
||||
"auto_progress": event.auto_progress,
|
||||
"website_url": event.event_media.url if event.event_media and hasattr(event.event_media, 'url') else None,
|
||||
# Video-specific fields
|
||||
"Autoplay": event.autoplay,
|
||||
"Loop": event.loop,
|
||||
"Volume": event.volume,
|
||||
"Muted": event.muted,
|
||||
"RecurrenceRule": event.recurrence_rule,
|
||||
"RecurrenceEnd": event.recurrence_end.isoformat() if event.recurrence_end else None,
|
||||
"SkipHolidays": event.skip_holidays,
|
||||
"Icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
|
||||
"autoplay": event.autoplay,
|
||||
"loop": event.loop,
|
||||
"volume": event.volume,
|
||||
"muted": event.muted,
|
||||
"recurrence_rule": event.recurrence_rule,
|
||||
"recurrence_end": event.recurrence_end.isoformat() if event.recurrence_end else None,
|
||||
"skip_holidays": event.skip_holidays,
|
||||
"icon": get_icon_for_type(event.event_type.value if event.event_type else "presentation"),
|
||||
}
|
||||
|
||||
return jsonify(dict_to_camel_case(event_dict))
|
||||
return jsonify(event_dict)
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Fehler beim Laden des Termins: {str(e)}"}), 500
|
||||
|
||||
74
server/serializers.py
Normal file
74
server/serializers.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Serialization helpers for converting between Python snake_case and JavaScript camelCase.
|
||||
"""
|
||||
import re
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
|
||||
def to_camel_case(snake_str: str) -> str:
|
||||
"""
|
||||
Convert snake_case string to camelCase.
|
||||
|
||||
Examples:
|
||||
event_type -> eventType
|
||||
start_time -> startTime
|
||||
is_active -> isActive
|
||||
"""
|
||||
components = snake_str.split('_')
|
||||
# Keep the first component as-is, capitalize the rest
|
||||
return components[0] + ''.join(word.capitalize() for word in components[1:])
|
||||
|
||||
|
||||
def to_snake_case(camel_str: str) -> str:
|
||||
"""
|
||||
Convert camelCase string to snake_case.
|
||||
|
||||
Examples:
|
||||
eventType -> event_type
|
||||
startTime -> start_time
|
||||
isActive -> is_active
|
||||
"""
|
||||
# Insert underscore before uppercase letters and convert to lowercase
|
||||
snake = re.sub('([A-Z])', r'_\1', camel_str).lower()
|
||||
# Remove leading underscore if present
|
||||
return snake.lstrip('_')
|
||||
|
||||
|
||||
def dict_to_camel_case(data: Union[Dict, List, Any]) -> Union[Dict, List, Any]:
|
||||
"""
|
||||
Recursively convert dictionary keys from snake_case to camelCase.
|
||||
Also handles lists of dictionaries.
|
||||
|
||||
Args:
|
||||
data: Dictionary, list, or primitive value to convert
|
||||
|
||||
Returns:
|
||||
Converted data structure with camelCase keys
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {to_camel_case(key): dict_to_camel_case(value)
|
||||
for key, value in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [dict_to_camel_case(item) for item in data]
|
||||
else:
|
||||
return data
|
||||
|
||||
|
||||
def dict_to_snake_case(data: Union[Dict, List, Any]) -> Union[Dict, List, Any]:
|
||||
"""
|
||||
Recursively convert dictionary keys from camelCase to snake_case.
|
||||
Also handles lists of dictionaries.
|
||||
|
||||
Args:
|
||||
data: Dictionary, list, or primitive value to convert
|
||||
|
||||
Returns:
|
||||
Converted data structure with snake_case keys
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return {to_snake_case(key): dict_to_snake_case(value)
|
||||
for key, value in data.items()}
|
||||
elif isinstance(data, list):
|
||||
return [dict_to_snake_case(item) for item in data]
|
||||
else:
|
||||
return data
|
||||
Reference in New Issue
Block a user