Geschützte Excel-Formulare mit Python bauen — openpyxl in der Praxis

Das Problem: Daten aus dem Feld zurückbekommen

Wer in einer Organisation arbeitet, kennt das: Daten werden zentral gesammelt, aber die Rückmeldung muss von den Leuten vor Ort kommen. In unserem Fall: Rettungswachen müssen monatlich Einsätze prüfen, die in der Abrechnung fehlen. Die Wachenleiter sollen pro Einsatz einen Kommentar eintragen, aber nichts anderes verändern.

Die Lösung: Automatisch generierte Excel-Dateien, die wie Formulare funktionieren. Alle Daten sind gesperrt, nur die Kommentarspalte ist editierbar. Pro Wache eine eigene Datei, alles als ZIP zum Download.

Warum XLSX und nicht ein Webformular?

Berechtigte Frage. Aber in der Praxis:

  • Vertraut: Jeder kennt Tabellenkalkulationen, niemand braucht eine Schulung
  • Flexibel: Wachenleiter können filtern, sortieren, drucken
  • Batch-fähig: 18 Wachen × 12 Monate = 216 Dateien pro Jahr, automatisch generiert
  • Kollaborativ: Die Dateien landen in der Nextcloud, ein Ordner pro Wache. Bearbeitung direkt im Browser über Nextcloud Office (Collabora), kein lokales Excel nötig
  • Offline-Fallback: Wer will, kann die Datei trotzdem herunterladen und lokal bearbeiten

Der Clou: Die Wachenleiter öffnen ihre XLSX direkt in der Nextcloud, tragen ihren Kommentar in die freigegebene Spalte ein, speichern. Fertig. Kein Upload, kein Mail-Ping-Pong. Und dank Blattschutz können sie nur die Kommentarspalte bearbeiten, der Rest der Daten ist sicher vor versehentlichen Änderungen.

Das Webformular wäre technisch eleganter, aber die Kombination aus XLSX + Nextcloud Office ist pragmatischer (und der Blattschutz funktioniert auch in Collabora).

Die Zutaten

1
pip install openpyxl

Ein Paket, keine weiteren Abhängigkeiten. openpyxl kann XLSX-Dateien lesen und schreiben, inklusive Styles, Schutz und Formeln.

Der Kern: Geschützte Arbeitsblätter mit einer offenen Spalte

Das Prinzip ist einfach:

  1. Blattschutz aktivieren: das gesamte Worksheet wird gesperrt
  2. Alle Zellen auf locked=True setzen (Default)
  3. Die Kommentarspalte auf locked=False setzen
  4. Passwort vergeben: damit der Schutz nicht versehentlich aufgehoben wird
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from openpyxl import Workbook
from openpyxl.styles import (
    Font, Alignment, Border, Side,
    PatternFill, Protection
)

def create_protected_xlsx(rows, headers, editable_column=6):
    """
    Erzeugt eine geschützte XLSX-Datei.
    Nur die angegebene Spalte (1-basiert) ist editierbar.
    """
    wb = Workbook()
    ws = wb.active
    ws.title = "Daten"

    # Schutz-Objekte
    locked = Protection(locked=True)
    unlocked = Protection(locked=False)

    # === Header schreiben ===
    for col_idx, header in enumerate(headers, 1):
        cell = ws.cell(row=1, column=col_idx, value=header)
        cell.protection = locked

    # === Daten schreiben ===
    for row_idx, row in enumerate(rows, 2):
        for col_idx, value in enumerate(row, 1):
            cell = ws.cell(row=row_idx, column=col_idx, value=value)
            if col_idx == editable_column:
                cell.protection = unlocked  # ← Diese Spalte darf bearbeitet werden
            else:
                cell.protection = locked

    # === Blattschutz aktivieren ===
    ws.protection.sheet = True
    ws.protection.password = 'MeinGeheimesPasswort'
    ws.protection.enable()

    # === Erste Zeile fixieren ===
    ws.freeze_panes = 'A2'

    return wb

Das war’s im Kern. 30 Zeilen für ein geschütztes Excel-Formular.

Professionelles Styling

