Erster Wurf

This commit is contained in:
2026-02-21 15:16:27 +01:00
parent 70ba4a0279
commit c6a3c5b3df
10 changed files with 270 additions and 107 deletions

View File

@@ -1,7 +1,7 @@
google_sheet: google_sheet:
sheet_id: "15_SuNjWruwHcX4_R5OZPYb8Zwa__DrQ56ZcAodPj7QQ" sheet_id: "15_SuNjWruwHcX4_R5OZPYb8Zwa__DrQ56ZcAodPj7QQ"
gid_times: "0" # Meistens 0 für das erste Blatt gid_times: "635683780"
gid_remarks: "92621051" # Beispiel-ID deines Bemerkungen-Blatts gid_remarks: "1437056802"
date_column: "Datum" date_column: "Datum"
processing: processing:

View File

@@ -1,66 +1,83 @@
import pandas as pd import pandas as pd
import requests import requests
import markdown
from io import StringIO from io import StringIO
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .config_loader import config from .config_loader import config
_cache = {"events": None, "remarks": None} _cache = {"events": None, "remarks": None, "timestamp": None}
HEADERS = {"User-Agent": "Mozilla/5.0"} HEADERS = {"User-Agent": "Mozilla/5.0"}
def invalidate_cache(): def invalidate_cache():
global _cache global _cache
_cache = {"events": None, "remarks": None} _cache = {"events": None, "remarks": None, "timestamp": None}
print("DEBUG: Cache wurde manuell geleert.")
return "Cache gelöscht" return "Cache gelöscht"
def get_upcoming_events(): def _is_cache_valid():
if _cache["events"] is not None: if _cache["timestamp"] is None:
return _cache["events"] return False
return (datetime.now() - _cache["timestamp"]) < timedelta(hours=1)
url = config['links']['times_csv']
print(f"DEBUG: Lade Zeiten von URL: {url}") def get_upcoming_events(days_to_show=None, limit=None):
# Sofort Standardwert aus Config setzen, falls None oder 0
response = requests.get(url, headers=HEADERS) if not days_to_show:
df = pd.read_csv(StringIO(response.text)) days_to_show = config['processing']['days_to_show']
# Debug: Spaltennamen prüfen # 1. Daten laden (entweder aus Cache oder von Google)
print(f"DEBUG: Spalten in Zeiten-Sheet gefunden: {df.columns.tolist()}") if not _is_cache_valid() or _cache["events"] is None:
url = config['links']['times_csv']
response = requests.get(url, headers=HEADERS)
response.raise_for_status()
response.encoding = 'utf-8'
df = pd.read_csv(StringIO(response.text))
# Typografie: Bindestrich durch En-Dash () ersetzen
for col in ['Morgen', 'Nachmittag']:
df[col] = df[col].fillna('').astype(str).str.replace('-', '', regex=False)
date_col = config['google_sheet']['date_column']
df = df.dropna(subset=[date_col])
df[date_col] = pd.to_datetime(df[date_col], dayfirst=True, errors='coerce')
wt_map = {0: 'Mo', 1: 'Di', 2: 'Mi', 3: 'Do', 4: 'Fr', 5: 'Sa', 6: 'So'}
df['Wochentag'] = df[date_col].dt.weekday.map(wt_map)
_cache["events"] = df.sort_values(by=date_col).to_dict(orient='records')
_cache["timestamp"] = datetime.now()
date_col = config['google_sheet']['date_column']
df = df.dropna(subset=[date_col])
df[date_col] = pd.to_datetime(df[date_col], dayfirst=True, errors='coerce')
df = df.dropna(subset=[date_col]).fillna('')
heute = pd.Timestamp(datetime.now().date()) heute = pd.Timestamp(datetime.now().date())
ende = heute + timedelta(days=config['processing']['days_to_show']) date_col = config['google_sheet']['date_column']
# PRIORITÄT 1: Zeilen-Limit (gewinnt immer)
if limit and limit > 0:
return [e for e in _cache["events"] if e[date_col] >= heute][:limit]
mask = (df[date_col] >= heute) & (df[date_col] <= ende) # PRIORITÄT 2: Tage-Logik
gefilterte = df.loc[mask].sort_values(by=date_col).copy() ende = heute + timedelta(days=int(days_to_show))
return [e for e in _cache["events"] if heute <= e[date_col] <= ende]
wt_map = {0: 'Mo', 1: 'Di', 2: 'Mi', 3: 'Do', 4: 'Fr', 5: 'Sa', 6: 'So'}
gefilterte['Wochentag'] = gefilterte[date_col].dt.weekday.map(wt_map)
_cache["events"] = gefilterte.to_dict(orient='records')
return _cache["events"]
def get_remarks(): def get_remarks():
if _cache["remarks"] is not None: if _is_cache_valid() and _cache["remarks"] is not None:
return _cache["remarks"] return _cache["remarks"]
url = config['links']['remarks_csv'] url = config['links']['remarks_csv']
print(f"DEBUG: Lade Bemerkungen von URL: {url}")
response = requests.get(url, headers=HEADERS) response = requests.get(url, headers=HEADERS)
response.raise_for_status()
# Debug: Die ersten 100 Zeichen der Antwort sehen response.encoding = 'utf-8'
print(f"DEBUG: Raw Data Bemerkungen (Anfang): {response.text[:100]}...")
df = pd.read_csv(StringIO(response.text), skiprows=2, header=None) df = pd.read_csv(StringIO(response.text), skiprows=2, header=None)
if not df.empty: if not df.empty:
print(f"DEBUG: Bemerkungen-DF Head:\n{df.head(3)}") raw_remarks = df[0].dropna().astype(str).tolist()
remarks = df[0].dropna().astype(str).tolist() processed = []
_cache["remarks"] = [r.strip() for r in remarks if r.strip()] for r in raw_remarks:
html = markdown.markdown(r.strip())
if html.startswith("<p>") and html.endswith("</p>"):
html = html[3:-4]
processed.append(html)
_cache["remarks"] = processed
_cache["timestamp"] = datetime.now()
else: else:
_cache["remarks"] = [] _cache["remarks"] = []

