Das Problem

Wer Hardwaregeräte in einem Unternehmen verwaltet, kennt die Situation: Seriennummern in Excel-Tabellen, Status in separaten Tickets, Verantwortlichkeiten per E-Mail kommuniziert. Wenn dann jemand fragt “Wo ist Gerät XY gerade?”, beginnt die Suche.

Das Ziel war eine zentrale, einfache Web-Anwendung: Hardware erfassen, Status pflegen, Änderungen nachverfolgen. Kein Over-Engineering, kein Framework-Overhead – aber echte Anforderungen wie Active Directory-Authentifizierung, Audit-Logging und Bulk-Import aus Excel.

Das Ergebnis ist eine CRUD-Anwendung mit realem Anwendungsbezug: inventory-htmx-fastapi.


Rollen & Entstehung

Dieses Projekt ist in enger Zusammenarbeit entstanden. Die Konzeptionierung, Anforderungsanalyse und Projektleitung lagen auf meiner Seite – von der initialen Idee über die Definition der Anforderungen bis hin zur Abstimmung der technischen Richtung und dem Abnahme-Prozess. Die Umsetzung und Programmierung hat mein Kollege Natnael geleistet, der sämtliche Entwicklungsaufgaben übernommen hat.

Wie das Projekt entstand

Der Ausgangspunkt war eine konkrete Schmerzstelle im Arbeitsalltag: Hardware-Verwaltung über verteilte Excel-Listen und Tickets. Keine zentrale Wahrheit, kein nachvollziehbarer Verlauf, zu viel manuelle Koordination.

Die erste Phase war die Anforderungsaufnahme – was muss die Anwendung leisten, was nicht? Die Kernfragen dabei:

  • Welche Daten müssen zu jedem Gerät erfasst werden?
  • Wer darf lesen, wer darf schreiben?
  • Wie sieht der Lifecycle eines Geräts aus, von Eingang bis Auslieferung?
  • Welche bestehenden Systeme müssen angebunden werden (Active Directory)?
  • Wie werden Bestände initial befüllt – und wie werden spätere Massenimporte gehandhabt?

Aus diesen Gesprächen entstanden die konkreten Spezifikationen: das Status-Modell mit fünf Stufen, die Rollenverteilung über AD-Gruppen, der zweistufige Bulk-Import mit Preview-Schritt und die Anforderung an lückenlose Audit-Logs.

Die technische Entscheidung für FastAPI und HTMX wurde gemeinsam getroffen – mit dem Ziel, eine wartbare Lösung zu bauen die ohne dediziertes Frontend-Team betrieben werden kann. Iterative Abstimmungen während der Entwicklung haben die Anforderungen weiter geschärft, bis das Ergebnis dem tatsächlichen Bedarf entsprach.


Der Tech-Stack – und warum

FastAPI        → Python Web-Framework, async, typsicher
SQLModel       → ORM auf Basis von SQLAlchemy + Pydantic
Alembic        → Datenbankmigrationen
HTMX           → Interaktivität ohne JavaScript-Framework
Jinja2         → Server-Side Rendering
Bootstrap 5    → UI-Komponenten
PostgreSQL     → Datenbank
python-ldap    → Active Directory Authentifizierung

FastAPI statt Django oder Flask

FastAPI war die bewusste Wahl für dieses Projekt. Die automatische API-Dokumentation über OpenAPI, native Pydantic-Validierung und das Dependency-Injection-System machen den Code strukturiert ohne viel Boilerplate. Für eine interne Anwendung dieser Größe ist FastAPI genau richtig – klein genug um schnell zu sein, mächtig genug für alle Anforderungen.

HTMX – Interaktivität ohne JavaScript-Framework

Die interessanteste Entscheidung war HTMX statt React, Vue oder einem anderen Frontend-Framework. Das Prinzip: HTML-Attribute steuern HTTP-Anfragen, der Server antwortet mit HTML-Fragmenten, HTMX tauscht diese im DOM aus.