Nackte Tabellen werden ignoriert. Ein bisschen Farbe und Struktur erhöht die Akzeptanz dramatisch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Styles definieren
header_fill = PatternFill(
    start_color='4472C4', end_color='4472C4', fill_type='solid')
header_font = Font(bold=True, size=11, color='FFFFFF')

# Editierbare Spalte hervorheben
editable_fill = PatternFill(
    start_color='FFF2CC', end_color='FFF2CC', fill_type='solid')
editable_header_fill = PatternFill(
    start_color='FFC000', end_color='FFC000', fill_type='solid')

thin_border = Border(
    left=Side(style='thin'),
    right=Side(style='thin'),
    top=Side(style='thin'),
    bottom=Side(style='thin')
)

Die editierbare Spalte bekommt einen gelben Hintergrund: sowohl im Header (kräftiges Orange) als auch in den Datenzellen (zartes Gelb). Das signalisiert sofort: „Hier bist du dran."

Styling anwenden

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
for col_idx, header in enumerate(headers, 1):
    cell = ws.cell(row=1, column=col_idx, value=header)
    cell.border = thin_border
    cell.alignment = Alignment(horizontal='center', wrap_text=True)
    cell.protection = locked

    if col_idx == editable_column:
        cell.font = Font(bold=True, size=11)
        cell.fill = editable_header_fill
    else:
        cell.font = header_font
        cell.fill = header_fill

for row_idx, row in enumerate(rows, 2):
    for col_idx, value in enumerate(row, 1):
        cell = ws.cell(row=row_idx, column=col_idx, value=value)
        cell.border = thin_border
        cell.alignment = Alignment(wrap_text=True)

        if col_idx == editable_column:
            cell.protection = unlocked
            cell.fill = editable_fill
        else:
            cell.protection = locked

Spaltenbreiten. Der unterschätzte Qualitätsfaktor

Nichts schreit lauter „automatisch generiert" als Spalten, die alle gleich breit sind. Ein Dict mit sinnvollen Breiten macht den Unterschied:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from openpyxl.utils import get_column_letter

col_widths = {
    1: 16,   # Datum
    2: 14,   # Nummer
    3: 10,   # Uhrzeit
    4: 16,   # Kennung
    5: 50,   # Beschreibung (braucht Platz)
    6: 40,   # Kommentar (editierbar)
    7: 25,   # Kategorie
    # ...
}

for col_idx, width in col_widths.items():
    if col_idx <= len(headers):
        ws.column_dimensions[get_column_letter(col_idx)].width = width

Tipp: Die Breiten-Einheit in openpyxl entspricht ungefähr der Zeichenbreite. Eine Spalte mit width=50 fasst etwa 50 Zeichen nebeneinander, bevor der Text umbricht.

Batch-Generierung: 18 Wachen, ein ZIP

In der Praxis generieren wir nicht eine Datei, sondern 18 gleichzeitig: eine pro Rettungswache. Jede bekommt nur die Einsätze, die zu dieser Wache gehören. Alles landet in einem ZIP:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io
import zipfile

def generate_batch(stations, month, year, query_func):
    """
    Generiert für jede Station eine XLSX und packt alles in ein ZIP.

    stations: Liste von (name, filter_params)
    query_func: Funktion die (filter_params, month, year) → (rows, headers)
    """
    zip_buf = io.BytesIO()

    with zipfile.ZipFile(zip_buf, 'w', zipfile.ZIP_DEFLATED) as zf:
        for name, params in stations:
            rows, headers = query_func(params, month, year)

            wb = create_protected_xlsx(rows, headers)
            xlsx_buf = io.BytesIO()
            wb.save(xlsx_buf)
            xlsx_buf.seek(0)

            filename = f'{name}_{year}_{month:02d}.xlsx'
            zf.writestr(filename, xlsx_buf.getvalue())

    zip_buf.seek(0)
    return zip_buf

Das ZIP kann dann als Download angeboten oder direkt in eine Nextcloud hochgeladen werden:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from flask import send_file

@app.route('/download/batch/<int:year>/<int:month>')
@login_required
def download_batch(year, month):
    zip_buf = generate_batch(STATIONS, month, year, run_query)

    return send_file(
        zip_buf,
        mimetype='application/zip',
        as_attachment=True,
        download_name=f'nacherfassung_{year}_{month:02d}.zip'
    )

