Merge pull request 'monatsplan - ckalender read fixed, developerpanel - rss feed implemented' (#13) from bugfix/monatsplan into dev

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-29 21:28:07 +02:00
6 changed files with 804 additions and 503 deletions
+40 -46
View File
@@ -263,27 +263,21 @@ Dockerfile
docker-compose.yml docker-compose.yml
docker-compose.local.yml docker-compose.local.yml
web/ web/
worker/
docker/
``` ```
Die produktive `docker-compose.yml` wird lokal **nicht** verwendet. Dadurch werden insbesondere folgende Produktionsbestandteile nicht gestartet: Die `docker-compose.local.yml` bildet die Anwendungsdienste der produktiven `docker-compose.yml` lokal ab und ergänzt die Datenbank. Lokal werden damit gestartet:
* `scheduler`
* `dokploy-network`
* Strato-SSH
* produktive Scheduler-Secrets
Die lokale Compose-Datei startet ausschließlich:
* `web` * `web`
* `scheduler`
* `db` (PostgreSQL 18 mit pgvector) * `db` (PostgreSQL 18 mit pgvector)
Sie verwendet denselben `Dockerfile` wie Produktion. Das externe `dokploy-network` wird lokal nicht benötigt. Alle drei Container verwenden stattdessen das lokale Compose-Netzwerk `app`. `web` und `scheduler` greifen über `DB_HOST=db` direkt auf den lokalen PostgreSQL-Container zu.
Der Web-Container ist lokal unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben. Der Web-Container ist unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben.
Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt. Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt. PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
```text ```text
/var/lib/postgresql /var/lib/postgresql
@@ -301,17 +295,18 @@ Durch den Bind-Mount
./web → /var/www/html ./web → /var/www/html
``` ```
werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder und Aushänge liegen weiterhin in lokalen Docker-Volumes. werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder, Aushänge, Scheduler-Zustand und generierte Kalenderdateien liegen weiterhin in lokalen Docker-Volumes.
--- ---
## 4. Environment-Variablen anlegen ## 4. Environment-Variablen anlegen
Im Projektroot eine `.env` anlegen: Im Projektroot eine `.env` anlegen. Werte, die lokal nicht benötigt oder noch nicht bekannt sind, bleiben leer.
```dotenv ```dotenv
# PostgreSQL # PostgreSQL
POSTGRES_VERSION=18 DB_HOST=
DB_PORT=5432
DB_NAME=intranet DB_NAME=intranet
DB_USER=intranet DB_USER=intranet
DB_PASSWORD=local-dev-password DB_PASSWORD=local-dev-password
@@ -319,24 +314,39 @@ DB_PASSWORD=local-dev-password
# Intranet # Intranet
INTRANET_FQDN=localhost:8080 INTRANET_FQDN=localhost:8080
# Lokale Dummy-Werte für externe Dienste # SMTP / Benachrichtigungen
SMTP_HOST=127.0.0.1 SMTP_HOST=
SMTP_PORT=1025 SMTP_PORT=
SMTP_USER=local SMTP_USER=
SMTP_PASSWORD=local SMTP_PASSWORD=
ADMIN_EMAIL=local@example.invalid ADMIN_EMAIL=
SMS_GATEWAY_URL=
SMS_GATEWAY_URL=http://127.0.0.1:9 # Dashboard / externe APIs
HA_BASE_URL=http://127.0.0.1:9 HA_BASE_URL=
HA_TOKEN=local HA_TOKEN=
OPENWEATHER_API_KEY=local OPENWEATHER_API_KEY=
# CKalender / Scheduler
CKAL_USERNAME= CKAL_USERNAME=
CKAL_PASSWORD= CKAL_PASSWORD=
CKAL_CALENDAR_URL= CKAL_CALENDAR_URL=
CALENDAR_INTERVAL_SECONDS=60
# Strato-Synchronisation
STRATO_SYNC_ENABLED=false
STRATO_SSH_HOST=
STRATO_SSH_PORT=
STRATO_SSH_USER=
STRATO_REMOTE_PATH=
STRATO_SSH_PRIVATE_KEY=
STRATO_KNOWN_HOSTS=
# Brandschutz
BRANDSCHUTZ_MAIL_ENABLED=false
``` ```
Die `.env` darf keine produktiven Secrets enthalten und sollte nicht ins Repository committed werden. Funktionen, die einen externen Dienst benötigen, funktionieren erst, wenn die zugehörigen Werte gesetzt sind.
> Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**. > Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**.
@@ -379,12 +389,6 @@ docker compose -f docker-compose.local.yml ps
Der Datenbank-Container sollte nach einigen Sekunden den Status `healthy` anzeigen. Der Datenbank-Container sollte nach einigen Sekunden den Status `healthy` anzeigen.
Optional kann geprüft werden, ob pgvector verfügbar ist:
```bash
docker exec -it intranet-local-db-1 psql -U intranet -d intranet -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
--- ---
## 7. Dump mit pgAdmin importieren ## 7. Dump mit pgAdmin importieren
@@ -399,8 +403,6 @@ Falls pgAdmin 4 noch nicht installiert ist, kann es lokal als Desktop-Anwendung
Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar. Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar.
Unter Windows mit WSL 2 kann pgAdmin direkt unter Windows installiert werden. Die PostgreSQL-Datenbank aus Docker ist anschließend über `localhost:5432` erreichbar.
In pgAdmin einen neuen Server registrieren: In pgAdmin einen neuen Server registrieren:
```text ```text
@@ -427,14 +429,8 @@ Danach:
7. **Clean before restore: No** beim ersten Import. 7. **Clean before restore: No** beim ersten Import.
8. **Restore** starten. 8. **Restore** starten.
Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden: > **Hinweis:** Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden: `docker compose -f docker-compose.local.yml down -v`,
`docker compose -f docker-compose.local.yml up -d db`. Danach den Dump erneut importieren.
```bash
docker compose -f docker-compose.local.yml down -v
docker compose -f docker-compose.local.yml up -d db
```
Danach den Dump erneut importieren.
> **Achtung:** `down -v` löscht nicht nur PostgreSQL, sondern auch alle übrigen lokalen Intranet-Volumes, z. B. Profilbilder, Aushänge und andere Laufzeitdaten. Nur verwenden, wenn diese Daten bewusst verworfen werden sollen. > **Achtung:** `down -v` löscht nicht nur PostgreSQL, sondern auch alle übrigen lokalen Intranet-Volumes, z. B. Profilbilder, Aushänge und andere Laufzeitdaten. Nur verwenden, wenn diese Daten bewusst verworfen werden sollen.
@@ -504,9 +500,7 @@ docker compose -f docker-compose.local.yml logs -f db
### Änderungen an PHP, CSS und JavaScript ### Änderungen an PHP, CSS und JavaScript
Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig. Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig. Nach dem Speichern reicht in der Regel ein Reload im Browser.
Nach dem Speichern reicht in der Regel ein Reload im Browser.
### Änderungen am Dockerfile ### Änderungen am Dockerfile
+156 -103
View File
@@ -1,103 +1,156 @@
# LOCAL DEVELOPMENT ONLY # LOCAL DEVELOPMENT ONLY
name: intranet-local name: intranet-local
services: services:
web: web:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
ports: restart: unless-stopped
- "127.0.0.1:8080:80" ports:
env_file: - "127.0.0.1:8080:80"
- .env environment:
environment: TZ: Europe/Berlin
TZ: Europe/Berlin INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
DB_HOST: db DB_HOST: db
DB_PORT: "5432" DB_PORT: "5432"
CKAL_XLSX_DIR: /data/ckal-xlsx DB_NAME: "${DB_NAME:-intranet}"
STRATO_SYNC_ENABLED: "false" DB_USER: "${DB_USER:-intranet}"
BRANDSCHUTZ_MAIL_ENABLED: "false" DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
depends_on: SMTP_HOST: "${SMTP_HOST:-}"
db: SMTP_PORT: "${SMTP_PORT:-}"
condition: service_healthy SMTP_USER: "${SMTP_USER:-}"
command: > SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
sh -c "printf '%s\n' 'session.cookie_secure = 0' ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
> /usr/local/etc/php/conf.d/zz-local.ini SMS_GATEWAY_URL: "${SMS_GATEWAY_URL:-}"
&& exec apache2-foreground" HA_BASE_URL: "${HA_BASE_URL:-}"
volumes: HA_TOKEN: "${HA_TOKEN:-}"
- ./web:/var/www/html OPENWEATHER_API_KEY: "${OPENWEATHER_API_KEY:-}"
- profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures CKAL_XLSX_DIR: /data/ckal-xlsx
- content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
- developer_work:/var/www/html/intranet_repo/projects/developerpanel/work BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
- seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data depends_on:
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month db:
- ckal_xlsx:/data/ckal-xlsx condition: service_healthy
- calendar_pdf:/var/www/html/pdf command: >
sh -c "printf '%s\n' 'session.cookie_secure = 0'
db: > /usr/local/etc/php/conf.d/zz-local.ini
image: pgvector/pgvector:pg18 && exec apache2-foreground"
ports: volumes:
- "127.0.0.1:5432:5432" - ./web:/var/www/html
environment: - profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
TZ: Europe/Berlin - content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
POSTGRES_DB: ${DB_NAME:?set DB_NAME in .env} - developer_work:/var/www/html/intranet_repo/projects/developerpanel/work
POSTGRES_USER: ${DB_USER:?set DB_USER in .env} - seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
volumes: - ckal_xlsx:/data/ckal-xlsx
- postgres_data:/var/lib/postgresql - calendar_pdf:/var/www/html/pdf:ro
healthcheck: networks:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] - app
interval: 5s
timeout: 5s scheduler:
retries: 12 build:
context: .
scheduler: dockerfile: Dockerfile
build: restart: unless-stopped
context: . init: true
dockerfile: Dockerfile user: "33:33"
env_file: command:
- .env - /bin/sh
environment: - -ec
TZ: Europe/Berlin - |
HOME: /tmp/intranet-worker if [ -z "$${CKAL_USERNAME}" ] || [ -z "$${CKAL_PASSWORD}" ] || [ -z "$${CKAL_CALENDAR_URL}" ]; then
XDG_CACHE_HOME: /tmp/intranet-worker-cache echo "Scheduler wartet: CKAL_USERNAME, CKAL_PASSWORD und CKAL_CALENDAR_URL sind nicht gesetzt."
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright exec sleep infinity
fi
DB_HOST: db exec /opt/intranet/docker/scheduler.py
DB_PORT: "5432" shm_size: "512m"
healthcheck:
CKAL_XLSX_DIR: /data/ckal-xlsx disable: true
PDF_OUTPUT: /data/pdf/terminuebersicht.pdf environment:
DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month TZ: Europe/Berlin
HOME: /tmp/intranet-worker
CALENDAR_INTERVAL_SECONDS: "60" XDG_CACHE_HOME: /tmp/intranet-worker-cache
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
STRATO_SYNC_ENABLED: "false" INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
BRANDSCHUTZ_MAIL_ENABLED: "false" DB_HOST: db
DB_PORT: "5432"
command: ["/opt/intranet/docker/scheduler.py"] DB_NAME: "${DB_NAME:-intranet}"
init: true DB_USER: "${DB_USER:-intranet}"
user: "33:33" DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
shm_size: "512m" SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-}"
depends_on: SMTP_USER: "${SMTP_USER:-}"
db: SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
condition: service_healthy ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
CKAL_USERNAME: "${CKAL_USERNAME:-}"
volumes: CKAL_PASSWORD: "${CKAL_PASSWORD:-}"
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month CKAL_CALENDAR_URL: "${CKAL_CALENDAR_URL:-}"
- ckal_xlsx:/data/ckal-xlsx CKAL_XLSX_DIR: /data/ckal-xlsx
- calendar_pdf:/data/pdf PDF_OUTPUT: /data/pdf/terminuebersicht.pdf
- scheduler_state:/data/state DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month
CALENDAR_INTERVAL_SECONDS: "${CALENDAR_INTERVAL_SECONDS:-60}"
volumes: STRATO_SSH_HOST: "${STRATO_SSH_HOST:-}"
postgres_data: STRATO_SSH_PORT: "${STRATO_SSH_PORT:-}"
profile_pictures: STRATO_SSH_USER: "${STRATO_SSH_USER:-}"
content_news: STRATO_REMOTE_PATH: "${STRATO_REMOTE_PATH:-}"
developer_work: STRATO_SSH_KEY_FILE: /run/secrets/strato_ed25519
seelsorge_data: STRATO_KNOWN_HOSTS_FILE: /run/secrets/known_hosts
dashboard_month: STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
ckal_xlsx: BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
calendar_pdf: depends_on:
scheduler_state: db:
condition: service_healthy
volumes:
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
- ckal_xlsx:/data/ckal-xlsx
- calendar_pdf:/data/pdf
- scheduler_state:/data/state
secrets:
- source: strato_ed25519
target: strato_ed25519
- source: strato_known_hosts
target: known_hosts
networks:
- app
db:
image: pgvector/pgvector:pg18
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
environment:
TZ: Europe/Berlin
POSTGRES_DB: "${DB_NAME:-intranet}"
POSTGRES_USER: "${DB_USER:-intranet}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
volumes:
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 5s
timeout: 5s
retries: 12
networks:
- app
volumes:
postgres_data:
profile_pictures:
content_news:
developer_work:
seelsorge_data:
dashboard_month:
ckal_xlsx:
calendar_pdf:
scheduler_state:
networks:
app:
secrets:
strato_ed25519:
environment: STRATO_SSH_PRIVATE_KEY
strato_known_hosts:
environment: STRATO_KNOWN_HOSTS
@@ -0,0 +1,132 @@
.developer-feed {
grid-column: 1 / -1;
padding: 0;
border-top: 3px solid var(--theme-color);
}
.developer-feed_header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--gray-200);
}
.developer-feed_header svg,
.developer-feed_icon svg {
width: 20px;
height: 20px;
fill: currentColor;
}
.developer-feed_header svg {
color: var(--theme-color);
}
.developer-feed h2 {
margin: 0;
font-size: var(--font-size-md);
font-weight: 600;
}
.developer-feed_group + .developer-feed_group {
border-top: 1px solid var(--gray-200);
}
.developer-feed_group h3 {
margin: 0;
padding: var(--space-sm) var(--space-lg);
color: var(--gray-700);
background: var(--gray-50);
font-size: var(--font-size-sm);
font-weight: 600;
}
.developer-feed_group ul {
margin: 0;
padding: 0;
list-style: none;
}
.developer-feed_group li + li {
border-top: 1px solid var(--gray-200);
}
.developer-feed a {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: var(--space-md);
align-items: center;
padding: var(--space-md) var(--space-lg);
color: var(--gray-900);
text-decoration: none;
border-left: 3px solid transparent;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.developer-feed a:hover,
.developer-feed a:focus-visible {
background: var(--theme-light);
border-left-color: var(--theme-color);
}
.developer-feed_icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
color: var(--theme-color);
background: var(--theme-light);
}
.developer-feed_message {
min-width: 0;
}
.developer-feed_message strong,
.developer-feed_description {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.developer-feed_message strong {
font-weight: 500;
}
.developer-feed_description,
.developer-feed_meta {
margin-top: 2px;
color: var(--gray-600);
font-size: var(--font-size-sm);
}
.developer-feed_meta {
display: flex;
gap: var(--space-sm);
}
.developer-feed_empty {
margin: 0;
padding: var(--space-lg);
color: var(--gray-600);
}
.developer-feed_more {
width: 100%;
padding: var(--space-md);
border: 0;
border-top: 1px solid var(--gray-200);
background: #fff;
color: var(--theme-color);
font: inherit;
font-weight: 500;
cursor: pointer;
}
.developer-feed_more:hover,
.developer-feed_more:focus-visible {
background: var(--theme-light);
}
@@ -4,16 +4,28 @@
// Initialisierung // Initialisierung
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php'; require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
require_once __DIR__.'/util/developer_helpers.php';
// Zugriff nur für Entwickler // Zugriff nur für Entwickler
if (!isset($groups["Entwickler"]) || $groups["Entwickler"] != 1) { if (!isset($groups["Entwickler"]) || $groups["Entwickler"] != 1) {
header("Location: {$g_httpHomePath}"); header("Location: {$g_httpHomePath}");
exit(); exit();
} }
$feedError = '';
try {
$feedItems = developer_feed_items();
} catch (RuntimeException) {
$feedItems = [];
$feedError = 'Der Git-RSS-Feed ist momentan nicht erreichbar.';
}
$feedGroups = developer_feed_groups($feedItems);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" dir="ltr"> <html lang="de" dir="ltr">
<head> <head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?> <?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/developerpanel.css">
</head> </head>
<body class="theme-green"> <body class="theme-green">
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?> <?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/header.php'; ?>
@@ -26,26 +38,81 @@
<div class="mainpage_container"> <div class="mainpage_container">
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet', '_blank', 'noopener,noreferrer');"> <button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M23.546 10.93 13.067.452a1.55 1.55 0 0 0-2.188 0L8.708 2.627l2.76 2.76a1.838 1.838 0 0 1 2.327 2.341l2.658 2.66a1.837 1.837 0 1 1-1.102 1.033L12.87 8.94v6.526a1.837 1.837 0 1 1-1.512-.053V8.828a1.838 1.838 0 0 1-.999-2.409L7.636 3.697.45 10.881a1.55 1.55 0 0 0 0 2.188l10.48 10.48a1.55 1.55 0 0 0 2.186 0l10.43-10.43a1.55 1.55 0 0 0 0-2.188Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M23.546 10.93 13.067.452a1.55 1.55 0 0 0-2.188 0L8.708 2.627l2.76 2.76a1.838 1.838 0 0 1 2.327 2.341l2.658 2.66a1.837 1.837 0 1 1-1.102 1.033L12.87 8.94v6.526a1.837 1.837 0 1 1-1.512-.053V8.828a1.838 1.838 0 0 1-.999-2.409L7.636 3.697.45 10.881a1.55 1.55 0 0 0 0 2.188l10.48 10.48a1.55 1.55 0 0 0 2.186 0l10.43-10.43a1.55 1.55 0 0 0 0-2.188Z"/></svg>
<div class="text">Git Repository</div> <div class="text">Quellcode (Git)</div>
</button> </button>
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://pgadmin.pelpino.de/', '_blank', 'noopener,noreferrer');"> <button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://pgadmin.pelpino.de/', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M12 2C7.03 2 3 3.79 3 6v12c0 2.21 4.03 4 9 4s9-1.79 9-4V6c0-2.21-4.03-4-9-4Zm0 2c4.42 0 7 .99 7 2s-2.58 2-7 2-7-.99-7-2 2.58-2 7-2Zm0 16c-4.42 0-7-.99-7-2v-2.13C6.63 16.57 9.14 17 12 17s5.37-.43 7-1.13V18c0 1.01-2.58 2-7 2Zm0-5c-4.42 0-7-.99-7-2v-2.13C6.63 11.57 9.14 12 12 12s5.37-.43 7-1.13V13c0 1.01-2.58 2-7 2Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M12 2C7.03 2 3 3.79 3 6v12c0 2.21 4.03 4 9 4s9-1.79 9-4V6c0-2.21-4.03-4-9-4Zm0 2c4.42 0 7 .99 7 2s-2.58 2-7 2-7-.99-7-2 2.58-2 7-2Zm0 16c-4.42 0-7-.99-7-2v-2.13C6.63 16.57 9.14 17 12 17s5.37-.43 7-1.13V18c0 1.01-2.58 2-7 2Zm0-5c-4.42 0-7-.99-7-2v-2.13C6.63 11.57 9.14 12 12 12s5.37-.43 7-1.13V13c0 1.01-2.58 2-7 2Z"/></svg>
<div class="text">Postgres-Datenbank</div> <div class="text">Datenbank (Postgres)</div>
</button> </button>
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/projects/2', '_blank', 'noopener,noreferrer');"> <button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/projects/2', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M3 3h5v18H3V3Zm6.5 0h5v11h-5V3ZM16 3h5v7h-5V3Zm0 8.5h5V21h-5v-9.5ZM9.5 15.5h5V21h-5v-5.5Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M3 3h5v18H3V3Zm6.5 0h5v11h-5V3ZM16 3h5v7h-5V3Zm0 8.5h5V21h-5v-9.5ZM9.5 15.5h5V21h-5v-5.5Z"/></svg>
<div class="text">Kanban-Board</div> <div class="text">Projekt-Board</div>
</button> </button>
<button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/wiki', '_blank', 'noopener,noreferrer');"> <button type="button" class="button_mainpage unselectable bg_green" onclick="window.open('https://git.pelpino.de/NAK-Reinickendorf/intranet/wiki', '_blank', 'noopener,noreferrer');">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M4 3a3 3 0 0 0-3 3v13.5A1.5 1.5 0 0 0 2.5 21H11V5.5A2.5 2.5 0 0 0 8.5 3H4Zm16 0h-4.5A2.5 2.5 0 0 0 13 5.5V21h8.5a1.5 1.5 0 0 0 1.5-1.5V6a3 3 0 0 0-3-3Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32" aria-hidden="true" focusable="false"><path d="M4 3a3 3 0 0 0-3 3v13.5A1.5 1.5 0 0 0 2.5 21H11V5.5A2.5 2.5 0 0 0 8.5 3H4Zm16 0h-4.5A2.5 2.5 0 0 0 13 5.5V21h8.5a1.5 1.5 0 0 0 1.5-1.5V6a3 3 0 0 0-3-3Z"/></svg>
<div class="text">Wiki</div> <div class="text">Wiki</div>
</button> </button>
<section class="content_panel developer-feed" aria-labelledby="developer-feed-title">
<div class="developer-feed_header">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.002 2.725a.75.75 0 0 1 .797-.699C8.79 2.42 13.58 7.21 13.974 13.201a.75.75 0 0 1-1.497.098 10.5 10.5 0 0 0-9.776-9.776.747.747 0 0 1-.7-.798ZM2.84 7.05h-.002a7 7 0 0 1 6.113 6.111.75.75 0 0 1-1.49.178 5.5 5.5 0 0 0-4.8-4.8.75.75 0 0 1 .179-1.489M2 13a1 1 0 1 1 2 0 1 1 0 0 1-2 0"/></svg>
<h2 id="developer-feed-title">Git-Aktivität</h2>
</div>
<?php if ($feedItems === []): ?>
<p class="developer-feed_empty"><?= $feedError ?: 'Noch keine Git-Aktivität vorhanden.' ?></p>
<?php else: ?>
<div id="developer-feed-list">
<?php $itemIndex = 0; ?>
<?php foreach ($feedGroups as $group => $items): ?>
<section class="developer-feed_group"<?= $itemIndex >= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>>
<h3><?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?></h3>
<ul>
<?php foreach ($items as $item): ?>
<li<?= $itemIndex++ >= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>>
<a href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener noreferrer">
<span class="developer-feed_icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="<?= developer_feed_icon($item['title']) ?>"/></svg></span>
<span class="developer-feed_message">
<strong><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></strong>
<?php if ($item['description'] !== ''): ?>
<span class="developer-feed_description"><?= htmlspecialchars($item['description'], ENT_QUOTES, 'UTF-8') ?></span>
<?php endif; ?>
<span class="developer-feed_meta">
<span><?= htmlspecialchars($item['author'], ENT_QUOTES, 'UTF-8') ?></span>
<?php if ($item['timestamp'] > 0): ?><span>·</span><time datetime="<?= date('c', $item['timestamp']) ?>"><?= date('d.m.Y · H:i', $item['timestamp']) ?></time><?php endif; ?>
</span>
</span>
</a>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php endforeach; ?>
</div>
<?php if (count($feedItems) > DEVELOPER_FEED_LIMIT): ?>
<button class="developer-feed_more" type="button" id="developer-feed-more" aria-expanded="false" aria-controls="developer-feed-list">Mehr anzeigen</button>
<?php endif; ?>
<?php endif; ?>
</section>
</div> </div>
</div> </div>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?> <?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/footer.php'; ?>
<?php if (count($feedItems) > DEVELOPER_FEED_LIMIT): ?>
<script>
document.getElementById('developer-feed-more').addEventListener('click', function () {
const expanded = this.getAttribute('aria-expanded') === 'true';
const list = document.getElementById('developer-feed-list');
list.querySelectorAll('li').forEach((item, index) => item.hidden = expanded && index >= <?= DEVELOPER_FEED_LIMIT ?>);
list.querySelectorAll('.developer-feed_group').forEach(group => group.hidden = !group.querySelector('li:not([hidden])'));
this.setAttribute('aria-expanded', String(!expanded));
this.textContent = expanded ? 'Mehr anzeigen' : 'Weniger anzeigen';
});
</script>
<?php endif; ?>
</body> </body>
</html> </html>
@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
const DEVELOPER_FEED_URL = 'https://git.pelpino.de/NAK-Reinickendorf/intranet.rss';
const DEVELOPER_REPO_URL = 'https://git.pelpino.de/NAK-Reinickendorf/intranet';
const DEVELOPER_FEED_LIMIT = 7;
function developer_feed_plain_text(string $value): string
{
return trim(preg_replace(
'/\s+/u',
' ',
strip_tags(preg_replace('/>\s*</', '> <', html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '')
) ?? '');
}
function developer_feed_items(string $url = DEVELOPER_FEED_URL): array
{
$handle = curl_init($url);
if ($handle === false) {
throw new RuntimeException('RSS-Feed konnte nicht initialisiert werden.');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Accept: application/rss+xml, application/xml;q=0.9'],
CURLOPT_USERAGENT => 'NAK-Reinickendorf-Intranet/1.0',
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 4,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
]);
try {
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
} finally {
curl_close($handle);
}
if ($response === false || $status < 200 || $status >= 300) {
throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
}
return developer_parse_feed($response);
}
function developer_parse_feed(string $xml): array
{
$previous = libxml_use_internal_errors(true);
try {
$feed = simplexml_load_string($xml, SimpleXMLElement::class, LIBXML_NONET | LIBXML_NOCDATA);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
if ($feed === false || !isset($feed->channel->item)) {
throw new RuntimeException('Ungültiger RSS-Feed.');
}
$items = [];
foreach ($feed->channel->item as $item) {
$title = developer_feed_plain_text((string) $item->title);
if ($title === '') {
continue;
}
$description = developer_feed_plain_text((string) $item->description);
$author = trim((string) $item->children('http://purl.org/dc/elements/1.1/')->creator ?: (string) $item->author);
if (preg_match('/\(([^)]+)\)\s*$/u', $author, $match) === 1) {
$author = trim($match[1]);
} else {
$author = trim(preg_replace('/<[^>]+>|\S+@\S+/u', '', $author) ?? '');
}
$link = trim((string) $item->link);
$items[] = [
'title' => $title,
'description' => $description !== $title ? $description : '',
'url' => filter_var($link, FILTER_VALIDATE_URL) && str_starts_with($link, DEVELOPER_REPO_URL) ? $link : DEVELOPER_REPO_URL,
'author' => $author !== '' ? $author : 'Git',
'timestamp' => strtotime((string) $item->pubDate) ?: 0,
];
}
usort($items, static fn(array $a, array $b): int => $b['timestamp'] <=> $a['timestamp']);
return $items;
}
function developer_feed_groups(array $items, ?DateTimeImmutable $now = null): array
{
$now ??= new DateTimeImmutable();
$today = $now->setTime(0, 0);
$week = $today->modify('monday this week');
$month = $today->modify('first day of this month');
$year = $today->setDate((int) $today->format('Y'), 1, 1);
$starts = [
'Heute' => $today,
'Gestern' => $today->modify('-1 day'),
'Diese Woche' => $week,
'Letzte Woche' => $week->modify('-1 week'),
'Diesen Monat' => $month,
'Letzten Monat' => $month->modify('-1 month'),
'Dieses Jahr' => $year,
'Letztes Jahr' => $year->modify('-1 year'),
];
$groups = [];
foreach ($items as $item) {
$date = (new DateTimeImmutable('@' . (int) $item['timestamp']))->setTimezone($now->getTimezone());
$group = 'Älter';
foreach ($starts as $label => $start) {
if ($date >= $start) {
$group = $label;
break;
}
}
$groups[$group][] = $item;
}
return $groups;
}
function developer_feed_icon(string $title): string
{
$title = strtolower($title);
return match (true) {
str_contains($title, 'merge pull'), str_starts_with($title, 'merge ') => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V9h4a4 4 0 0 1 4 4v1.17a3 3 0 1 0 2 0V13a6 6 0 0 0-6-6H7V5.83A3 3 0 0 0 7 3Z',
preg_match('/\b(fix|fixed|bugfix|hotfix|repair)\b/u', $title) === 1 => 'M14.7 6.3a5 5 0 0 0-6.47 6.47l-5.94 5.94a1 1 0 0 0 0 1.42l1.58 1.58a1 1 0 0 0 1.42 0l5.94-5.94A5 5 0 0 0 17.7 9.3l-3 3-3-3 3-3Z',
preg_match('/\b(remove|removed|delete|deleted|revert)\b/u', $title) === 1 => 'M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Zm3 2v8h2v-8H9Zm4 0v8h2v-8h-2Z',
preg_match('/\b(refactor|cleanup|clean up|simplif|optimi[sz])\w*/u', $title) === 1 => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V14h3a5 5 0 0 0 5-5V7.83a3 3 0 1 0-2 0V9a3 3 0 0 1-3 3H7V5.83A3 3 0 0 0 7 3Z',
preg_match('/\b(doc|docs|readme|wiki|tutorial)\w*/u', $title) === 1 => 'M4 3h7a2 2 0 0 1 2 2v15a3 3 0 0 0-3-3H4V3Zm16 0h-5v14h5V3Zm0 16h-5a1 1 0 0 0-1 1h6v-1Z',
preg_match('/\b(add|added|new|create|created|introduce|introduced|implement|implemented)\b/u', $title) === 1 => 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z',
default => 'M12 3a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21V6h-5.13A4 4 0 0 0 12 3Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm0 10a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21v-2h-5.13A4 4 0 0 0 12 15Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z',
};
}
+264 -351
View File
@@ -1,351 +1,264 @@
#! /usr/bin/env python3 #!/usr/bin/env python3
from calendar import monthrange import datetime as dt
from fpdf import FPDF import os
import datetime import re
from datetime import datetime as datelul from urllib.parse import parse_qsl, urlencode, urlsplit
from bs4 import BeautifulSoup
import requests import requests
import os from bs4 import BeautifulSoup
from fpdf import FPDF
def is_invalid_date(date_str): MONTHS = (
try: "Januar", "Februar", "März", "April", "Mai", "Juni",
datelul.strptime(date_str, "%Y-%m-%d") "Juli", "August", "September", "Oktober", "November", "Dezember",
return False )
except ValueError: WEEKDAYS = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
return True DATE_PATTERNS = (
(re.compile(r"(?<!\d)(\d{4})-(\d{1,2})-(\d{1,2})(?!\d)"), (1, 2, 3)),
(re.compile(r"(?<!\d)(\d{1,2})\.(\d{1,2})\.(\d{4})(?!\d)"), (3, 2, 1)),
def pdf_safe(text): )
if text is None: PDF_TRANSLATION = str.maketrans({
return "" "„": '"', "“": '"', "‚": "'", "‘": "'", "’": "'",
"–": "-", "—": "-", "…": "...", "\xa0": " ",
text = str(text) })
TABLE_COLUMNS = (
replacements = { (20, "Datum", "C"),
"„": '"', (15, "Tag", "C"),
"“": '"', (20, "Uhrzeit", "C"),
"‚": "'", (75, "Veranstaltung", "L"),
"‘": "'", (60, "Ort", "C"),
"’": "'", )
"–": "-",
"—": "-",
"…": "...", def pdf_safe(text):
"\xa0": " ", text = "" if text is None else str(text)
} return text.translate(PDF_TRANSLATION).encode("latin-1", "replace").decode("latin-1")
for old, new in replacements.items():
text = text.replace(old, new) def parse_date(value):
for pattern, order in DATE_PATTERNS:
# Alles, was nicht latin-1-kompatibel ist, verwerfen match = pattern.search(str(value or ""))
return text.encode("latin-1", "replace").decode("latin-1") if match:
parts = match.groups()
try:
#### GENERAL DATE CONFIGURATION #### return dt.date(*(int(parts[index - 1]) for index in order))
months_numeric = ["JANUAR", "FEBRUAR", "MÄRZ", "APRIL", "MAI", "JUNI", "JULI", "AUGUST", "SEPTEMBER", "OKTOBER", "NOVEMBER", "DEZEMBER"] except ValueError:
months_numeric_lower = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] return None
date = datetime.datetime.now() return None
month_range = monthrange(date.year, date.month)
month = months_numeric[date.month - 1]
weekdays_list = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] def calendar_cells(html):
counter = 2 cells = {}
actual_day = datetime.date.today() for element in BeautifulSoup(html, "html.parser").find_all(attrs={"data-date": True}):
first_day = actual_day.replace(day=1) day = parse_date(element.get("data-date"))
weekday = first_day.weekday() if day:
cells.setdefault(day, element)
#### URL HTML PARSING #### return cells
url = os.environ.get("CKAL_CALENDAR_URL")
if not url:
raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.") def has_style(value, declaration):
result = requests.get(url, timeout=30) return bool(value) and declaration in value.replace(" ", "").lower()
result.raise_for_status()
doc = BeautifulSoup(result.text, "html.parser") def event_rows(cell):
table = doc.find_all("tbody")[0] blocks = cell.find_all("div", class_="calEventBox")
tablerows = table.find_all("tr") titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
#### PDF GENERATING PROGRESS #### for index, title in enumerate(titles):
pdf = FPDF("P", "mm", "A4") time_text, place = "", "--"
pdf.set_auto_page_break(auto=True, margin=15) block = blocks[index] if index < len(blocks) else None
pdf.add_page() details = block and block.find(
style=lambda value: has_style(value, "font-weight:normal")
# Header )
pdf.set_font("helvetica", "B", 16) if details:
pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(month) + " " + str(date.year)), 0, 1) parts = [part.strip() for part in details.stripped_strings if part.strip()]
if parts and ":" in parts[0]:
path = os.path.join(os.path.dirname(__file__), "nak_logo.png") time_text = parts.pop(0)
assert os.path.isfile(path) if parts:
pdf.image(path, 188, 9.5, 11) place = parts[0].lstrip(", ") or "--"
yield time_text, title.get_text(strip=True), place
pdf.set_font("helvetica", "", 10)
pdf.cell(160, 5, pdf_safe("der Gemeinde Berlin-Reinickendorf"), 0, 1)
pdf.cell(0, 2, " ", 0, 1) def next_month(month):
if month.month == 12:
# Tablehead return dt.date(month.year + 1, 1, 1)
pdf.set_font("helvetica", "B", 10) return dt.date(month.year, month.month + 1, 1)
pdf.cell(20, 5, "Datum", 1, 0, "C")
pdf.cell(15, 5, "Tag", 1, 0, "C")
pdf.cell(20, 5, "Uhrzeit", 1, 0, "C") def target_months(today):
pdf.cell(75, 5, "Veranstaltung", 1, 0) current = today.replace(day=1)
pdf.cell(60, 5, "Ort", 1, 1, "C") return current, next_month(current)
pdf.set_font("helvetica", "", 8)
def month_days(month):
# Startposition (heutiges Datum) suchen count = (next_month(month) - month).days
iStart = None return (month + dt.timedelta(days=offset) for offset in range(count))
jStart = None
for i in range(len(tablerows)): def month_end(month):
table_cells = tablerows[i].find_all("td") return next_month(month) - dt.timedelta(days=1)
for j in range(len(table_cells)):
table_dates = table_cells[j].get("data-date", "")
date_for_table = str(table_dates).split("-") def missing_days(cells, month):
if len(date_for_table) == 3: return [day for day in month_days(month) if day not in cells]
if (
date_for_table[0] == str(date.strftime("%Y"))
and date_for_table[1] == str(date.strftime("%m")) def require_month(cells, month):
and date_for_table[2] == str(date.strftime("%d")) missing = missing_days(cells, month)
): if not missing:
iStart = i return
jStart = j
break label = month.strftime("%m.%Y")
if jStart is not None: if len(missing) == (next_month(month) - month).days:
break raise RuntimeError(f"CKalender enthält keine Daten für {label}.")
if jStart is None: preview = ", ".join(day.strftime("%d.%m.") for day in missing[:3])
raise RuntimeError("Heutiges Datum wurde im Kalender nicht gefunden.") suffix = " …" if len(missing) > 3 else ""
raise RuntimeError(
j = jStart f"CKalender-Daten für {label} sind unvollständig "
f"({len(missing)} Tage fehlen: {preview}{suffix})."
for i in range(len(tablerows)): )
table_cells = tablerows[i].find_all("td")
table_dates = table_cells[j].get("data-date", "")
date_for_table = str(table_dates).split("-") def calendar_url_for_period(url, start, end, kind):
parts = urlsplit(url)
if len(date_for_table) != 3: overridden = {"calendar_kind", "from_date", "until_date"}
break query = [
item for item in parse_qsl(parts.query, keep_blank_values=True)
if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]): if item[0] not in overridden
break ]
query += [
# Trennlinie ("calendar_kind", kind),
pdf.set_fill_color(130) ("from_date", start.isoformat()),
pdf.cell(0, 0.3, " ", 0, 1, fill=True) ("until_date", end.isoformat()),
]
# Wochentag korrigieren return parts._replace(query=urlencode(query)).geturl()
if weekday == 7:
weekday = 0
def fetch_calendar_cells(url, start, end, kind):
# Zeilenfarbe response = requests.get(calendar_url_for_period(url, start, end, kind), timeout=30)
if counter % 2 == 0: response.raise_for_status()
pdf.set_fill_color(255, 255, 255) return calendar_cells(response.text)
else:
pdf.set_fill_color(220)
def load_calendar_cells(url, today):
# Samstag/Sonntag hervorheben months = target_months(today)
pdf.set_font("helvetica", "", 8) cells = fetch_calendar_cells(url, months[0], month_end(months[1]), "year")
if weekday == 2 or weekday == 6:
pdf.set_font("helvetica", "B", 8) for month in months:
if weekday == 6: if not missing_days(cells, month):
pdf.set_fill_color(170, 210, 255) continue
end = month_end(month)
# Heutigen Tag gelb markieren fetched = fetch_calendar_cells(url, month, end, "month")
if ( cells.update({
date_for_table[0] == str(date.strftime("%Y")) day: cell for day, cell in fetched.items()
and date_for_table[1] == str(date.strftime("%m")) if month <= day <= end
and date_for_table[2] == str(date.strftime("%d")) })
): return cells
pdf.set_fill_color(255, 255, 0)
pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True) def add_table_header(pdf):
pdf.set_font("helvetica", "B", 10)
event_full_block = table_cells[j].find_all("div", class_="calEventBox") for index, (width, label, align) in enumerate(TABLE_COLUMNS):
events = table_cells[j].find_all( pdf.cell(width, 5, label, 1, int(index == len(TABLE_COLUMNS) - 1), align)
style=lambda style: style
and "margin-right:.2em" in style.replace(" ", "")
) def add_footer(pdf, now):
pdf.set_font("helvetica", "", 8)
pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True) pdf.cell(0, 1, " ", 0, 1)
pdf.cell(0, 0.1, " ", 1, 1)
if len(events) == 0: pdf.cell(0, 1, " ", 0, 1)
pdf.cell(0, 4, " ", 0, 1, fill=True) pdf.cell(153, 3, "gemeindeleitung@nak-reinickendorf.de", 0, 0)
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
for k in range(len(events)): pdf.set_font("helvetica", "I", 8)
if k > 0: pdf.cell(153, 3, f"Berlin, {now:%d.%m.%Y %H:%M}", 0, 0)
pdf.cell(35, 4, " ", 0, 0, fill=True) pdf.set_font("helvetica", "", 8)
pdf.cell(30, 3, "nak-reinickendorf.de", 0, 1)
event_place = "--"
pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
def add_month(pdf, cells, month, today=None):
if k < len(event_full_block): for day in month_days(month):
event_time = event_full_block[k].find_all(style="font-weight:normal") pdf.set_fill_color(130)
else: pdf.cell(0, 0.3, " ", 0, 1, fill=True)
event_time = [] pdf.set_fill_color(255 if day.day % 2 else 220)
pdf.set_font("helvetica", "B" if day.weekday() >= 5 else "", 8)
if len(event_time) > 0: if day.weekday() == 6:
try: pdf.set_fill_color(170, 210, 255)
elements = str(event_time[0]).split("<") if day == today:
except Exception: pdf.set_fill_color(255, 255, 0)
print("error1")
else: pdf.cell(20, 4, day.strftime("%d.%m.%Y"), 0, 0, "C", fill=True)
if len(elements) > 1 and ">" in elements[1]: pdf.cell(15, 4, WEEKDAYS[day.weekday()], 0, 0, "C", fill=True)
time_text = elements[1].split(">")[1]
if ":" in str(time_text): events = list(event_rows(cells[day])) if day in cells else []
pdf.set_x(pdf.get_x() - 20) if not events:
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True) pdf.cell(0, 4, " ", 0, 1, fill=True)
continue
if len(elements) > 2 and ">" in elements[2]:
place_text = elements[2].split(">")[1] for index, (time_text, title, place) in enumerate(events):
if place_text == "": if index:
event_place = "--" pdf.cell(35, 4, " ", 0, 0, fill=True)
else: pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
if str(place_text).startswith(","): pdf.cell(75, 4, pdf_safe(title), 0, 0, fill=True)
event_place = str(place_text)[2:] pdf.cell(60, 4, pdf_safe(place), 0, 1, "C", fill=True)
else:
event_place = str(place_text)
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
event_name = events[k].get_text(strip=True) if len(events) > 0 else "" name = MONTHS[month.month - 1]
pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True) pdf.set_font("helvetica", "B", 16)
pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True) pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
pdf.set_font("helvetica", "", 10)
counter += 1 subtitle = (
weekday += 1 f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
if planning_note else "der Gemeinde Berlin-Reinickendorf"
# Fußzeile )
pdf.set_font("helvetica", "", 8) pdf.cell(160, 5, pdf_safe(subtitle), 0, 1)
pdf.cell(0, 1, " ", 0, 1) pdf.cell(0, 2, " ", 0, 1)
pdf.cell(0, 0.1, " ", 1, 1) add_table_header(pdf)
pdf.cell(0, 1, " ", 0, 1) add_month(pdf, cells, month, today)
pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0) add_footer(pdf, now)
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
pdf.set_font("helvetica", "I", 8)
pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0) def create_pdf(cells, output_path, now=None):
pdf.set_font("helvetica", "", 8) now = now or dt.datetime.now()
pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1) today = now.date()
months = target_months(today)
# Zweite Monats-Spalte / Folgemonat for month in months:
if jStart + 1 < 12: require_month(cells, month)
pdf.cell(0, 4, " ", 0, 1)
pdf.cell(0, 2, " ", 0, 1) pdf = FPDF("P", "mm", "A4")
pdf.set_auto_page_break(auto=True, margin=15)
pdf.set_font("helvetica", "B", 16) pdf.add_page()
pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(months_numeric[date.month] + " " + str(date.year))), 0, 1)
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
pdf.set_font("helvetica", "", 10) if not os.path.isfile(logo):
pdf.cell( raise RuntimeError(f"Logo fehlt: {logo}")
160, pdf.image(logo, 188, 9.5, 11)
5,
pdf_safe("Die Termine für " + str(months_numeric_lower[date.month]) + " befinden sich noch in Planung und können sich ändern."), for index, month in enumerate(months):
0, if index:
1, pdf.cell(0, 6, " ", 0, 1)
) add_month_section(
pdf.cell(0, 2, " ", 0, 1) pdf, cells, month, now,
today=today if index == 0 else None,
# Tablehead planning_note=index == 1,
pdf.set_font("helvetica", "B", 10) )
pdf.cell(20, 5, "Datum", 1, 0, "C")
pdf.cell(15, 5, "Tag", 1, 0, "C") output_dir = os.path.dirname(output_path)
pdf.cell(20, 5, "Uhrzeit", 1, 0, "C") if output_dir:
pdf.cell(75, 5, "Veranstaltung", 1, 0) os.makedirs(output_dir, exist_ok=True)
pdf.cell(60, 5, "Ort", 1, 1, "C") pdf.output(output_path)
j = jStart + 1
def main():
for i in range(len(tablerows)): url = os.environ.get("CKAL_CALENDAR_URL")
table_cells = tablerows[i].find_all("td") if not url:
table_dates = table_cells[j].get("data-date", "") raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
date_for_table = str(table_dates).split("-")
now = dt.datetime.now()
if len(date_for_table) != 3: cells = load_calendar_cells(url, now.date())
break create_pdf(cells, os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf"), now)
if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]):
break if __name__ == "__main__":
main()
pdf.set_fill_color(130)
pdf.cell(0, 0.3, " ", 0, 1, fill=True)
if weekday == 7:
weekday = 0
if counter % 2 == 0:
pdf.set_fill_color(255, 255, 255)
else:
pdf.set_fill_color(220)
pdf.set_font("helvetica", "", 8)
if weekday == 2 or weekday == 6:
pdf.set_font("helvetica", "B", 8)
if weekday == 6:
pdf.set_fill_color(170, 210, 255)
pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True)
event_full_block = table_cells[j].find_all("div", class_="calEventBox")
events = table_cells[j].find_all(
style=lambda style: style
and "margin-right:.2em" in style.replace(" ", "")
)
pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True)
if len(events) == 0:
pdf.cell(0, 4, " ", 0, 1, fill=True)
for k in range(len(events)):
if k > 0:
pdf.cell(35, 4, " ", 0, 0, fill=True)
event_place = "--"
pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
if k < len(event_full_block):
event_time = event_full_block[k].find_all(style="font-weight:normal")
else:
event_time = []
if len(event_time) > 0:
try:
elements = str(event_time[0]).split("<")
except Exception:
print("error3")
else:
if len(elements) > 1 and ">" in elements[1]:
time_text = elements[1].split(">")[1]
if ":" in str(time_text):
pdf.set_x(pdf.get_x() - 20)
pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
if len(elements) > 2 and ">" in elements[2]:
place_text = elements[2].split(">")[1]
if place_text == "":
event_place = "--"
else:
if str(place_text).startswith(","):
event_place = str(place_text)[2:]
else:
event_place = str(place_text)
event_name = events[k].get_text(strip=True) if len(events) > 0 else ""
pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True)
pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True)
counter += 1
weekday += 1
# Fußzeile
pdf.set_font("helvetica", "", 8)
pdf.cell(0, 1, " ", 0, 1)
pdf.cell(0, 0.1, " ", 1, 1)
pdf.cell(0, 1, " ", 0, 1)
pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0)
pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
pdf.set_font("helvetica", "I", 8)
pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0)
pdf.set_font("helvetica", "", 8)
pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1)
#### PDF ERZEUGEN ####
output_path = os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf")
os.makedirs(os.path.dirname(output_path), exist_ok=True)
pdf.output(output_path)