<!-- Suche die bei jeder Eingabe die Tabelle neu lädt -->
<input
  type="text"
  name="search"
  hx-get="/hardware/table"
  hx-trigger="keyup changed delay:300ms"
  hx-target="#hardware-table-container"
  hx-push-url="true"
  placeholder="Search...">

Die Suche schickt einen GET-Request an /hardware/table, der Server gibt ein HTML-Fragment zurück, HTMX tauscht #hardware-table-container aus. Kein State-Management, kein Virtual DOM, kein Build-Step.

Das Ergebnis: eine reaktive Oberfläche mit Live-Suche, Statusfiltern und sortierbare Tabellen – alles server-side gerendert, ohne eine einzige JavaScript-Zeile selbst geschrieben zu haben.


Das Datenmodell

Ein Hardwaregerät trägt alle relevanten Informationen direkt:

class StatusEnum(str, Enum):
    IN_STOCK   = "IN_STOCK"
    RESERVED   = "RESERVED"
    IMAGING    = "IMAGING"
    SHIPPED    = "SHIPPED"
    COMPLETED  = "COMPLETED"

class Hardware(SQLModel, table=True):
    hostname:      str
    serial_number: str          # unique, indexed
    model:         ModelEnum    # Notebook, MFF, AllInOne, ...
    status:        StatusEnum
    ip:            Optional[str]
    mac:           Optional[str]
    uuid:          Optional[str]
    center:        Optional[str]
    enduser:       Optional[str]
    ticket:        Optional[str]
    po_ticket:     Optional[str]
    admin:         str          # wird automatisch aus Session gesetzt
    missing:       bool
    shipped_at:    Optional[datetime]  # gesetzt wenn Status → SHIPPED

Der shipped_at-Timestamp wird automatisch gesetzt wenn ein Gerät erstmals den Status SHIPPED bekommt – und bleibt danach erhalten, auch wenn der Status nochmals geändert wird. Das war ein konkretes Reporting-Requirement: wann wurde das Gerät tatsächlich verschickt?

Status-Lifecycle

Geräte durchlaufen einen definierten Lifecycle:

IN_STOCK → RESERVED → IMAGING → SHIPPED → COMPLETED

Im Code ist das als cycle_hardware_status implementiert – ein einzelner Button-Klick bewegt das Gerät zum nächsten Status:

def cycle_hardware_status(self, hardware_id: int, current_user: dict):
    status_cycle = [
        StatusEnum.IN_STOCK,
        StatusEnum.RESERVED,
        StatusEnum.IMAGING,
        StatusEnum.SHIPPED,
        StatusEnum.COMPLETED
    ]
    current_index = status_cycle.index(hardware.status)
    new_status = status_cycle[current_index + 1]
    # ...

COMPLETED ist ein Endstatus – kein weiteres Cycling möglich. Die Standardansicht filtert COMPLETED-Geräte automatisch heraus; sie sind im System, aber nicht im täglichen View.


Active Directory Authentifizierung

Eine der interessanteren technischen Herausforderungen: Authentifizierung gegen ein bestehendes Active Directory. Kein eigenes User-Management, kein Passwort-Speichern – Nutzer melden sich mit ihren AD-Zugangsdaten an.

Das Vorgehen:

def authenticate_ad(self, username: str, password: str):
    # 1. Service-Account-Bind zum Suchen des Users
    ldap_conn.simple_bind_s(settings.ldap_bind_dn, settings.ldap_bind_password)

    # 2. User im AD suchen, Gruppen auslesen
    result = ldap_conn.search_s(
        settings.ldap_base_dn,
        ldap.SCOPE_SUBTREE,
        f"(&(objectClass=user)(sAMAccountName={username}))",
        ['distinguishedName', 'memberOf', 'mail', 'displayName', 'objectGUID']
    )

    # 3. Rolle aus AD-Gruppen ableiten
    role = self._determine_role(user_groups)

    # 4. Credential-Prüfung: User-Bind mit eingegebenem Passwort
    user_conn.simple_bind_s(f"{username}@{domain}", password)