In unserem Fall landen die einzelnen XLSX-Dateien direkt in der Nextcloud: ein Ordner pro Wache. Die Wachenleiter öffnen ihre Datei über Nextcloud Office im Browser, füllen die Kommentarspalte aus und speichern. Kein Download, kein Upload, kein E-Mail-Versand. Der Workflow reduziert sich auf: Datei öffnen → Spalte ausfüllen → speichern.

Intelligente Feststellungen statt roher Daten

Der eigentliche Mehrwert liegt nicht in der Excel-Generierung, sondern in der Logik dahinter. Statt einfach nur Rohdaten aufzulisten, prüft das System jeden Einsatz gegen die Abrechnungsdatenbank und generiert eine menschenlesbare Feststellung:

1
2
3
4
5
6
7
8
CASE
    WHEN abrechnung.id IS NOT NULL THEN
        'Erfasst unter falscher Nummer'
    WHEN alternative.id IS NOT NULL THEN
        'Wurde falsch unter anderem Fahrzeug erfasst'
    ELSE
        'Dringend nachzuerfassen!'
END AS Feststellung

Der Wachenleiter sieht also nicht nur „dieser Einsatz fehlt", sondern warum er fehlt und wo er möglicherweise gelandet ist. Das spart Rückfragen und beschleunigt die Bearbeitung erheblich.

Tipps aus der Praxis

1. Passwort ≠ Sicherheit

Der Blattschutz in Excel ist kein Sicherheitsfeature. Das Passwort lässt sich trivial umgehen. Der Schutz verhindert versehentliche Änderungen, nicht böswillige. Für echte Datensicherheit braucht es andere Maßnahmen.

2. freeze_panes ist Pflicht

1
ws.freeze_panes = 'A2'

Eine Zeile Code, aber enorm wichtig: Der Header bleibt beim Scrollen sichtbar. Bei Tabellen mit 200+ Zeilen ist das der Unterschied zwischen brauchbar und unbrauchbar.

3. Dateinamen mit Kontext

1
2
filename = f'{wache}_{year}_{month:02d}.xlsx'
# → mainz_2026_02.xlsx

Wenn 18 Dateien in einem ZIP liegen, muss der Name selbsterklärend sein. Keine generischen export.xlsx.

4. Leere Tabellen abfangen

1
2
3
4
if not rows:
    # Leere Datei mit Headers generieren
    headers = ['Datum', 'Nummer', 'Uhrzeit', ...]
    rows = []

Wenn eine Wache im Monat keine fehlenden Einsätze hat: trotzdem eine Datei erzeugen. Das signalisiert „wurde geprüft, nichts offen" (besser als eine fehlende Datei, die Fragen aufwirft).

5. In-Memory statt Dateisystem

1
2
3
xlsx_buf = io.BytesIO()
wb.save(xlsx_buf)
xlsx_buf.seek(0)

Alles passiert im RAM. Keine temporären Dateien, kein Aufräumen, kein Race-Condition-Risiko. Besonders wichtig bei Web-Anwendungen mit mehreren gleichzeitigen Requests.

Fazit

openpyxl macht es überraschend einfach, professionelle Excel-Formulare zu generieren. Der Blattschutz mit gezielt freigegebenen Spalten ist ein Pattern, das in vielen Organisationen sofort nützlich ist — überall dort, wo Daten zentral generiert und dezentral ergänzt werden.

Die Kernzutaten:

  • Protection(locked=False) für editierbare Zellen
  • ws.protection.sheet = True + Passwort für den Rest
  • Farbliche Hervorhebung der editierbaren Bereiche
  • io.BytesIO() für In-Memory-Generierung
  • ZIP-Archiv für Batch-Verarbeitung

Das gesamte Pattern (von der Datenbankabfrage über die XLSX-Generierung bis zum ZIP-Download) lässt sich in unter 200 Zeilen Python umsetzen. Nicht schlecht für etwas, das in vielen Unternehmen manuell gemacht wird.


Code-Beispiele basieren auf einer produktiven Flask-Anwendung im Rettungsdienst-QM. Das Paket: openpyxl (MIT-Lizenz).