View File

@@ -1,4 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from routers.web_routes import router as web_router from routers.web_routes import router as web_router
import uvicorn import uvicorn
from core.config_loader import config from core.config_loader import config
@@ -7,6 +8,7 @@ app = FastAPI()
# Router einbinden # Router einbinden
app.include_router(web_router) app.include_router(web_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host=config['server']['host'], port=config['server']['port']) uvicorn.run(app, host=config['server']['host'], port=config['server']['port'])

View File

@@ -4,4 +4,5 @@ pyyaml
fastapi fastapi
uvicorn uvicorn
jinja2 jinja2
python-multipart python-multipart
markdown

View File

@@ -2,31 +2,39 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from core.data_processor import get_upcoming_events, get_remarks, invalidate_cache from core.data_processor import get_upcoming_events, get_remarks, invalidate_cache
from core.config_loader import config
from pathlib import Path from pathlib import Path
router = APIRouter() router = APIRouter()
BASE_DIR = Path(__file__).parent.parent BASE_DIR = Path(__file__).parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
# Admin auf Root @router.get("/zeiten", response_class=HTMLResponse)
@router.get("/", response_class=HTMLResponse) async def public_table(request: Request, days: int = None, lines: int = None, test: bool = False):
async def admin_page(request: Request): # Wenn nichts angegeben wurde, greift der Standard aus der Config
from core.config_loader import config if not days and not lines:
return templates.TemplateResponse("admin.html", { days = config['processing']['days_to_show']
"request": request, "links": config['links']
events = get_upcoming_events(days_to_show=days, limit=lines)
remarks = get_remarks()
# Template 'zeiten.html' laden und 'test' Parameter durchreichen
return templates.TemplateResponse("zeiten.html", {
"request": request,
"events": events,
"remarks": remarks,
"test": test
}) })
# Tabelle unter /zeiten @router.get("/", response_class=HTMLResponse)
@router.get("/zeiten", response_class=HTMLResponse) async def admin_page(request: Request):
async def times_table(request: Request): return templates.TemplateResponse("admin.html", {
events = get_upcoming_events() "request": request,
remarks = get_remarks() "links": config['links'],
return templates.TemplateResponse("index.html", { "config_days": config['processing']['days_to_show']
"request": request, "events": events, "remarks": remarks
}) })
@router.get("/cache-clear") @router.get("/cache-clear")
async def clear_cache(): async def clear_cache():
invalidate_cache() invalidate_cache()
# Relativer Redirect zurück zum Admin-Interface return RedirectResponse(url="/")
return RedirectResponse(url="./")

Binary file not shown.

Binary file not shown.

View File