Die Rollenzuweisung erfolgt über AD-Gruppen: Mitglieder der admin_group bekommen administrator-Rechte, Mitglieder der visitor_group können nur lesen. Wer in keiner der beiden Gruppen ist, bekommt keinen Zugang.

Nach erfolgreicher Authentifizierung wird ein JWT-Session-Token ausgestellt und als Cookie gesetzt. Die Authentifizierung selbst läuft über eine Middleware – alle Routen außer /login und statischen Assets erfordern einen gültigen Token.


Audit-Logging als Middleware

Jede Aktion in der Anwendung wird geloggt: wer hat was wann geändert. Das klingt nach einer einfachen Anforderung, hat aber eine elegante Lösung bekommen.

Das Context-Variable Pattern

Das Problem: Ein HTTP-Request durchläuft mehrere Schichten (Middleware → Route → Service). Die Middleware kennt den User, der Service führt die Datenbankoperation durch. Wie bekommt der Service den User, ohne ihn durch jeden Funktionsaufruf durchzuschleusen?

Lösung: Python contextvars:

# context.py
from contextvars import ContextVar

audit_context: ContextVar[Optional[Dict]] = ContextVar("audit_context", default=None)

Die Middleware setzt den Context am Request-Eingang:

context_data = {
    "method": request.method,
    "path": request.url.path,
    "username": current_user.get("username"),
    "status_code": 500,  # wird überschrieben nach Response
}
token = audit_context.set(context_data)

Der AuditLog-Eintrag wird als Background Task nach der Response geschrieben – die Antwortzeit des Requests wird dadurch nicht beeinflusst:

response.background.add_task(log_to_database, log_data=log_data_for_access_log)

Das AuditLog-Model erfasst dabei nicht nur die Businesslogik-Ebene, sondern den vollständigen HTTP-Kontext: Method, Path, Status Code, Response-Zeit, IP-Adresse, User-Agent und die semantische Aktion (CREATE, UPDATE, DELETE) mit Entity-Name und ID.


Bulk-Import: Excel und CSV

Eine Anforderung aus der Praxis: Bestehende Hardware-Listen aus Excel importieren. Das hätte simpel sein können – wurde aber sorgfältig umgesetzt.

Der Import läuft in zwei Phasen:

Phase 1: Parse & Preview – Die Datei wird validiert ohne etwas zu schreiben. Fehler werden pro Zeile gemeldet, gültige Einträge werden als Preview angezeigt:

def parse_import_file(self, file_content: bytes, filename: str):
    # CSV oder Excel automatisch erkennen
    # Jede Zeile validieren (Pflichtfelder, gültige Enums)
    # Duplikate innerhalb der Datei erkennen
    # Existing vs. New klassifizieren (create / update)
    return {
        "valid_items": [...],
        "errors": ["Row 5: Invalid model 'Laptop'", ...],
        "create_count": 12,
        "update_count": 3,
    }

Phase 2: Apply – Erst nach Bestätigung werden die Daten geschrieben. Upsert-Logik über Seriennummer: existiert das Gerät schon, wird es aktualisiert; sonst neu angelegt.

Das verhindert blinde Imports und gibt dem Administrator die Kontrolle vor dem Commit.


QR-Code und Label-Export

Für jedes Gerät lässt sich ein QR-Code generieren der direkt auf die Detail-Seite verlinkt:

def generate_qr_code(self, hardware_id: int):
    api_url = f"{settings.base_url}/hardware/{hardware_id}"
    qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
    qr.add_data(api_url)
    # → PNG als Download

Dazu gibt es einen Label-CSV-Export der direkt in Etikettendruck-Software importiert werden kann:

HN,HOSTNAME-001
SN,ABC123456
IP,192.168.1.100
T#,TICKET-4711
PO#,PO-2024-001
User,Max Mustermann
Cent,München

