Dashboard Add top-right user dropdown using Syncfusion DropDownButton: shows username + role; menu entries “Profil” and “Abmelden”. Replace custom dropdown logic with Syncfusion component; position at header’s right edge. Update /logout page to call backend logout and redirect to /login (reliable user switching). Build/Config Add @syncfusion/ej2-react-splitbuttons and @syncfusion/ej2-splitbuttons dependencies. Update Vite optimizeDeps.include to pre-bundle splitbuttons and avoid import-analysis errors. Docs README: Rework Architecture Overview with clearer data flow: Listener consumes MQTT (discovery/heartbeats) and updates API. Scheduler reads from API and publishes events via MQTT to clients. Clients send via MQTT and receive via MQTT. Worker receives commands directly from API and reports results back (no MQTT). Explicit note: MariaDB is accessed exclusively by the API Server; Dashboard never talks to DB directly. README: Add SplitButtons to “Syncfusion Components Used”; add troubleshooting steps for @syncfusion/ej2-react-splitbuttons import issues (optimizeDeps + volume reset). Copilot instructions: Document header user menu and splitbuttons technical notes (deps, optimizeDeps, dev-container node_modules volume). Program info Bump to 2025.1.0-alpha.10 with changelog: UI: Header user menu (DropDownButton with username/role; Profil/Abmelden). Frontend: Syncfusion SplitButtons integration + Vite pre-bundling config. Fix: Added README guidance for splitbuttons import errors. No breaking changes.
288 lines
12 KiB
Python
288 lines
12 KiB
Python
from sqlalchemy import (
|
|
Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime, Date, UniqueConstraint
|
|
)
|
|
from sqlalchemy.orm import declarative_base, relationship
|
|
import enum
|
|
from datetime import datetime, timezone
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
class UserRole(enum.Enum):
|
|
user = "user"
|
|
editor = "editor"
|
|
admin = "admin"
|
|
superadmin = "superadmin"
|
|
|
|
|
|
class AcademicPeriodType(enum.Enum):
|
|
schuljahr = "schuljahr"
|
|
semester = "semester"
|
|
trimester = "trimester"
|
|
|
|
|
|
class User(Base):
|
|
__tablename__ = 'users'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
username = Column(String(50), unique=True, nullable=False, index=True)
|
|
password_hash = Column(String(128), nullable=False)
|
|
role = Column(Enum(UserRole), nullable=False, default=UserRole.user)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
created_at = Column(TIMESTAMP(timezone=True),
|
|
server_default=func.current_timestamp())
|
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
|
), onupdate=func.current_timestamp())
|
|
|
|
|
|
class AcademicPeriod(Base):
|
|
__tablename__ = 'academic_periods'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
name = Column(String(100), nullable=False) # "Schuljahr 2024/25"
|
|
display_name = Column(String(50), nullable=True) # "SJ 24/25" (kurz)
|
|
start_date = Column(Date, nullable=False, index=True)
|
|
end_date = Column(Date, nullable=False, index=True)
|
|
period_type = Column(Enum(AcademicPeriodType),
|
|
nullable=False, default=AcademicPeriodType.schuljahr)
|
|
# nur eine aktive Periode zur Zeit
|
|
is_active = Column(Boolean, default=False, nullable=False)
|
|
created_at = Column(TIMESTAMP(timezone=True),
|
|
server_default=func.current_timestamp())
|
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
|
), onupdate=func.current_timestamp())
|
|
|
|
# Constraint: nur eine aktive Periode zur Zeit
|
|
__table_args__ = (
|
|
Index('ix_academic_periods_active', 'is_active'),
|
|
UniqueConstraint('name', name='uq_academic_periods_name'),
|
|
)
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"display_name": self.display_name,
|
|
"start_date": self.start_date.isoformat() if self.start_date else None,
|
|
"end_date": self.end_date.isoformat() if self.end_date else None,
|
|
"period_type": self.period_type.value if self.period_type else None,
|
|
"is_active": self.is_active,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
|
|
|
|
class ClientGroup(Base):
|
|
__tablename__ = 'client_groups'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
name = Column(String(100), unique=True, nullable=False)
|
|
description = Column(String(255), nullable=True) # Manuell zu setzen
|
|
created_at = Column(TIMESTAMP(timezone=True),
|
|
server_default=func.current_timestamp())
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
|
class Client(Base):
|
|
__tablename__ = 'clients'
|
|
uuid = Column(String(36), primary_key=True, nullable=False)
|
|
hardware_token = Column(String(64), nullable=True)
|
|
ip = Column(String(45), nullable=True)
|
|
type = Column(String(50), nullable=True)
|
|
hostname = Column(String(100), nullable=True)
|
|
os_version = Column(String(100), nullable=True)
|
|
software_version = Column(String(100), nullable=True)
|
|
macs = Column(String(255), nullable=True)
|
|
model = Column(String(100), nullable=True)
|
|
description = Column(String(255), nullable=True) # Manuell zu setzen
|
|
registration_time = Column(TIMESTAMP(
|
|
timezone=True), server_default=func.current_timestamp(), nullable=False)
|
|
last_alive = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
|
), onupdate=func.current_timestamp(), nullable=False)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
group_id = Column(Integer, ForeignKey(
|
|
'client_groups.id'), nullable=False, default=1)
|
|
|
|
|
|
class EventType(enum.Enum):
|
|
presentation = "presentation"
|
|
website = "website"
|
|
video = "video"
|
|
message = "message"
|
|
other = "other"
|
|
webuntis = "webuntis"
|
|
|
|
|
|
class MediaType(enum.Enum):
|
|
# Präsentationen
|
|
pdf = "pdf"
|
|
ppt = "ppt"
|
|
pptx = "pptx"
|
|
odp = "odp"
|
|
# Videos (gängige VLC-Formate)
|
|
mp4 = "mp4"
|
|
avi = "avi"
|
|
mkv = "mkv"
|
|
mov = "mov"
|
|
wmv = "wmv"
|
|
flv = "flv"
|
|
webm = "webm"
|
|
mpg = "mpg"
|
|
mpeg = "mpeg"
|
|
ogv = "ogv"
|
|
# Bilder (benutzerfreundlich)
|
|
jpg = "jpg"
|
|
jpeg = "jpeg"
|
|
png = "png"
|
|
gif = "gif"
|
|
bmp = "bmp"
|
|
tiff = "tiff"
|
|
svg = "svg"
|
|
# HTML-Mitteilung
|
|
html = "html"
|
|
# Webseiten
|
|
website = "website"
|
|
|
|
|
|
class Event(Base):
|
|
__tablename__ = 'events'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
group_id = Column(Integer, ForeignKey('client_groups.id'), nullable=False, index=True)
|
|
academic_period_id = Column(Integer, ForeignKey('academic_periods.id'), nullable=True, index=True) # Optional für Rückwärtskompatibilität
|
|
title = Column(String(100), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
|
end = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
|
event_type = Column(Enum(EventType), nullable=False)
|
|
event_media_id = Column(Integer, ForeignKey('event_media.id'), nullable=True)
|
|
autoplay = Column(Boolean, nullable=True) # NEU
|
|
loop = Column(Boolean, nullable=True) # NEU
|
|
volume = Column(Float, nullable=True) # NEU
|
|
slideshow_interval = Column(Integer, nullable=True) # NEU
|
|
# Recurrence fields
|
|
recurrence_rule = Column(String(255), nullable=True, index=True) # iCalendar RRULE string
|
|
recurrence_end = Column(TIMESTAMP(timezone=True), nullable=True, index=True) # When recurrence ends
|
|
# Whether recurrences should skip school holidays
|
|
skip_holidays = Column(Boolean, nullable=False, server_default='0')
|
|
created_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp())
|
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
|
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
|
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
# Add relationships
|
|
academic_period = relationship("AcademicPeriod", foreign_keys=[academic_period_id])
|
|
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
|
exceptions = relationship("EventException", back_populates="event", cascade="all, delete-orphan")
|
|
# --- EventException: Store exceptions/overrides for recurring events ---
|
|
class EventException(Base):
|
|
__tablename__ = 'event_exceptions'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
event_id = Column(Integer, ForeignKey('events.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
exception_date = Column(Date, nullable=False, index=True) # Date of the exception/override
|
|
is_skipped = Column(Boolean, default=False, nullable=False) # If this occurrence is skipped
|
|
override_title = Column(String(100), nullable=True)
|
|
override_description = Column(Text, nullable=True)
|
|
override_start = Column(TIMESTAMP(timezone=True), nullable=True)
|
|
override_end = Column(TIMESTAMP(timezone=True), nullable=True)
|
|
created_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp())
|
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(), onupdate=func.current_timestamp())
|
|
|
|
event = relationship("Event", back_populates="exceptions")
|
|
|
|
|
|
|
|
class EventMedia(Base):
|
|
__tablename__ = 'event_media'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
academic_period_id = Column(Integer, ForeignKey(
|
|
# Optional für bessere Organisation
|
|
'academic_periods.id'), nullable=True, index=True)
|
|
media_type = Column(Enum(MediaType), nullable=False)
|
|
url = Column(String(255), nullable=False)
|
|
file_path = Column(String(255), nullable=True)
|
|
message_content = Column(Text, nullable=True)
|
|
uploaded_at = Column(TIMESTAMP, nullable=False,
|
|
default=lambda: datetime.now(timezone.utc))
|
|
|
|
# Add relationship
|
|
academic_period = relationship(
|
|
"AcademicPeriod", foreign_keys=[academic_period_id])
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"id": self.id,
|
|
"academic_period_id": self.academic_period_id,
|
|
"media_type": self.media_type.value if self.media_type else None,
|
|
"url": self.url,
|
|
"file_path": self.file_path,
|
|
"message_content": self.message_content,
|
|
}
|
|
|
|
|
|
class SchoolHoliday(Base):
|
|
__tablename__ = 'school_holidays'
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
name = Column(String(150), nullable=False)
|
|
start_date = Column(Date, nullable=False, index=True)
|
|
end_date = Column(Date, nullable=False, index=True)
|
|
region = Column(String(100), nullable=True, index=True)
|
|
source_file_name = Column(String(255), nullable=True)
|
|
imported_at = Column(TIMESTAMP(timezone=True),
|
|
server_default=func.current_timestamp())
|
|
|
|
__table_args__ = (
|
|
UniqueConstraint('name', 'start_date', 'end_date',
|
|
'region', name='uq_school_holidays_unique'),
|
|
)
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"start_date": self.start_date.isoformat() if self.start_date else None,
|
|
"end_date": self.end_date.isoformat() if self.end_date else None,
|
|
"region": self.region,
|
|
"source_file_name": self.source_file_name,
|
|
"imported_at": self.imported_at.isoformat() if self.imported_at else None,
|
|
}
|
|
|
|
# --- Conversions: Track PPT/PPTX/ODP -> PDF processing state ---
|
|
|
|
|
|
class ConversionStatus(enum.Enum):
|
|
pending = "pending"
|
|
processing = "processing"
|
|
ready = "ready"
|
|
failed = "failed"
|
|
|
|
|
|
class Conversion(Base):
|
|
__tablename__ = 'conversions'
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
# Source media to be converted
|
|
source_event_media_id = Column(
|
|
Integer,
|
|
ForeignKey('event_media.id', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
target_format = Column(String(10), nullable=False,
|
|
index=True) # e.g. 'pdf'
|
|
# relative to server/media
|
|
target_path = Column(String(512), nullable=True)
|
|
status = Column(Enum(ConversionStatus), nullable=False,
|
|
default=ConversionStatus.pending)
|
|
file_hash = Column(String(64), nullable=False) # sha256 of source file
|
|
started_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
|
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
|
error_message = Column(Text, nullable=True)
|
|
|
|
__table_args__ = (
|
|
# Fast lookup per media/format
|
|
Index('ix_conv_source_target', 'source_event_media_id', 'target_format'),
|
|
# Operational filtering
|
|
Index('ix_conv_status_target', 'status', 'target_format'),
|
|
# Idempotency: same source + target + file content should be unique
|
|
UniqueConstraint('source_event_media_id', 'target_format',
|
|
'file_hash', name='uq_conv_source_target_hash'),
|
|
)
|