Ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz des Flood Fill. Der Fuellvorgang lief vom Rand in das Logo hinein und loeschte es mit - das Ergebnis war ein vollstaendig transparentes PNG, auf der Willkommensseite also gar kein Logo. Ohne jede Rueckmeldung: die bisherige Pruefung schlug nur an, wenn *nichts* entfernt wurde, nicht wenn alles entfernt wurde. remove_logo_background() legt jetzt vor dem Eingriff eine Kopie an und misst danach ueber das Alpha-Histogramm, wie viel sichtbar geblieben ist. Unter 2% gilt der Versuch als misslungen: das unveraenderte Bild wird zurueckgegeben und die Oberflaeche nennt den Grund samt Auswegen (transparentes PNG hochladen oder "Hintergrund behalten" ankreuzen). Getestet mit Logos von dunkelrot bis reinweiss auf weissem Grund sowie einem dunklen Logo auf schwarzem Grund. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
722 lines
28 KiB
Python
722 lines
28 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 trim_transparent_border(img):
|
||
"""Schneidet durchsichtige Raender weg, sodass das Logo die Bildflaeche
|
||
ausfuellt.
|
||
|
||
Ohne das erscheint ein hochgeladenes Logo kleiner als ein automatisch
|
||
gefundenes: die Willkommensseite skaliert mit object-fit: contain auf
|
||
eine feste Hoehe, und ein breiter durchsichtiger Rand zaehlt dabei mit.
|
||
Brandfetch-Logos sind bereits eng beschnitten."""
|
||
if img.mode != "RGBA":
|
||
return img
|
||
bbox = img.getchannel("A").getbbox()
|
||
if not bbox or bbox == (0, 0, img.width, img.height):
|
||
return img
|
||
return img.crop(bbox)
|
||
|
||
|
||
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.")
|
||
|
||
# Vor dem Eingriff sichern: ist das Logo farblich zu nah am Hintergrund,
|
||
# frisst der Flood Fill es mit auf und wir muessen zurueckfallen koennen.
|
||
original = img.copy()
|
||
|
||
# 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)))
|
||
|
||
# Wie viel ist uebrig geblieben? Ein helles Logo auf hellem Grund liegt
|
||
# innerhalb der Toleranz und wuerde komplett mit entfernt - das Ergebnis
|
||
# waere ein leeres Bild und auf der Willkommensseite gar kein Logo.
|
||
histogramm = img.getchannel("A").histogram()
|
||
sichtbar = sum(histogramm[1:])
|
||
anteil = sichtbar / float(w * h)
|
||
|
||
if anteil < 0.02:
|
||
return original, ("Das Logo ist farblich zu nah am Hintergrund – beim Freistellen "
|
||
"wäre fast nichts übrig geblieben. Es wurde deshalb unverändert "
|
||
"übernommen. Am besten ein PNG mit transparentem Hintergrund "
|
||
"hochladen oder die Option \u201eHintergrund behalten\u201c ankreuzen.")
|
||
|
||
if anteil > 0.999:
|
||
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
|
||
|
||
return img, None
|
||
|
||
|
||
# Logos liegen als Dateien direkt im Standort-Verzeichnis, nicht in einem
|
||
# Unterordner: admin() behandelt jeden Unterordner von media/<site>/ als Screen
|
||
# und legt ihn dauerhaft in der Konfiguration an. Das Hintergrundbild wird aus
|
||
# demselben Grund als media/<site>/background.* abgelegt.
|
||
LOGO_URL_PREFIX = "logos" # nur der Pfadbestandteil in der URL
|
||
LOGO_FILENAME = "logo_{slot}.png"
|
||
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")
|
||
|
||
# Nach dem Freistellen zuschneiden, damit das Logo genauso gross erscheint
|
||
# wie ein automatisch gefundenes
|
||
vorher = img.size
|
||
img = trim_transparent_border(img)
|
||
if img.size != vorher:
|
||
print(f"✅ Generate - Logo zugeschnitten: {vorher[0]}x{vorher[1]} → {img.size[0]}x{img.size[1]}")
|
||
|
||
site_dir = os.path.join(MEDIA_DIR, site)
|
||
os.makedirs(site_dir, exist_ok=True)
|
||
dateiname = LOGO_FILENAME.format(slot=slot)
|
||
img.save(os.path.join(site_dir, dateiname), "PNG")
|
||
|
||
url = f"/media/{site}/{LOGO_URL_PREFIX}/{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)
|
||
|
||
# Standort-Kopie zuerst - sie ueberlebt das Abschalten aller Screens
|
||
master = _welcome_master_path(site)
|
||
os.makedirs(os.path.dirname(master), exist_ok=True)
|
||
with open(master, "w", encoding="utf-8") as f:
|
||
f.write(html_content)
|
||
|
||
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 _welcome_path(site, screen):
|
||
return os.path.join(MEDIA_DIR, site, screen, WELCOME_FILENAME)
|
||
|
||
|
||
def _welcome_master_path(site):
|
||
"""Standort-Kopie der Willkommensseite. Sie liegt neben den Screen-Ordnern,
|
||
wird beim Generieren mitgeschrieben und nie geloescht - damit der Schalter
|
||
eines Screens die Seite auch dann noch platzieren kann, wenn sie zuvor
|
||
auf allen Screens abgeschaltet war."""
|
||
return os.path.join(MEDIA_DIR, site, WELCOME_FILENAME)
|
||
|
||
|
||
def find_welcome_source(site, config):
|
||
"""Sucht eine vorhandene welcome.html für einen Standort.
|
||
Die Seite ist je Standort identisch, jede Kopie taugt als Quelle.
|
||
Gibt den Pfad zurück oder None, wenn noch nie eine erzeugt wurde."""
|
||
master = _welcome_master_path(site)
|
||
if os.path.exists(master):
|
||
return master
|
||
|
||
screens = config.get("sites", {}).get(site, {}).get("screens", {})
|
||
for screen in sorted(screens):
|
||
pfad = _welcome_path(site, screen)
|
||
if os.path.exists(pfad):
|
||
return pfad
|
||
return None
|
||
|
||
|
||
def _remove_welcome_file(site, screen):
|
||
"""Löscht die welcome.html eines Screens, falls vorhanden."""
|
||
pfad = _welcome_path(site, screen)
|
||
if os.path.exists(pfad):
|
||
try:
|
||
os.remove(pfad)
|
||
except OSError as e:
|
||
print(f"⚠️ Generate - welcome.html in {site}/{screen} nicht löschbar: {e}")
|
||
|
||
|
||
def sync_welcome_for_screen(config, site, screen, enabled):
|
||
"""Bringt einen einzelnen Screen mit dem Schalter "Willkommensseite hier
|
||
anzeigen" in Einklang - ohne dass neu generiert werden muss.
|
||
|
||
enabled=True : vorhandene welcome.html hineinkopieren und vorne einfügen
|
||
enabled=False: Eintrag entfernen und die Kopie löschen
|
||
|
||
Verändert nur das übergebene config-Dict; das Speichern übernimmt der
|
||
Aufrufer. Gibt True zurück, wenn sich an der Playlist etwas geändert hat."""
|
||
screen_cfg = config.get("sites", {}).get(site, {}).get("screens", {}).get(screen)
|
||
if screen_cfg is None:
|
||
return False
|
||
|
||
playlist = screen_cfg.setdefault("playlist", [])
|
||
vorher = list(playlist)
|
||
|
||
if not enabled:
|
||
_strip_welcome(playlist, WELCOME_FILENAME)
|
||
_remove_welcome_file(site, screen)
|
||
return playlist != vorher
|
||
|
||
quelle = find_welcome_source(site, config)
|
||
if not quelle:
|
||
# Noch keine Willkommensseite erzeugt - dann gibt es nichts zu platzieren.
|
||
return False
|
||
|
||
ziel = _welcome_path(site, screen)
|
||
if os.path.abspath(quelle) != os.path.abspath(ziel):
|
||
try:
|
||
os.makedirs(os.path.dirname(ziel), exist_ok=True)
|
||
with open(quelle, "rb") as q, open(ziel, "wb") as z:
|
||
z.write(q.read())
|
||
except OSError as e:
|
||
print(f"❌ Generate - welcome.html nach {site}/{screen} nicht kopierbar: {e}")
|
||
return False
|
||
|
||
# Vorhandenen Eintrag entfernen und vorne neu einfügen
|
||
_strip_welcome(playlist, WELCOME_FILENAME)
|
||
playlist.insert(0, {"name": WELCOME_FILENAME, "enabled": True})
|
||
print(f"✅ Generate - Willkommensseite in {site}/{screen} platziert")
|
||
return True
|
||
|
||
|
||
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))
|
||
else:
|
||
# Auch die Datei entfernen - sonst bliebe auf einem abgewaehlten
|
||
# Screen eine verwaiste Kopie liegen, die bei leerer Playlist
|
||
# sogar noch abgespielt wuerde.
|
||
_remove_welcome_file(site, name)
|
||
|
||
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
|