@@ -1,11 +1,39 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Admin - Öffnungszeiten</title> <title>Administration - Öffnungszeiten</title>
<style> <style>
body { font-family: sans-serif; padding: 40px; line-height: 1.6; } body { font-family: sans-serif; padding: 40px; line-height: 1.6; }
.btn { background: #ff4757; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; } .btn {
display: inline-block;
background: #ff4757;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 5px;
border: none;
cursor: pointer;
}
.card { border: 1px solid #ddd; padding: 20px; margin-bottom: 20px; border-radius: 8px; } .card { border: 1px solid #ddd; padding: 20px; margin-bottom: 20px; border-radius: 8px; }
/* Flex-Container für die Linksbündigkeit (Standard) */
.input-row {
margin-top: 15px;
display: flex;
justify-content: flex-start; /* Alles nach links */
align-items: center;
gap: 25px; /* Abstand zwischen den Paaren (Tage / Zeilen) */
}
/* Gruppiert Label und Input eng zusammen */
.input-group {
display: flex;
align-items: center;
gap: 8px;
}
.hint { text-align: left; font-size: 13px; color: #666; margin-top: 10px; }
.action-row { text-align: left; margin-top: 20px; }
</style> </style>
</head> </head>
<body> <body>
@@ -15,14 +43,58 @@
<h3>Google Sheets Links</h3> <h3>Google Sheets Links</h3>
<p><a href="{{ links.times_edit }}" target="_blank">➔ Tabelle: Öffnungszeiten bearbeiten</a></p> <p><a href="{{ links.times_edit }}" target="_blank">➔ Tabelle: Öffnungszeiten bearbeiten</a></p>
<p><a href="{{ links.remarks_edit }}" target="_blank">➔ Tabelle: Bemerkungen bearbeiten</a></p> <p><a href="{{ links.remarks_edit }}" target="_blank">➔ Tabelle: Bemerkungen bearbeiten</a></p>
<div class="input-row">
<div class="input-group">
<label for="days_input" style="font-size: 14px;">Tage:</label>
<input type="number" id="days_input" value="0" min="0" style="width: 55px; padding: 4px;">
</div>
<div class="input-group">
<label for="lines_input" style="font-size: 14px;">Zeilen:</label>
<input type="number" id="lines_input" value="0" min="0" style="width: 55px; padding: 4px;">
</div>
</div>
<p class="hint">
Zeilen hat Vorrang vor Tagen. Wenn beide Werte 0 sind, werden die Standard-Tage aus der Config genutzt (aktuell: {{ config_days }}).
</p>
<div class="action-row">
<a id="preview_link" href="/zeiten?test=1" target="_blank" class="btn" style="background: #2ed573;">➔ Vorschau mit Test-Hintergrund</a>
</div>
</div> </div>
<div class="card"> <div class="card">
<h3>Cache Management</h3> <h3>Cache Management</h3>
<p>Nach Änderungen in Google Sheets muss der Cache gelöscht werden, damit die Website sofort aktualisiert wird.</p> <p>Der Cache wird automatisch alle 60 Minuten aktualisiert. Nach manuellen Änderungen in Google Sheets können Sie ihn hier sofort leeren.</p>
<a href="/admin/cache-clear" class="btn">Invalidate Cache (Cache löschen)</a> <a href="/cache-clear" class="btn">Cache jetzt löschen</a>
</div> </div>
<p><a href="/">← Zurück zur Ansicht</a></p> <p><a href="/zeiten">← Zurück zur Ansicht</a></p>
<script>
const daysInput = document.getElementById('days_input');
const linesInput = document.getElementById('lines_input');
const previewLink = document.getElementById('preview_link');
function updateUrl() {
const days = parseInt(daysInput.value) || 0;
const lines = parseInt(linesInput.value) || 0;
let url = "/zeiten?test=1";
if (lines > 0) {
url += `&lines=${lines}`;
} else if (days > 0) {
url += `&days=${days}`;
}
previewLink.href = url;
}
daysInput.addEventListener('input', updateUrl);
linesInput.addEventListener('input', updateUrl);
</script>
</body> </body>
</html> </html>

View File

@@ -1,45 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
/* Für Wix: background auf transparent stellen */
body { font-family: sans-serif; color: white; background: black; margin: 0; padding: 10px; }
table { width: 100%; border-collapse: collapse; max-width: 500px; table-layout: fixed; }
td { padding: 10px 5px; border-bottom: 1px solid #333; font-size: 14px; vertical-align: middle; }
/* Spalten-Definitionen */
.col-wt { text-align: left; width: 45px; font-weight: bold; }
.col-date { text-align: right; width: 55px; color: #aaa; }
.col-time { text-align: right; width: 100px; }
.col-closed { text-align: center; font-style: italic; opacity: 0.6; }
.remarks { margin-top: 30px; font-size: 13px; color: #ccc; border-top: 1px solid #444; padding-top: 15px; line-height: 1.6; }
</style>
</head>
<body>
<table>
{% for event in events %}
<tr>
<td class="col-wt">{{ event.Wochentag }}.</td>
<td class="col-date">{{ event.Datum.strftime('%d.%m.') }}</td>
{% if not event.Morgen and not event.Nachmittag %}
<td colspan="2" class="col-closed">geschlossen</td>
{% else %}
<td class="col-time">{{ event.Morgen }}</td>
<td class="col-time">{{ event.Nachmittag }}</td>
{% endif %}
</tr>
{% endfor %}
</table>
{% if remarks %}
<div class="remarks">
{% for r in remarks %}
<div style="margin-bottom: 8px;">• {{ r }}</div>
{% endfor %}
</div>
{% endif %}
</body>
</html>

108
templates/zeiten.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; color: white; background: transparent; margin: 0; padding: 0; }
@font-face {
font-family: 'BrittanySignature';
src: url('/static/BrittanySignature.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap; /* Verhindert unsichtbaren Text beim Laden */
}
.monat {
font-size: 25px;
font-weight: bold;
padding: 1.5ex 0.5em 0.5ex 0.5em;
color: rgba(255, 255, 255, 0.9);
font-family: 'BrittanySignature', cursive, sans-serif;
}
table {
border-collapse: collapse;
table-layout: fixed; /* Garantiert gleiches Alignment über mehrere Tabellen */
width: 100%;
margin-bottom: 10px;
}
td {
padding: 0.5ex 0.5em;
font-size: 15px;
vertical-align: top;
white-space: nowrap;
/* border-top: 1px solid rgba(255, 255, 255, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.2); */
}
/* tr:nth-of-type(odd) { background-color: rgba(255, 255, 255, 0.1); } */
tr { background-color: rgba(255, 255, 255, 0.1); }
tr.week-spacer { border: none !important; background: transparent !important; height: 15px; }
/* Feste Prozentwerte für identische Spalten in allen Tabellen */
.col-wt { text-align: left; width: 9%; padding-right: 0; }
.col-date { text-align: right; width:14%; }
.col-time { text-align: right; width: 37%; }
.col-closed { text-align: center; }
.remarks-section { margin-top: 1ex; }
.remark-item { margin-top: 0ex; margin-bottom: 0.5ex; text-align: center; }
</style>
</head>
<body>
{% if test %}
<div style="width: 300px ; margin: 20px auto; padding: 10px; background: rgb(55, 55, 55); border-radius: 8px;">
{% endif %}
{% set monate = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'] %}
{% set ns = namespace(last_month=none, last_week=none) %}
{% for event in events %}
{% set current_month_name = monate[event.Datum.month - 1] %}
{% set current_week = event.Datum.isocalendar()[1] %}
{# Monatstitel und Tabellen-Start/Ende Logik #}
{% if current_month_name != ns.last_month %}
{% if ns.last_month is not none %} </table> {% endif %}
<div class="monat">{{ current_month_name }}</div>
<table>
{% set ns.last_week = none %} {# Reset Wochendistanz bei neuem Monat #}
{% endif %}
{# Wochen-Abstand innerhalb eines Monats #}
{% if ns.last_week is not none and current_week != ns.last_week %}
<tr class="week-spacer"><td colspan="4"></td></tr>
{% endif %}
<tr class="data-row">
<td class="col-wt">{{ event.Wochentag }}.</td>
<td class="col-date">{{ event.Datum.strftime('%d.%m.') }}</td>
{% if not event.Morgen and not event.Nachmittag %}
<td colspan="2" class="col-closed">geschlossen</td>
{% else %}
<td class="col-time">{{ event.Morgen }}</td>
<td class="col-time">{{ event.Nachmittag }}</td>
{% endif %}
</tr>
{% set ns.last_month = current_month_name %}
{% set ns.last_week = current_week %}
{% endfor %}
</table>
{% if remarks %}
<div class="remarks-section">
{% for r in remarks %}
<div class="remark-item">{{ r | safe }}</div>
{% endfor %}
</div>
{% endif %}
{% if test %}
</div>
{% endif %}
</div>
</body>
</html>