Findet die automatische Suche kein Logo, kann pro Kunde eine Bilddatei hochgeladen werden. Ein Upload hat Vorrang vor der Suche. Freistellen: - remove_logo_background() entfernt den Hintergrund per Flood Fill von den Bildraendern her. Bewusst kein globaler Farbvergleich - sonst verschwindet auch Weiss innerhalb des Logos (etwa Text in einer Flaeche). Weiche Kante gegen Treppeneffekte. - Umgesetzt mit Pillow, das ueber qrcode[pil] ohnehin vorhanden ist. Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only Root-FS, ein Modell-Cache zur Laufzeit waere dort nicht moeglich. - Bereits transparente Bilder bleiben unangetastet. Ist der Bildrand nicht einfarbig, wird nicht freigestellt und die Oberflaeche weist darauf hin, statt ein beschaedigtes Logo zu liefern. - Checkbox "Hintergrund behalten" schaltet das Freistellen pro Kunde ab. - Gespeichert wird immer als PNG (JPEG kann keine Transparenz), grosse Bilder werden auf 800px verkleinert. Ablage unter media/<site>/logos/logo_<slot>.png, neue Route /media/<site>/logos/<datei>. Die URL traegt einen Zeitstempel, damit Player kein altes Logo aus dem Cache zeigen. Nebenbei behoben: welcome_data speicherte nur die erfolgreichen Kunden statt der Felder 1-3. Fiel Kunde 1 aus, rutschte Kunde 2 beim naechsten Aufruf in Feld 1 und Logo wie Ansprechpartner verschoben sich mit. Jetzt feldweise - dadurch bleibt auch ein hochgeladenes Logo beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht. Die drei Kundenbloecke im Formular sind zu einer Schleife zusammengefasst, damit die Upload-Felder nicht dreifach gepflegt werden muessen. README, AGENTS.md und Hilfeseite nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
585 lines
23 KiB
Python
585 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Willkommensseite-Generierung für CANCOM Simple Signage.
|
||
───────────────────────────────────────────────────────
|
||
Diese Hilfsdatei sucht anhand von Kundennamen Logos (via OpenAI + Brandfetch),
|
||
generiert daraus eine HTML-Willkommensseite und fügt sie in die Playlists aller
|
||
Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
|
||
"""
|
||
|
||
import os
|
||
import json
|
||
import time
|
||
from openai import OpenAI
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
MEDIA_DIR = os.path.join(BASE_DIR, "media")
|
||
|
||
WELCOME_FILENAME = "welcome.html"
|
||
|
||
|
||
# -------------------------------------------------
|
||
# OpenAI-Client
|
||
# -------------------------------------------------
|
||
|
||
def get_openai_client():
|
||
api_key = os.environ.get("OPENAI_API_KEY")
|
||
if not api_key:
|
||
raise ValueError("OPENAI_API_KEY environment variable not set")
|
||
return OpenAI(api_key=api_key)
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Brandfetch-Logo-Suche
|
||
# -------------------------------------------------
|
||
|
||
# -------------------------------------------------
|
||
# Logo-Upload: Freistellen
|
||
# -------------------------------------------------
|
||
|
||
LOGO_MAX_SIZE = 800 # Kantenlänge, auf die grosse Uploads verkleinert werden
|
||
LOGO_TOLERANCE = 32 # Farbabstand, bis zu dem ein Pixel als Hintergrund gilt
|
||
LOGO_BORDER_UNIFORMITY = 48 # Streuung, bis zu der ein Rand als einfarbig durchgeht
|
||
|
||
|
||
def _color_distance(a, b):
|
||
"""Abstand zweier RGB-Werte (Manhattan über die Kanäle, gemittelt)."""
|
||
return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) / 3.0
|
||
|
||
|
||
def _border_color(px, w, h):
|
||
"""Ermittelt die Hintergrundfarbe aus den Randpixeln.
|
||
Gibt (farbe, einfarbig) zurück - einfarbig=False, wenn der Rand zu
|
||
stark streut und ein Freistellen deshalb nicht sinnvoll ist."""
|
||
rand = []
|
||
schritt = max(1, min(w, h) // 100)
|
||
for x in range(0, w, schritt):
|
||
rand.append(px[x, 0][:3])
|
||
rand.append(px[x, h - 1][:3])
|
||
for y in range(0, h, schritt):
|
||
rand.append(px[0, y][:3])
|
||
rand.append(px[w - 1, y][:3])
|
||
|
||
mitte = tuple(sorted(k[i] for k in rand)[len(rand) // 2] for i in range(3))
|
||
streuung = sum(_color_distance(k, mitte) for k in rand) / len(rand)
|
||
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
|
||
|
||
|
||
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
||
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
|
||
Rändern her und macht ihn transparent.
|
||
|
||
Bewusst ein Flood Fill von den Rändern und kein globaler Farbvergleich -
|
||
so bleibt Weiss *innerhalb* des Logos (etwa Text in einer Fläche) erhalten.
|
||
|
||
Gibt (bild, hinweis) zurück. hinweis ist None bei Erfolg, sonst ein Text
|
||
für die Oberfläche - das Bild wird dann unverändert zurückgegeben.
|
||
"""
|
||
from collections import deque
|
||
from PIL import Image
|
||
|
||
img = image.convert("RGBA")
|
||
|
||
if max(img.size) > LOGO_MAX_SIZE:
|
||
img.thumbnail((LOGO_MAX_SIZE, LOGO_MAX_SIZE), Image.LANCZOS)
|
||
|
||
# Bereits transparent? Dann nicht anfassen.
|
||
alpha = img.getchannel("A")
|
||
if alpha.getextrema()[0] < 255:
|
||
return img, None
|
||
|
||
w, h = img.size
|
||
px = img.load()
|
||
bg, einfarbig = _border_color(px, w, h)
|
||
|
||
if not einfarbig:
|
||
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
|
||
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
|
||
|
||
# Flood Fill von allen Randpixeln aus
|
||
besucht = bytearray(w * h)
|
||
queue = deque()
|
||
for x in range(w):
|
||
for y in (0, h - 1):
|
||
queue.append((x, y))
|
||
for y in range(h):
|
||
for x in (0, w - 1):
|
||
queue.append((x, y))
|
||
|
||
while queue:
|
||
x, y = queue.popleft()
|
||
i = y * w + x
|
||
if besucht[i]:
|
||
continue
|
||
besucht[i] = 1
|
||
if _color_distance(px[x, y], bg) > tolerance:
|
||
continue
|
||
px[x, y] = (0, 0, 0, 0)
|
||
if x > 0: queue.append((x - 1, y))
|
||
if x < w - 1: queue.append((x + 1, y))
|
||
if y > 0: queue.append((x, y - 1))
|
||
if y < h - 1: queue.append((x, y + 1))
|
||
|
||
# Weiche Kante: Pixel direkt an der Freistellung teilweise durchscheinen
|
||
# lassen, sonst bleibt ein harter Treppenrand stehen.
|
||
for y in range(h):
|
||
for x in range(w):
|
||
r, g, b, a = px[x, y]
|
||
if a == 0:
|
||
continue
|
||
nachbar_frei = (
|
||
(x > 0 and px[x - 1, y][3] == 0) or
|
||
(x < w - 1 and px[x + 1, y][3] == 0) or
|
||
(y > 0 and px[x, y - 1][3] == 0) or
|
||
(y < h - 1 and px[x, y + 1][3] == 0)
|
||
)
|
||
if not nachbar_frei:
|
||
continue
|
||
d = _color_distance((r, g, b), bg)
|
||
if d < tolerance * 2:
|
||
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
|
||
|
||
if not any(px[x, y][3] == 0 for x in range(0, w, max(1, w // 50)) for y in (0, h - 1)):
|
||
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
|
||
|
||
return img, None
|
||
|
||
|
||
LOGO_DIRNAME = "logos"
|
||
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||
|
||
|
||
def save_uploaded_logo(file_storage, site, slot, freistellen=True):
|
||
"""Nimmt ein hochgeladenes Logo entgegen, stellt es optional frei und legt es
|
||
unter media/<site>/logos/logo_<slot>.png ab.
|
||
|
||
Gespeichert wird immer als PNG - JPEG kann keine Transparenz.
|
||
Gibt (url, hinweis) zurück; url ist None, wenn die Datei unbrauchbar war.
|
||
Der Zeitstempel in der URL verhindert, dass Player ein altes Logo
|
||
aus dem Cache anzeigen."""
|
||
from PIL import Image, UnidentifiedImageError
|
||
|
||
name = (file_storage.filename or "").lower()
|
||
if not name.endswith(LOGO_EXTENSIONS):
|
||
return None, f"Format nicht unterstützt ({', '.join(LOGO_EXTENSIONS)})."
|
||
|
||
try:
|
||
img = Image.open(file_storage.stream)
|
||
img.load()
|
||
except (UnidentifiedImageError, OSError) as e:
|
||
return None, f"Datei konnte nicht gelesen werden: {e}"
|
||
|
||
hinweis = None
|
||
if freistellen:
|
||
img, hinweis = remove_logo_background(img)
|
||
else:
|
||
img = img.convert("RGBA")
|
||
|
||
logo_dir = os.path.join(MEDIA_DIR, site, LOGO_DIRNAME)
|
||
os.makedirs(logo_dir, exist_ok=True)
|
||
dateiname = f"logo_{slot}.png"
|
||
img.save(os.path.join(logo_dir, dateiname), "PNG")
|
||
|
||
url = f"/media/{site}/{LOGO_DIRNAME}/{dateiname}?v={int(time.time())}"
|
||
print(f"✅ Generate - Logo hochgeladen: {url}"
|
||
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
|
||
return url, hinweis
|
||
|
||
|
||
def get_brandfetch_logo(domain):
|
||
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
|
||
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""
|
||
print(f"🔍 Generate - Versuche Brandfetch für Domain: {domain}")
|
||
logo_url = f"https://cdn.brandfetch.io/{domain}/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||
|
||
print(f"✅ Generate - Brandfetch Logo gefunden: {logo_url}")
|
||
return logo_url
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Logo-Ermittlung via OpenAI
|
||
# -------------------------------------------------
|
||
|
||
def search_customer_logo(customer_name):
|
||
"""
|
||
Sucht das Logo eines Kunden über einen zweistufigen Prozess:
|
||
1. OpenAI GPT-4 ermittelt die Domain des Unternehmens
|
||
2. Brandfetch CDN liefert das Logo zur Domain
|
||
|
||
Gibt die Logo-URL zurück oder None, wenn nichts gefunden wurde.
|
||
"""
|
||
print(f"✅ Generate - Suche nach Kundenlogo für: {customer_name}")
|
||
try:
|
||
client = get_openai_client()
|
||
|
||
response = client.chat.completions.create(
|
||
model="gpt-4",
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": """Du bist ein Experte für die Suche nach Domain Namen. Du findest anhand des Firmennamens die richtige Domain dazu
|
||
Gib nur den Domain Namen zurück, keine weiteren Informationen.
|
||
FALLBACK: Wenn du kein direkt Aufrufbare Domain findest, antworte mit "FALLBACK"."""
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": f"Finde die Domain für dasUnternehmen: {customer_name}"
|
||
}
|
||
],
|
||
temperature=0.1,
|
||
max_tokens=100
|
||
)
|
||
|
||
logo_url = response.choices[0].message.content.strip()
|
||
print(f"🔍 OpenAI Logo-URL: {logo_url}")
|
||
if logo_url.upper() == "FALLBACK":
|
||
logo_url = None
|
||
|
||
if logo_url:
|
||
brandfetch_url = get_brandfetch_logo(logo_url)
|
||
if brandfetch_url:
|
||
return brandfetch_url
|
||
|
||
return None
|
||
except Exception as e:
|
||
print(f"❌ Logo search error: {e}")
|
||
return None
|
||
|
||
|
||
# -------------------------------------------------
|
||
# HTML-Generierung
|
||
# -------------------------------------------------
|
||
|
||
def md_to_html(text):
|
||
"""Wandelt einfaches Markdown in HTML um (fett, kursiv, code, zeilenumbrüche, aufzählungen)."""
|
||
import re
|
||
text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
|
||
text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
|
||
text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
|
||
lines = text.split('\n')
|
||
parts = []
|
||
in_list = False
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
is_li = stripped.startswith('- ') or stripped.startswith('* ')
|
||
if is_li:
|
||
if not in_list:
|
||
parts.append('<ul>')
|
||
in_list = True
|
||
parts.append(f'<li>{stripped[2:]}</li>')
|
||
else:
|
||
if in_list:
|
||
parts.append('</ul>')
|
||
in_list = False
|
||
if stripped:
|
||
parts.append(stripped)
|
||
parts.append('<br>')
|
||
if in_list:
|
||
parts.append('</ul>')
|
||
if parts and parts[-1] == '<br>':
|
||
parts.pop()
|
||
return ''.join(parts)
|
||
|
||
|
||
def generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
|
||
"""
|
||
Generiert den HTML-Code für die Willkommensseite.
|
||
- Links oben: CANCOM-Logo + "meets" + Kundenlogos
|
||
- Unter jedem Logo: Ansprechpartner-Kontaktdaten (Landscape)
|
||
- Portrait: Kontaktdaten links neben den Logos
|
||
|
||
customer_names: Liste der Kundennamen (max. 3)
|
||
logo_urls: Liste der Logo-URLs (gleiche Reihenfolge)
|
||
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
|
||
site: Standort-Name für die Anzeige
|
||
background_url: URLs zum Hintergrundbild (None → Standard-Wallpaper)
|
||
"""
|
||
site_display = site.capitalize()
|
||
names_str = ", ".join(customer_names) if customer_names else "Unbekannt"
|
||
print(f"✅ Generate - Willkommensseite wird generiert für: {names_str} (site: {site})")
|
||
site_display = site.capitalize()
|
||
|
||
if contacts is None:
|
||
contacts = [""] * len(customer_names)
|
||
|
||
bg_image = background_url if background_url else "/static/wallpaper.png"
|
||
cancom_svg = '''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style><path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="currentColor" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>'''
|
||
|
||
logo_html = ""
|
||
for i, (name, url) in enumerate(zip(customer_names, logo_urls)):
|
||
if url:
|
||
contact_html = ""
|
||
if i < len(contacts) and contacts[i]:
|
||
contact_html = f'''<div class="contact-info">{md_to_html(contacts[i])}</div>'''
|
||
logo_html += f'''
|
||
<div class="customer-logo-item">
|
||
<img src="{url}" alt="{name}" class="customer-logo" crossorigin="anonymous">
|
||
<div class="customer-meta">
|
||
{contact_html}
|
||
</div>
|
||
</div>'''
|
||
|
||
html_content = f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Willkommen bei CANCOM</title>
|
||
<link rel="stylesheet"
|
||
href="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css">
|
||
<script defer
|
||
src="https://unpkg.com/@tabler/core@1.0.0-beta20/dist/js/tabler.min.js">
|
||
</script>
|
||
<style>
|
||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||
body {{
|
||
width: 100vw; height: 100vh; overflow: hidden;
|
||
font-family: 'Segoe UI', 'Helvetica Neue', sans-serif;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background-image: url("{bg_image}");
|
||
background-repeat: no-repeat; background-size: cover;
|
||
}}
|
||
.screen {{
|
||
width: 100%; height: 100%; position: relative;
|
||
display: flex; flex-direction: column;
|
||
align-items: flex-end; justify-content: space-between;
|
||
padding: 60px;
|
||
}}
|
||
.logo-section {{
|
||
display: flex; flex-direction: column;
|
||
align-items: flex-end; gap: 10px; z-index: 10;
|
||
}}
|
||
.cancom-logo {{
|
||
width: 235px; height: auto; color: #DA002D;
|
||
object-fit: contain;
|
||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.1));
|
||
}}
|
||
.meets {{
|
||
font-size: 2.5rem; font-weight: 400; color: #000;
|
||
margin-top: -8px; margin-bottom: 10px; letter-spacing: 1px;
|
||
}}
|
||
.customer-logos {{
|
||
display: flex; flex-direction: row;
|
||
align-items: flex-start; justify-content: flex-end;
|
||
gap: 48px; max-width: 100%;
|
||
}}
|
||
.customer-logo-item {{
|
||
flex: 0 0 280px;
|
||
display: flex; flex-direction: column;
|
||
align-items: stretch; justify-content: flex-start;
|
||
}}
|
||
.customer-meta {{
|
||
width: 100%; text-align: left;
|
||
}}
|
||
.customer-logo {{
|
||
width: 100%; height: 120px;
|
||
object-fit: contain;
|
||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15));
|
||
display: block;
|
||
}}
|
||
.contact-info {{
|
||
font-size: 1.3rem; color: #333; line-height: 1.3;
|
||
margin-top: 24px; text-align: left;
|
||
}}
|
||
.contact-info strong {{ color: #000; }}
|
||
.contact-info code {{
|
||
font-size: 0.8rem; padding: 1px 4px;
|
||
background: rgba(0,0,0,0.06); border-radius: 3px;
|
||
}}
|
||
.contact-info ul {{
|
||
margin: 0; padding-left: 0;
|
||
list-style-position: inside;
|
||
}}
|
||
.contact-info li {{
|
||
margin: 0; padding: 0;
|
||
}}
|
||
@media (orientation: portrait) {{
|
||
.customer-logos {{
|
||
flex-direction: column; gap: 20px;
|
||
}}
|
||
.customer-logo-item {{
|
||
flex: 0 0 auto;
|
||
flex-direction: row; gap: 40px;
|
||
width: 100%; max-width: 600px;
|
||
}}
|
||
.customer-logo {{
|
||
max-height: 100px; width: 180px; flex-shrink: 0;
|
||
order: 2;
|
||
}}
|
||
.contact-info {{
|
||
text-align: left;
|
||
}}
|
||
.customer-meta {{
|
||
display: flex; flex-direction: column;
|
||
align-items: flex-start; flex: 1;
|
||
order: 1;
|
||
}}
|
||
}}
|
||
.content {{
|
||
text-align: right; z-index: 10;
|
||
}}
|
||
.welcome-block {{
|
||
font-size: 4rem; font-weight: 700;
|
||
line-height: 1.3; color: #000;
|
||
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.3);
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="screen">
|
||
<div class="logo-section">
|
||
<div class="cancom-logo">{cancom_svg}</div>
|
||
<div class="meets">meets</div>
|
||
<div class="customer-logos">
|
||
{logo_html}
|
||
</div>
|
||
</div>
|
||
<div class="content">
|
||
<div class="welcome-block">
|
||
Herzlich <span style="color: #DA002D;">Willkommen</span><br>
|
||
<span style="color: #DA002D;">in</span> unserer<br>
|
||
Niederlassung <span style="color: #DA002D;">{site_display}!</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return html_content
|
||
|
||
|
||
# -------------------------------------------------
|
||
# Speichern + Playlist-Einfügung
|
||
# -------------------------------------------------
|
||
|
||
def welcome_target_screens(config, site):
|
||
"""Ermittelt die Screens eines Standorts, auf denen die Willkommensseite laufen soll.
|
||
|
||
Ein Screen ist Ziel, wenn "welcome_enabled" gesetzt ist. Ist der Schluessel bei
|
||
keinem Screen des Standorts vorhanden, greift das alte Verhalten: nur "lobby".
|
||
Damit bleiben bestehende Standorte ohne Konfigurationsaufwand unveraendert."""
|
||
screens = config.get("sites", {}).get(site, {}).get("screens", {})
|
||
|
||
configured = [name for name, cfg in screens.items()
|
||
if isinstance(cfg, dict) and cfg.get("welcome_enabled")]
|
||
if configured:
|
||
return sorted(configured)
|
||
|
||
# Kein Screen markiert: nur dann auf "lobby" zurückfallen, wenn noch nirgends
|
||
# eine bewusste Entscheidung getroffen wurde. Wurde das Häkchen überall
|
||
# entfernt, ist "keine Screens" das gewollte Ergebnis.
|
||
entschieden = any(isinstance(cfg, dict) and "welcome_enabled" in cfg
|
||
for cfg in screens.values())
|
||
if entschieden:
|
||
return []
|
||
|
||
return ["lobby"] if "lobby" in screens else []
|
||
|
||
|
||
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart",
|
||
background_url=None, screens=None):
|
||
"""Speichert die generierte Willkommensseite als welcome.html in den Zielscreens.
|
||
|
||
screens: Liste der Screen-Namen; None → Zielscreens aus der Konfiguration.
|
||
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
|
||
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
|
||
Gibt den Dateinamen zurück oder None bei Fehler.
|
||
|
||
Die Seite referenziert alle Bilder absolut (/static/..., /media/<site>/...),
|
||
ist also ortsunabhängig - eine Kopie je Screen rendert identisch."""
|
||
try:
|
||
from app import load_config
|
||
|
||
if screens is None:
|
||
screens = welcome_target_screens(load_config(), site)
|
||
|
||
if not screens:
|
||
print(f"⚠️ Generate - Kein Zielscreen für die Willkommensseite (site: {site})")
|
||
return None
|
||
|
||
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts,
|
||
site=site, background_url=background_url)
|
||
|
||
for screen in screens:
|
||
welcome_dir = os.path.join(MEDIA_DIR, site, screen)
|
||
os.makedirs(welcome_dir, exist_ok=True)
|
||
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
|
||
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(html_content)
|
||
if os.path.getsize(filepath) == 0:
|
||
raise IOError("File not written")
|
||
print(f"✅ Generate - Willkommensseite gespeichert in: {filepath}")
|
||
|
||
names_str = ", ".join(customer_names)
|
||
print(f"✅ Generate - Willkommensseite generiert für: {names_str} "
|
||
f"(site: {site}, screens: {', '.join(screens)})")
|
||
return WELCOME_FILENAME
|
||
except Exception as e:
|
||
print(f"❌ Error saving welcome page: {e}")
|
||
return None
|
||
|
||
|
||
def _strip_welcome(playlist, filename):
|
||
"""Entfernt alle welcome.html-Einträge aus einer Playlist (String- und Dict-Form)."""
|
||
playlist[:] = [
|
||
item for item in playlist
|
||
if not (
|
||
(isinstance(item, str) and item == filename) or
|
||
(isinstance(item, dict) and item.get("name") == filename)
|
||
)
|
||
]
|
||
|
||
|
||
def add_customer_to_playlists(html_filename, site="stuttgart", screens=None, valid_until=None):
|
||
"""Setzt die Willkommensseite an Position 0 der Playlist jedes Zielscreens.
|
||
|
||
screens: Liste der Zielscreens; None → aus der Konfiguration ermitteln.
|
||
valid_until: optionales Datum "YYYY-MM-DD". Ist es gesetzt, bekommt der
|
||
Eintrag eine Zeitsteuerung und verschwindet danach von selbst.
|
||
|
||
Aus allen übrigen Screens des Standorts wird die Seite entfernt, damit ein
|
||
abgewählter Screen nicht mit einer alten Begrüßung stehen bleibt.
|
||
Importiert load_config/save_config aus app.py (kein Zirkelimport)."""
|
||
try:
|
||
from app import load_config, save_config
|
||
|
||
config = load_config()
|
||
site_cfg = config.setdefault("sites", {}).setdefault(site, {"screens": {}})
|
||
all_screens = site_cfg.setdefault("screens", {})
|
||
|
||
if screens is None:
|
||
screens = welcome_target_screens(config, site)
|
||
|
||
# Nur Screens, die es wirklich gibt - kein stillschweigendes Anlegen
|
||
targets = [name for name in screens if name in all_screens]
|
||
|
||
entry = {"name": html_filename, "enabled": True}
|
||
if valid_until:
|
||
# Ganztaegig bis einschliesslich valid_until
|
||
entry["schedule"] = {
|
||
"mode": "daily",
|
||
"start": "00:00",
|
||
"end": "23:59",
|
||
"valid_until": valid_until
|
||
}
|
||
|
||
for name, screen_cfg in all_screens.items():
|
||
if not isinstance(screen_cfg, dict):
|
||
continue
|
||
playlist = screen_cfg.setdefault("playlist", [])
|
||
_strip_welcome(playlist, html_filename)
|
||
if name in targets:
|
||
playlist.insert(0, dict(entry))
|
||
|
||
save_config(config)
|
||
|
||
if targets:
|
||
print(f"✅ Generate - Willkommensseite platziert (site: {site}, "
|
||
f"screens: {', '.join(targets)})")
|
||
else:
|
||
print(f"⚠️ Generate - Kein Zielscreen vorhanden (site: {site})")
|
||
return bool(targets)
|
||
except Exception as e:
|
||
print(f"❌ Generate - Error adding to playlist: {e}")
|
||
return False
|