📝 Commit Details:
This commit is contained in:
507
backend/utils/template_helpers.py
Normal file
507
backend/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