Kleine Features – aber genau die Art von Anwendungsbezug der eine CRUD-App von einer echten Lösung unterscheidet.


Architektur

app/
├── core/           # Config, DB-Session, Template-Setup
├── models/         # SQLModel-Klassen (Hardware, User, AuditLog)
├── services/       # Business-Logik (HardwareService, AuthService, AuditService)
├── routes/         # FastAPI Router (hardware, auth, audit_log, pages)
├── middleware/     # Auth-Middleware, Audit-Logging-Middleware
├── dependencies/   # Dependency Injection (require_admin, get_current_user)
├── audit/          # ContextVar für Request-Context
└── templates/      # Jinja2 Templates + Partials (für HTMX-Responses)
    └── partials/   # Hardware-Tabelle, Filter, Stats-Cards (HTMX-Fragmente)

Die Trennung von Service-Layer und Routes ist konsequent durchgezogen. Routes sind dünn – sie nehmen Request-Parameter entgegen, delegieren an Services und geben Templates zurück. Die Geschäftslogik liegt vollständig in den Services und ist damit testbar ohne HTTP-Kontext.


Was gut funktioniert hat

HTMX für interne Tools ist eine hervorragende Entscheidung. Die Live-Suche, die Filter-Interaktivität und die Status-Updates ohne Seitenreload fühlen sich modern an – ohne ein JavaScript-Framework einzuführen. Für ein internes Tool wo die Komplexität der Anwendung im Backend liegt, ist das der richtige Trade-off.

SQLModel als Kombination aus SQLAlchemy und Pydantic hat sich bewährt. Ein Modell definiert gleichzeitig die Datenbankstruktur, die Validierungsregeln und das Schema für API-Responses.

Alembic für Datenbankmigrationen macht Schema-Änderungen nachvollziehbar und reproduzierbar – besonders wichtig wenn die Anwendung in Docker deployed wird und die Datenbank beim Update migriert werden muss.


Was anders gemacht werden könnte

Die audit_context-Lösung mit contextvars funktioniert – hat aber den Nachteil dass der Audit-Context implizit ist. Wer den Code liest, sieht nicht sofort wo die Daten herkommen. Eine explizitere Dependency-Injection wäre klarer, wenn auch ausführlicher.

Der Bulk-Import hat aktuell keine Transaktionssicherheit über den gesamten Import hinweg. Schlägt Zeile 47 von 100 fehl, sind die ersten 46 bereits geschrieben. Ein vollständiges Rollback bei Fehlern wäre für kritische Datenimporte besser.


Deployment

Die Anwendung läuft containerisiert:

services:
  app:
    build: .
    environment:
      - DATABASE_URL=postgresql://...
      - LDAP_URL=ldap://...
      - SECRET_KEY=...
    depends_on:
      - postgres

  postgres:
    image: postgres:15

Datenbankmigrationen laufen beim Start automatisch durch Alembic. Die Konfiguration kommt vollständig aus Umgebungsvariablen – kein Config-File im Container.


Fazit

Eine CRUD-App ist nie “nur eine CRUD-App” wenn echte Anforderungen dahinterstehen. Active Directory-Authentifizierung, lückenlose Audit-Logs, zweistufiger Bulk-Import und ein klarer Status-Lifecycle sind keine akademischen Übungen – das sind Anforderungen aus der Praxis.

HTMX hat sich dabei als die richtige Wahl für den Frontend-Ansatz erwiesen: Die Komplexität bleibt im Python-Backend, die Oberfläche ist trotzdem reaktiv. Für interne Unternehmensanwendungen, wo Entwicklungsgeschwindigkeit und Wartbarkeit wichtiger sind als eine SPA-Architektur, ist das ein Muster das sich wiederholen lässt.

Der Quellcode ist auf GitHub verfügbar: TecDevOrg/inventory-htmx-fastapi