feat: Major updates to backend structure and security enhancements
- Removed `COMMON_ERRORS.md` file to streamline documentation. - Added `Flask-Limiter` for rate limiting and `redis` for session management in `requirements.txt`. - Expanded `ROADMAP.md` to include completed security features and planned enhancements for version 2.2. - Enhanced `setup_myp.sh` for ultra-secure kiosk installation, including system hardening and security configurations. - Updated `app.py` to integrate CSRF protection and improved logging setup. - Refactored user model to include username and active status for better user management. - Improved job scheduler with uptime tracking and task management features. - Updated various templates for a more cohesive user interface and experience.
This commit is contained in:
@@ -4,7 +4,11 @@ import logging
|
||||
from typing import Dict, Callable, Any, List, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PyP100 import PyP110
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from models import Job, Printer, get_db_session
|
||||
|
||||
# Lazy logger initialization
|
||||
_logger = None
|
||||
@@ -27,6 +31,7 @@ class BackgroundTaskScheduler:
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._running = False
|
||||
self._start_time: Optional[datetime] = None
|
||||
|
||||
def register_task(self,
|
||||
task_id: str,
|
||||
@@ -165,6 +170,45 @@ class BackgroundTaskScheduler:
|
||||
for tid, task in self._tasks.items()
|
||||
]
|
||||
|
||||
def get_tasks(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Gibt alle Tasks mit ihren Konfigurationen zurück.
|
||||
|
||||
Returns:
|
||||
Dict: Dictionary mit Task-IDs als Schlüssel und Task-Konfigurationen als Werte
|
||||
"""
|
||||
return {
|
||||
task_id: {
|
||||
"interval": task["interval"],
|
||||
"enabled": task["enabled"],
|
||||
"last_run": task["last_run"].isoformat() if task["last_run"] else None,
|
||||
"next_run": task["next_run"].isoformat() if task["next_run"] else None
|
||||
}
|
||||
for task_id, task in self._tasks.items()
|
||||
}
|
||||
|
||||
def get_uptime(self) -> Optional[str]:
|
||||
"""
|
||||
Gibt die Laufzeit des Schedulers seit dem Start zurück.
|
||||
|
||||
Returns:
|
||||
str: Formatierte Laufzeit oder None, wenn der Scheduler nicht läuft
|
||||
"""
|
||||
if not self._running or not self._start_time:
|
||||
return None
|
||||
|
||||
uptime = datetime.now() - self._start_time
|
||||
days = uptime.days
|
||||
hours, remainder = divmod(uptime.seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if days > 0:
|
||||
return f"{days} Tage, {hours} Stunden, {minutes} Minuten"
|
||||
elif hours > 0:
|
||||
return f"{hours} Stunden, {minutes} Minuten"
|
||||
else:
|
||||
return f"{minutes} Minuten, {seconds} Sekunden"
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Startet den Scheduler.
|
||||
@@ -182,6 +226,7 @@ class BackgroundTaskScheduler:
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
self._running = True
|
||||
self._start_time = datetime.now()
|
||||
|
||||
logger.info("Scheduler gestartet")
|
||||
return True
|
||||
@@ -203,6 +248,7 @@ class BackgroundTaskScheduler:
|
||||
self._thread.join(timeout=5.0)
|
||||
|
||||
self._running = False
|
||||
self._start_time = None
|
||||
logger.info("Scheduler gestoppt")
|
||||
return True
|
||||
|
||||
@@ -216,29 +262,150 @@ class BackgroundTaskScheduler:
|
||||
return self._running
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Interne Methode zum Ausführen des Scheduler-Loops."""
|
||||
"""Hauptloop des Schedulers."""
|
||||
logger = get_scheduler_logger()
|
||||
logger.info("Scheduler-Thread gestartet")
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
now = datetime.now()
|
||||
|
||||
for task_id, task in self._tasks.items():
|
||||
if not task["enabled"] or not task["next_run"]:
|
||||
continue
|
||||
|
||||
if now >= task["next_run"]:
|
||||
logger = get_scheduler_logger()
|
||||
logger.info(f"Ausführung von Task {task_id}")
|
||||
|
||||
if now >= task["next_run"]:
|
||||
try:
|
||||
logger.debug(f"Führe Task {task_id} aus")
|
||||
task["func"](*task["args"], **task["kwargs"])
|
||||
logger.info(f"Task {task_id} erfolgreich ausgeführt")
|
||||
task["last_run"] = now
|
||||
task["next_run"] = now + timedelta(seconds=task["interval"])
|
||||
logger.debug(f"Task {task_id} erfolgreich ausgeführt, nächste Ausführung: {task['next_run']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Ausführung von Task {task_id}: {str(e)}")
|
||||
|
||||
task["last_run"] = now
|
||||
task["next_run"] = now + timedelta(seconds=task["interval"])
|
||||
|
||||
# 1 Sekunde warten und erneut prüfen
|
||||
self._stop_event.wait(1)
|
||||
# Trotzdem nächste Ausführung planen
|
||||
task["next_run"] = now + timedelta(seconds=task["interval"])
|
||||
|
||||
# Schlafenszeit berechnen (1 Sekunde oder weniger)
|
||||
time.sleep(1)
|
||||
|
||||
logger.info("Scheduler-Thread beendet")
|
||||
|
||||
# Singleton-Instanz
|
||||
scheduler = BackgroundTaskScheduler()
|
||||
|
||||
def toggle_plug(printer_id: int, state: bool) -> bool:
|
||||
"""
|
||||
Schaltet eine Tapo-Steckdose ein oder aus.
|
||||
|
||||
Args:
|
||||
printer_id: ID des Druckers
|
||||
state: True für ein, False für aus
|
||||
|
||||
Returns:
|
||||
bool: True wenn erfolgreich, False wenn fehlgeschlagen
|
||||
"""
|
||||
logger = get_logger("printers")
|
||||
db_session = get_db_session()
|
||||
|
||||
try:
|
||||
printer = db_session.query(Printer).get(printer_id)
|
||||
|
||||
if not printer:
|
||||
logger.error(f"Drucker mit ID {printer_id} nicht gefunden")
|
||||
db_session.close()
|
||||
return False
|
||||
|
||||
# TP-Link Tapo P110 Verbindung herstellen
|
||||
p110 = PyP110.P110(printer.plug_ip, printer.plug_username, printer.plug_password)
|
||||
p110.handshake() # Authentifizierung
|
||||
p110.login() # Login
|
||||
|
||||
# Steckdose ein-/ausschalten
|
||||
if state:
|
||||
p110.turnOn()
|
||||
logger.info(f"Steckdose für Drucker {printer.name} (ID: {printer_id}) eingeschaltet")
|
||||
else:
|
||||
p110.turnOff()
|
||||
logger.info(f"Steckdose für Drucker {printer.name} (ID: {printer_id}) ausgeschaltet")
|
||||
|
||||
db_session.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Schalten der Steckdose für Drucker {printer_id}: {str(e)}")
|
||||
db_session.close()
|
||||
return False
|
||||
|
||||
|
||||
def check_jobs():
|
||||
"""
|
||||
Überprüft alle geplanten und laufenden Jobs und schaltet Steckdosen entsprechend.
|
||||
|
||||
Diese Funktion wird vom Scheduler regelmäßig aufgerufen und:
|
||||
1. Prüft, ob geplante Jobs gestartet werden müssen
|
||||
2. Prüft, ob laufende Jobs beendet werden müssen
|
||||
3. Aktualisiert den Status der Jobs
|
||||
"""
|
||||
logger = get_logger("jobs")
|
||||
db_session = get_db_session()
|
||||
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# Geplante Jobs abrufen (mit 5 Minuten Puffer für vergangene Jobs)
|
||||
scheduled_jobs = db_session.query(Job).options(
|
||||
joinedload(Job.printer)
|
||||
).filter(
|
||||
Job.status == "scheduled",
|
||||
Job.start_at <= now
|
||||
).all()
|
||||
|
||||
# Laufende Jobs abrufen (mit 5 Minuten Sicherheitspuffer)
|
||||
running_jobs = db_session.query(Job).options(
|
||||
joinedload(Job.printer)
|
||||
).filter(
|
||||
Job.status == "running",
|
||||
Job.end_at <= now - timedelta(minutes=5) # 5 Minuten Sicherheitspuffer
|
||||
).all()
|
||||
|
||||
# Geplante Jobs starten
|
||||
for job in scheduled_jobs:
|
||||
logger.info(f"Starte geplanten Job {job.id}: {job.name} für Drucker {job.printer.name}")
|
||||
|
||||
# Steckdose einschalten
|
||||
if toggle_plug(job.printer_id, True):
|
||||
# Job als laufend markieren
|
||||
job.status = "running"
|
||||
job.end_at = job.start_at + timedelta(minutes=job.duration_minutes)
|
||||
db_session.commit()
|
||||
logger.info(f"Job {job.id} gestartet: läuft bis {job.end_at}")
|
||||
else:
|
||||
logger.error(f"Fehler beim Starten von Job {job.id}: Steckdose konnte nicht eingeschaltet werden")
|
||||
|
||||
# Beendete Jobs stoppen
|
||||
for job in running_jobs:
|
||||
logger.info(f"Beende laufenden Job {job.id}: {job.name} für Drucker {job.printer.name}")
|
||||
|
||||
# Steckdose ausschalten
|
||||
if toggle_plug(job.printer_id, False):
|
||||
# Job als beendet markieren
|
||||
job.status = "finished"
|
||||
job.actual_end_time = now
|
||||
db_session.commit()
|
||||
logger.info(f"Job {job.id} beendet: tatsächliche Endzeit {job.actual_end_time}")
|
||||
else:
|
||||
logger.error(f"Fehler beim Beenden von Job {job.id}: Steckdose konnte nicht ausgeschaltet werden")
|
||||
|
||||
db_session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler im Job-Scheduler: {str(e)}")
|
||||
db_session.close()
|
||||
|
||||
|
||||
# Globaler Scheduler
|
||||
scheduler = BackgroundTaskScheduler()
|
||||
|
||||
# Job-Überprüfungs-Task registrieren (alle 60 Sekunden)
|
||||
scheduler.register_task(
|
||||
task_id="check_jobs",
|
||||
func=check_jobs,
|
||||
interval=60,
|
||||
enabled=True
|
||||
)
|
50
backend/app/utils/scheduler.py
Normal file
50
backend/app/utils/scheduler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Scheduler utility functions for the admin panel.
|
||||
"""
|
||||
|
||||
from utils.job_scheduler import scheduler
|
||||
|
||||
def scheduler_is_running():
|
||||
"""
|
||||
Überprüft, ob der Job-Scheduler läuft.
|
||||
|
||||
Returns:
|
||||
bool: True wenn der Scheduler aktiv ist, sonst False
|
||||
"""
|
||||
return scheduler.is_running()
|
||||
|
||||
def start_scheduler():
|
||||
"""
|
||||
Startet den Job-Scheduler.
|
||||
|
||||
Returns:
|
||||
bool: True wenn erfolgreich gestartet, False wenn bereits läuft
|
||||
"""
|
||||
return scheduler.start()
|
||||
|
||||
def stop_scheduler():
|
||||
"""
|
||||
Stoppt den Job-Scheduler.
|
||||
|
||||
Returns:
|
||||
bool: True wenn erfolgreich gestoppt, False wenn nicht läuft
|
||||
"""
|
||||
return scheduler.stop()
|
||||
|
||||
def get_scheduler_uptime():
|
||||
"""
|
||||
Gibt die Laufzeit des Schedulers zurück.
|
||||
|
||||
Returns:
|
||||
str: Formatierte Laufzeit oder None, wenn der Scheduler nicht läuft
|
||||
"""
|
||||
return scheduler.get_uptime()
|
||||
|
||||
def get_scheduler_tasks():
|
||||
"""
|
||||
Gibt alle registrierten Tasks im Scheduler zurück.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary mit Task-IDs als Schlüssel und Task-Konfigurationen als Werte
|
||||
"""
|
||||
return scheduler.get_tasks()
|
507
backend/app/utils/template_helpers.py
Normal file
507
backend/app/utils/template_helpers.py
Normal file
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
Template Helpers für MYP Platform
|
||||
Jinja2 Helper-Funktionen für UI-Komponenten
|
||||
"""
|
||||
|
||||
from flask import current_app, url_for, request
|
||||
from markupsafe import Markup
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
import calendar
|
||||
import random
|
||||
|
||||
|
||||
class UIHelpers:
|
||||
"""UI-Helper-Klasse für Template-Funktionen"""
|
||||
|
||||
@staticmethod
|
||||
def component_button(text: str, type: str = "primary", size: str = "md",
|
||||
classes: str = "", icon: str = "", onclick: str = "",
|
||||
disabled: bool = False, **attrs) -> Markup:
|
||||
"""
|
||||
Erstellt einen Button mit Tailwind-Klassen
|
||||
|
||||
Args:
|
||||
text: Button-Text
|
||||
type: Button-Typ (primary, secondary, danger, success)
|
||||
size: Button-Größe (sm, md, lg)
|
||||
classes: Zusätzliche CSS-Klassen
|
||||
icon: SVG-Icon-Code
|
||||
onclick: JavaScript-Code für onclick
|
||||
disabled: Button deaktiviert
|
||||
**attrs: Zusätzliche HTML-Attribute
|
||||
"""
|
||||
base_classes = ["btn"]
|
||||
|
||||
# Typ-spezifische Klassen
|
||||
type_classes = {
|
||||
"primary": "btn-primary",
|
||||
"secondary": "btn-secondary",
|
||||
"danger": "btn-danger",
|
||||
"success": "btn-success"
|
||||
}
|
||||
base_classes.append(type_classes.get(type, "btn-primary"))
|
||||
|
||||
# Größen-spezifische Klassen
|
||||
size_classes = {
|
||||
"sm": "btn-sm",
|
||||
"md": "",
|
||||
"lg": "btn-lg"
|
||||
}
|
||||
if size_classes.get(size):
|
||||
base_classes.append(size_classes[size])
|
||||
|
||||
if disabled:
|
||||
base_classes.append("opacity-50 cursor-not-allowed")
|
||||
|
||||
# Zusätzliche Klassen hinzufügen
|
||||
if classes:
|
||||
base_classes.append(classes)
|
||||
|
||||
# HTML-Attribute aufbauen
|
||||
attrs_str = ""
|
||||
for key, value in attrs.items():
|
||||
attrs_str += f' {key.replace("_", "-")}="{value}"'
|
||||
|
||||
if onclick:
|
||||
attrs_str += f' onclick="{onclick}"'
|
||||
|
||||
if disabled:
|
||||
attrs_str += ' disabled'
|
||||
|
||||
# Icon und Text kombinieren
|
||||
content = ""
|
||||
if icon:
|
||||
content += f'<span class="inline-block mr-2">{icon}</span>'
|
||||
content += text
|
||||
|
||||
html = f'''<button class="{" ".join(base_classes)}"{attrs_str}>
|
||||
{content}
|
||||
</button>'''
|
||||
|
||||
return Markup(html)
|
||||
|
||||
@staticmethod
|
||||
def component_badge(text: str, type: str = "blue", classes: str = "") -> Markup:
|
||||
"""
|
||||
Erstellt ein Badge/Tag-Element
|
||||
|
||||
Args:
|
||||
text: Badge-Text
|
||||
type: Badge-Typ (blue, green, red, yellow, purple)
|
||||
classes: Zusätzliche CSS-Klassen
|
||||
"""
|
||||
base_classes = ["badge", f"badge-{type}"]
|
||||
|
||||
if classes:
|
||||
base_classes.append(classes)
|
||||
|
||||
html = f'<span class="{" ".join(base_classes)}">{text}</span>'
|
||||
return Markup(html)
|
||||
|
||||
@staticmethod
|
||||
def component_status_badge(status: str, type: str = "job") -> Markup:
|
||||
"""
|
||||
Erstellt ein Status-Badge für Jobs oder Drucker
|
||||
|
||||
Args:
|
||||
status: Status-Wert
|
||||
type: Typ (job, printer)
|
||||
"""
|
||||
if type == "job":
|
||||
class_name = f"job-status job-{status}"
|
||||
else:
|
||||
class_name = f"printer-status printer-{status}"
|
||||
|
||||
# Status-Text übersetzen
|
||||
translations = {
|
||||
"job": {
|
||||
"queued": "In Warteschlange",
|
||||
"printing": "Wird gedruckt",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"cancelled": "Abgebrochen",
|
||||
"paused": "Pausiert"
|
||||
},
|
||||
"printer": {
|
||||
"ready": "Bereit",
|
||||
"busy": "Beschäftigt",
|
||||
"error": "Fehler",
|
||||
"offline": "Offline",
|
||||
"maintenance": "Wartung"
|
||||
}
|
||||
}
|
||||
|
||||
display_text = translations.get(type, {}).get(status, status)
|
||||
|
||||
html = f'<span class="{class_name}">{display_text}</span>'
|
||||
return Markup(html)
|
||||
|
||||
@staticmethod
|
||||
def component_card(title: str = "", content: str = "", footer: str = "",
|
||||
classes: str = "", hover: bool = False) -> Markup:
|
||||
"""
|
||||
Erstellt eine Karte
|
||||
|
||||
Args:
|
||||
title: Karten-Titel
|
||||
content: Karten-Inhalt
|
||||
footer: Karten-Footer
|
||||
classes: Zusätzliche CSS-Klassen
|
||||
hover: Hover-Effekt aktivieren
|
||||
"""
|
||||
base_classes = ["card"]
|
||||
|
||||
if hover:
|
||||
base_classes.append("card-hover")
|
||||
|
||||
if classes:
|
||||
base_classes.append(classes)
|
||||
|
||||
html_parts = [f'<div class="{" ".join(base_classes)}">']
|
||||
|
||||
if title:
|
||||
html_parts.append(f'<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">{title}</h3>')
|
||||
|
||||
if content:
|
||||
html_parts.append(f'<div class="text-slate-600 dark:text-slate-300">{content}</div>')
|
||||
|
||||
if footer:
|
||||
html_parts.append(f'<div class="mt-4 pt-4 border-t border-light-border dark:border-dark-border">{footer}</div>')
|
||||
|
||||
html_parts.append('</div>')
|
||||
|
||||
return Markup("".join(html_parts))
|
||||
|
||||
@staticmethod
|
||||
def component_alert(message: str, type: str = "info", dismissible: bool = False) -> Markup:
|
||||
"""
|
||||
Erstellt eine Alert-Benachrichtigung
|
||||
|
||||
Args:
|
||||
message: Alert-Nachricht
|
||||
type: Alert-Typ (info, success, warning, error)
|
||||
dismissible: Schließbar machen
|
||||
"""
|
||||
base_classes = ["alert", f"alert-{type}"]
|
||||
|
||||
html_parts = [f'<div class="{" ".join(base_classes)}">']
|
||||
|
||||
if dismissible:
|
||||
html_parts.append('''
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
''')
|
||||
|
||||
html_parts.append(f'<p>{message}</p>')
|
||||
|
||||
if dismissible:
|
||||
html_parts.append('''
|
||||
</div>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
class="text-current opacity-70 hover:opacity-100">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
''')
|
||||
|
||||
html_parts.append('</div>')
|
||||
|
||||
return Markup("".join(html_parts))
|
||||
|
||||
@staticmethod
|
||||
def component_modal(modal_id: str, title: str, content: str,
|
||||
footer: str = "", size: str = "md") -> Markup:
|
||||
"""
|
||||
Erstellt ein Modal-Dialog
|
||||
|
||||
Args:
|
||||
modal_id: Eindeutige Modal-ID
|
||||
title: Modal-Titel
|
||||
content: Modal-Inhalt
|
||||
footer: Modal-Footer
|
||||
size: Modal-Größe (sm, md, lg, xl)
|
||||
"""
|
||||
size_classes = {
|
||||
"sm": "max-w-md",
|
||||
"md": "max-w-lg",
|
||||
"lg": "max-w-2xl",
|
||||
"xl": "max-w-4xl"
|
||||
}
|
||||
|
||||
max_width = size_classes.get(size, "max-w-lg")
|
||||
|
||||
html = f'''
|
||||
<div id="{modal_id}" class="fixed inset-0 z-50 hidden">
|
||||
<div class="flex items-center justify-center min-h-screen px-4">
|
||||
<div class="modal-content bg-white dark:bg-slate-800 rounded-lg shadow-xl transform scale-95 opacity-0 transition-all duration-150 w-full {max_width}">
|
||||
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
|
||||
<button data-modal-close="{modal_id}" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
{content}
|
||||
</div>
|
||||
{f'<div class="px-6 py-4 border-t border-slate-200 dark:border-slate-700">{footer}</div>' if footer else ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
return Markup(html)
|
||||
|
||||
@staticmethod
|
||||
def component_table(headers: List[str], rows: List[List[str]],
|
||||
classes: str = "", striped: bool = True) -> Markup:
|
||||
"""
|
||||
Erstellt eine styled Tabelle
|
||||
|
||||
Args:
|
||||
headers: Tabellen-Kopfzeilen
|
||||
rows: Tabellen-Zeilen
|
||||
classes: Zusätzliche CSS-Klassen
|
||||
striped: Zebra-Streifen aktivieren
|
||||
"""
|
||||
html_parts = ['<div class="table-container">']
|
||||
|
||||
table_classes = ["table-styled"]
|
||||
if classes:
|
||||
table_classes.append(classes)
|
||||
|
||||
html_parts.append(f'<table class="{" ".join(table_classes)}">')
|
||||
|
||||
# Kopfzeilen
|
||||
html_parts.append('<thead><tr>')
|
||||
for header in headers:
|
||||
html_parts.append(f'<th>{header}</th>')
|
||||
html_parts.append('</tr></thead>')
|
||||
|
||||
# Zeilen
|
||||
html_parts.append('<tbody>')
|
||||
for i, row in enumerate(rows):
|
||||
row_classes = ""
|
||||
if striped and i % 2 == 1:
|
||||
row_classes = 'class="bg-slate-50 dark:bg-slate-800/50"'
|
||||
html_parts.append(f'<tr {row_classes}>')
|
||||
for cell in row:
|
||||
html_parts.append(f'<td>{cell}</td>')
|
||||
html_parts.append('</tr>')
|
||||
html_parts.append('</tbody>')
|
||||
|
||||
html_parts.append('</table></div>')
|
||||
|
||||
return Markup("".join(html_parts))
|
||||
|
||||
@staticmethod
|
||||
def format_datetime_german(dt: datetime, format_str: str = "%d.%m.%Y %H:%M") -> str:
|
||||
"""
|
||||
Formatiert Datetime für deutsche Anzeige
|
||||
|
||||
Args:
|
||||
dt: Datetime-Objekt
|
||||
format_str: Format-String
|
||||
"""
|
||||
if not dt:
|
||||
return ""
|
||||
return dt.strftime(format_str)
|
||||
|
||||
@staticmethod
|
||||
def format_duration(minutes: int) -> str:
|
||||
"""
|
||||
Formatiert Dauer in Minuten zu lesbarem Format
|
||||
|
||||
Args:
|
||||
minutes: Dauer in Minuten
|
||||
"""
|
||||
if not minutes:
|
||||
return "0 Min"
|
||||
|
||||
if minutes < 60:
|
||||
return f"{minutes} Min"
|
||||
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
|
||||
if remaining_minutes == 0:
|
||||
return f"{hours} Std"
|
||||
|
||||
return f"{hours} Std {remaining_minutes} Min"
|
||||
|
||||
@staticmethod
|
||||
def json_encode(data: Any) -> str:
|
||||
"""
|
||||
Enkodiert Python-Daten als JSON für JavaScript
|
||||
|
||||
Args:
|
||||
data: Zu enkodierendes Objekt
|
||||
"""
|
||||
return json.dumps(data, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def register_template_helpers(app):
|
||||
"""
|
||||
Registriert alle Template-Helper bei der Flask-App
|
||||
|
||||
Args:
|
||||
app: Flask-App-Instanz
|
||||
"""
|
||||
# Funktionen registrieren
|
||||
app.jinja_env.globals['ui_button'] = UIHelpers.component_button
|
||||
app.jinja_env.globals['ui_badge'] = UIHelpers.component_badge
|
||||
app.jinja_env.globals['ui_status_badge'] = UIHelpers.component_status_badge
|
||||
app.jinja_env.globals['ui_card'] = UIHelpers.component_card
|
||||
app.jinja_env.globals['ui_alert'] = UIHelpers.component_alert
|
||||
app.jinja_env.globals['ui_modal'] = UIHelpers.component_modal
|
||||
app.jinja_env.globals['ui_table'] = UIHelpers.component_table
|
||||
|
||||
# Filter registrieren
|
||||
app.jinja_env.filters['german_datetime'] = UIHelpers.format_datetime_german
|
||||
app.jinja_env.filters['duration'] = UIHelpers.format_duration
|
||||
app.jinja_env.filters['json'] = UIHelpers.json_encode
|
||||
|
||||
# Zusätzliche globale Variablen
|
||||
app.jinja_env.globals['current_year'] = datetime.now().year
|
||||
|
||||
# Icons als globale Variablen
|
||||
icons = {
|
||||
'check': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>',
|
||||
'x': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>',
|
||||
'plus': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>',
|
||||
'edit': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>',
|
||||
'trash': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>',
|
||||
'printer': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path></svg>',
|
||||
'dashboard': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path></svg>',
|
||||
}
|
||||
|
||||
app.jinja_env.globals['icons'] = icons
|
||||
|
||||
@app.context_processor
|
||||
def utility_processor():
|
||||
"""Fügt nützliche Hilfsfunktionen zu Jinja hinzu."""
|
||||
return dict(
|
||||
active_page=active_page,
|
||||
format_datetime=format_datetime,
|
||||
format_date=format_date,
|
||||
format_time=format_time,
|
||||
random_avatar_color=random_avatar_color,
|
||||
get_initials=get_initials,
|
||||
render_progress_bar=render_progress_bar
|
||||
)
|
||||
|
||||
def active_page(path):
|
||||
"""
|
||||
Überprüft, ob der aktuelle Pfad mit dem gegebenen Pfad übereinstimmt.
|
||||
"""
|
||||
if request.path == path:
|
||||
return 'active'
|
||||
return ''
|
||||
|
||||
def format_datetime(value, format='%d.%m.%Y %H:%M'):
|
||||
"""
|
||||
Formatiert ein Datum mit Uhrzeit nach deutschem Format.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value.strftime(format)
|
||||
|
||||
def format_date(value, format='%d.%m.%Y'):
|
||||
"""
|
||||
Formatiert ein Datum nach deutschem Format.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value.strftime(format)
|
||||
|
||||
def format_time(value, format='%H:%M'):
|
||||
"""
|
||||
Formatiert eine Uhrzeit nach deutschem Format.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value.strftime(format)
|
||||
|
||||
def random_avatar_color():
|
||||
"""
|
||||
Gibt eine zufällige Hintergrundfarbe für Avatare zurück.
|
||||
"""
|
||||
colors = [
|
||||
'bg-blue-100 text-blue-800',
|
||||
'bg-green-100 text-green-800',
|
||||
'bg-yellow-100 text-yellow-800',
|
||||
'bg-red-100 text-red-800',
|
||||
'bg-indigo-100 text-indigo-800',
|
||||
'bg-purple-100 text-purple-800',
|
||||
'bg-pink-100 text-pink-800',
|
||||
'bg-gray-100 text-gray-800',
|
||||
]
|
||||
return random.choice(colors)
|
||||
|
||||
def get_initials(name, max_length=2):
|
||||
"""
|
||||
Extrahiert die Initialen eines Namens.
|
||||
"""
|
||||
if not name:
|
||||
return "?"
|
||||
|
||||
parts = name.split()
|
||||
if len(parts) == 1:
|
||||
return name[0:max_length].upper()
|
||||
|
||||
initials = ""
|
||||
for part in parts:
|
||||
if part and len(initials) < max_length:
|
||||
initials += part[0].upper()
|
||||
|
||||
return initials
|
||||
|
||||
def render_progress_bar(value, color='blue'):
|
||||
"""
|
||||
Rendert einen Fortschrittsbalken ohne Inline-Styles.
|
||||
|
||||
Args:
|
||||
value (int): Der Prozentwert für den Fortschrittsbalken (0-100)
|
||||
color (str): Die Farbe des Balkens (blue, green, purple, red)
|
||||
|
||||
Returns:
|
||||
str: HTML-Markup für den Fortschrittsbalken
|
||||
"""
|
||||
css_class = f"progress-bar-fill-{color}"
|
||||
|
||||
# Sicherstellen, dass der Wert im gültigen Bereich liegt
|
||||
if value < 0:
|
||||
value = 0
|
||||
elif value > 100:
|
||||
value = 100
|
||||
|
||||
# Erstellen des DOM-Struktur für den Fortschrittsbalken
|
||||
html = f"""
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar-fill {css_class}" data-width="{value}"></div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return Markup(html)
|
Reference in New Issue
Block a user