Files
infoscreen/models/models.py
RobbStarkAustria a7df3c2708 feat(dashboard): header user dropdown (Syncfusion) + proper logout; docs: clarify architecture; build: add splitbuttons; bump alpha.10
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.
2025-10-15 16:33:35 +00:00

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'),
)