From 059f386c60b3404c035a2e41fbb147b0c1aaee43 Mon Sep 17 00:00:00 2001 From: DenisToxic Date: Tue, 29 Sep 2026 08:43:05 +0200 Subject: [PATCH] Initialize clean CSR inventory repository --- .dockerignore | 17 + .env.example | 20 + .gitattributes | 2 + .github/workflows/tests.yml | 39 + .gitignore | 19 + Dockerfile | 18 + README.md | 297 ++++++ changes.md | 175 ++++ csr_inventory/__init__.py | 1 + csr_inventory/settings.py | 144 +++ csr_inventory/sqlite_migration_settings.py | 10 + csr_inventory/test_settings.py | 12 + csr_inventory/urls.py | 15 + csr_inventory/wsgi.py | 5 + deploy/csr-inventory-tls.service | 9 + deploy/csr-inventory-tls.timer | 9 + docker-compose.dev.yml | 10 + docker-compose.lan.yml | 6 + docker-compose.yml | 93 ++ docs/datenbankschema.md | 29 + docs/homelab-root-ca.crt | 11 + docs/postgresql-migration.md | 113 +++ docs/serverbetrieb.md | 160 ++++ docs/traefik.md | 58 ++ inventory/__init__.py | 1 + inventory/admin.py | 120 +++ inventory/api.py | 282 ++++++ inventory/api_auth.py | 64 ++ inventory/api_pagination.py | 7 + inventory/api_urls.py | 13 + inventory/apps.py | 9 + inventory/context_processors.py | 10 + inventory/cpu_catalog.py | 104 +++ inventory/cpu_models.txt | 640 +++++++++++++ inventory/device_types.py | 61 ++ inventory/error_views.py | 13 + inventory/forms.py | 313 +++++++ inventory/import_export.py | 267 ++++++ inventory/management/__init__.py | 1 + inventory/management/commands/__init__.py | 1 + .../management/commands/create_api_key.py | 46 + .../commands/generate_test_devices.py | 53 ++ .../commands/import_sqlite_export.py | 51 + .../management/commands/seed_initial_data.py | 55 ++ inventory/migrations/0001_initial.py | 233 +++++ .../0002_postgres_search_indexes.py | 30 + .../0003_audit_revert_and_device_types.py | 51 + .../migrations/0004_choicevalue_color.py | 24 + ..._api_client_device_hwid_inventoryapikey.py | 50 + inventory/migrations/0006_device_revision.py | 16 + inventory/migrations/0007_workflow_tasks.py | 67 ++ .../0008_normalize_hardware_by_type.py | 36 + .../0009_split_device_specifications.py | 237 +++++ .../migrations/0010_workflow_integrity.py | 63 ++ .../0011_inspectionrecord_repairrecord.py | 98 ++ ...ification_desktopspecification_and_more.py | 302 ++++++ inventory/migrations/__init__.py | 0 inventory/models.py | 564 ++++++++++++ inventory/permissions.py | 37 + inventory/query.py | 167 ++++ inventory/services.py | 389 ++++++++ inventory/signals.py | 12 + inventory/static/inventory/css/app.css | 179 ++++ inventory/static/inventory/img/csr-logo.png | Bin 0 -> 7509 bytes inventory/static/inventory/js/app.js | 404 ++++++++ inventory/static/inventory/js/theme.js | 39 + inventory/templates/403.html | 1 + inventory/templates/404.html | 1 + inventory/templates/500.html | 13 + .../templates/inventory/_device_table.html | 61 ++ .../templates/inventory/archive_list.html | 1 + inventory/templates/inventory/audit_list.html | 5 + inventory/templates/inventory/base.html | 49 + .../templates/inventory/configuration.html | 21 + .../templates/inventory/device_detail.html | 37 + .../templates/inventory/device_list.html | 113 +++ inventory/templates/inventory/import_map.html | 20 + .../templates/inventory/import_upload.html | 1 + inventory/templates/inventory/overview.html | 59 ++ inventory/templates/inventory/quick_add.html | 13 + .../templates/inventory/simple_form.html | 17 + inventory/templates/inventory/task_list.html | 8 + inventory/templates/inventory/user_list.html | 4 + .../templates/inventory/workflow_board.html | 42 + inventory/templates/registration/login.html | 5 + inventory/templatetags/__init__.py | 1 + inventory/templatetags/inventory_tags.py | 66 ++ inventory/test_migration.py | 53 ++ inventory/test_security.py | 161 ++++ inventory/test_specification_migration.py | 72 ++ inventory/test_workflow.py | 266 ++++++ inventory/tests.py | 662 +++++++++++++ inventory/urls.py | 43 + inventory/views.py | 868 ++++++++++++++++++ inventory/workflow.py | 146 +++ manage.py | 14 + media/imports/.gitkeep | 1 + requirements.txt | 7 + scripts/backup.sh | 20 + scripts/entrypoint.sh | 7 + scripts/export_sqlite.py | 57 ++ scripts/renew_local_tls.py | 117 +++ scripts/restore.sh | 17 + scripts/test_backup_scripts.py | 67 ++ scripts/test_frontend.cjs | 58 ++ scripts/test_local_tls.py | 54 ++ traefik/dynamic/routes.yml | 30 + 107 files changed, 9639 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 changes.md create mode 100644 csr_inventory/__init__.py create mode 100644 csr_inventory/settings.py create mode 100644 csr_inventory/sqlite_migration_settings.py create mode 100644 csr_inventory/test_settings.py create mode 100644 csr_inventory/urls.py create mode 100644 csr_inventory/wsgi.py create mode 100644 deploy/csr-inventory-tls.service create mode 100644 deploy/csr-inventory-tls.timer create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.lan.yml create mode 100644 docker-compose.yml create mode 100644 docs/datenbankschema.md create mode 100644 docs/homelab-root-ca.crt create mode 100644 docs/postgresql-migration.md create mode 100644 docs/serverbetrieb.md create mode 100644 docs/traefik.md create mode 100644 inventory/__init__.py create mode 100644 inventory/admin.py create mode 100644 inventory/api.py create mode 100644 inventory/api_auth.py create mode 100644 inventory/api_pagination.py create mode 100644 inventory/api_urls.py create mode 100644 inventory/apps.py create mode 100644 inventory/context_processors.py create mode 100644 inventory/cpu_catalog.py create mode 100644 inventory/cpu_models.txt create mode 100644 inventory/device_types.py create mode 100644 inventory/error_views.py create mode 100644 inventory/forms.py create mode 100644 inventory/import_export.py create mode 100644 inventory/management/__init__.py create mode 100644 inventory/management/commands/__init__.py create mode 100644 inventory/management/commands/create_api_key.py create mode 100644 inventory/management/commands/generate_test_devices.py create mode 100644 inventory/management/commands/import_sqlite_export.py create mode 100644 inventory/management/commands/seed_initial_data.py create mode 100644 inventory/migrations/0001_initial.py create mode 100644 inventory/migrations/0002_postgres_search_indexes.py create mode 100644 inventory/migrations/0003_audit_revert_and_device_types.py create mode 100644 inventory/migrations/0004_choicevalue_color.py create mode 100644 inventory/migrations/0005_auditlog_api_client_device_hwid_inventoryapikey.py create mode 100644 inventory/migrations/0006_device_revision.py create mode 100644 inventory/migrations/0007_workflow_tasks.py create mode 100644 inventory/migrations/0008_normalize_hardware_by_type.py create mode 100644 inventory/migrations/0009_split_device_specifications.py create mode 100644 inventory/migrations/0010_workflow_integrity.py create mode 100644 inventory/migrations/0011_inspectionrecord_repairrecord.py create mode 100644 inventory/migrations/0012_allinonespecification_desktopspecification_and_more.py create mode 100644 inventory/migrations/__init__.py create mode 100644 inventory/models.py create mode 100644 inventory/permissions.py create mode 100644 inventory/query.py create mode 100644 inventory/services.py create mode 100644 inventory/signals.py create mode 100644 inventory/static/inventory/css/app.css create mode 100644 inventory/static/inventory/img/csr-logo.png create mode 100644 inventory/static/inventory/js/app.js create mode 100644 inventory/static/inventory/js/theme.js create mode 100644 inventory/templates/403.html create mode 100644 inventory/templates/404.html create mode 100644 inventory/templates/500.html create mode 100644 inventory/templates/inventory/_device_table.html create mode 100644 inventory/templates/inventory/archive_list.html create mode 100644 inventory/templates/inventory/audit_list.html create mode 100644 inventory/templates/inventory/base.html create mode 100644 inventory/templates/inventory/configuration.html create mode 100644 inventory/templates/inventory/device_detail.html create mode 100644 inventory/templates/inventory/device_list.html create mode 100644 inventory/templates/inventory/import_map.html create mode 100644 inventory/templates/inventory/import_upload.html create mode 100644 inventory/templates/inventory/overview.html create mode 100644 inventory/templates/inventory/quick_add.html create mode 100644 inventory/templates/inventory/simple_form.html create mode 100644 inventory/templates/inventory/task_list.html create mode 100644 inventory/templates/inventory/user_list.html create mode 100644 inventory/templates/inventory/workflow_board.html create mode 100644 inventory/templates/registration/login.html create mode 100644 inventory/templatetags/__init__.py create mode 100644 inventory/templatetags/inventory_tags.py create mode 100644 inventory/test_migration.py create mode 100644 inventory/test_security.py create mode 100644 inventory/test_specification_migration.py create mode 100644 inventory/test_workflow.py create mode 100644 inventory/tests.py create mode 100644 inventory/urls.py create mode 100644 inventory/views.py create mode 100644 inventory/workflow.py create mode 100644 manage.py create mode 100644 media/imports/.gitkeep create mode 100644 requirements.txt create mode 100644 scripts/backup.sh create mode 100644 scripts/entrypoint.sh create mode 100644 scripts/export_sqlite.py create mode 100644 scripts/renew_local_tls.py create mode 100644 scripts/restore.sh create mode 100644 scripts/test_backup_scripts.py create mode 100644 scripts/test_frontend.cjs create mode 100644 scripts/test_local_tls.py create mode 100644 traefik/dynamic/routes.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07c351f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.venv +__pycache__ +*.pyc +.DS_Store +.env +*.sqlite3 +staticfiles +backups +media +*.zip +*.tar.gz +*.log +.pytest_cache +.coverage +tls +traefik/dynamic/tls.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8cfd6f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +DEBUG=false +SECRET_KEY=replace-with-a-long-random-value +ALLOWED_HOSTS=inventory.example.org,127.0.0.1,localhost +CSRF_TRUSTED_ORIGINS=https://inventory.example.org +SECURE_COOKIES=true +SECURE_SSL_REDIRECT=true +TIME_ZONE=Europe/Berlin +POSTGRES_DB=csr_inventory +POSTGRES_USER=csr_inventory +POSTGRES_PASSWORD=replace-with-a-strong-password +POSTGRES_HOST=db +POSTGRES_PORT=5432 +APP_HOST=inventory.example.org +# Public domain: letsencrypt. LAN: docker-compose.lan.yml disables ACME. +TRAEFIK_CERT_RESOLVER=letsencrypt +ACME_EMAIL=admin@example.org +MAX_UPLOAD_SIZE=10485760 +LOG_LEVEL=WARNING +API_ANON_RATE=20/min +API_CLIENT_RATE=300/min diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..aa8627c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests +on: [push, pull_request] +jobs: + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_DB: csr_inventory + POSTGRES_USER: csr_inventory + POSTGRES_PASSWORD: ci-only-password + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U csr_inventory -d csr_inventory" + --health-interval 5s --health-timeout 5s --health-retries 12 + env: + DEBUG: "true" + SECURE_SSL_REDIRECT: "false" + POSTGRES_DB: csr_inventory + POSTGRES_USER: csr_inventory + POSTGRES_PASSWORD: ci-only-password + POSTGRES_HOST: localhost + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install -r requirements.txt + - run: python manage.py check + - run: python manage.py makemigrations --check --dry-run + - run: python manage.py test --noinput + - run: python scripts/test_backup_scripts.py + - run: python scripts/test_local_tls.py + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm install --prefix /tmp/csr-js-tests jsdom@26 + - run: node scripts/test_frontend.cjs /tmp/csr-js-tests/node_modules/jsdom diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f45326 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.env +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +media/* +!media/imports/ +media/imports/* +!media/imports/.gitkeep +staticfiles/ +.DS_Store +*.log +*.zip +*.tar.gz +backups/ +tls/ +traefik/dynamic/tls.yml +.pytest_cache/ +.coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8aaf35a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +RUN addgroup --system app && adduser --system --ingroup app app \ + && apt-get update && apt-get install -y --no-install-recommends libpq5 postgresql-client \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN chmod +x /app/scripts/entrypoint.sh /app/scripts/backup.sh /app/scripts/restore.sh \ + && mkdir -p /app/staticfiles /app/media \ + && chown -R app:app /app +USER app +EXPOSE 8000 +ENTRYPOINT ["/app/scripts/entrypoint.sh"] +CMD ["gunicorn", "csr_inventory.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "2", "--timeout", "60", "--access-logfile", "-", "--error-logfile", "-"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a2d486 --- /dev/null +++ b/README.md @@ -0,0 +1,297 @@ +# CS-R Geräteverwaltung + +Produktionsnahe interne Geräteverwaltung als kompakter Django-Monolith. Die Anwendung ersetzt die bisherige Excel-Liste und ist für den Betrieb auf einem kleinen Linux-VPS ausgelegt. + +## Funktionen + +- Rollenbasierte Anmeldung mit `ADMIN`, `EDITOR` und `VIEWER` +- Schneller Quick-Add für einzelne Geräte oder Serien mit automatisch fortlaufenden, niemals wiederverwendeten Gerätenummern +- Serverseitige Suche, kombinierbare Filter, Sortierung und Pagination +- Kompakte Gerätetabelle mit ein-/ausblendbaren Spalten und Inline-Bearbeitung +- Detail-Drawer und vollständige Bearbeitungsansicht +- Optimistic Concurrency gegen unbemerktes Überschreiben +- Globale, deaktivierbare Auswahlwerte und Mitarbeiterverwaltung +- Benutzerdefinierte Felder ohne neue Datenbankmigration +- Transaktionaler XLSX-Import mit Vorschau und Spaltenzuordnung +- CSV-, XLSX- und ODS-Export für LibreOffice, auch für gefilterte Bestände +- Bearbeitbare Statusfarben mit klar unterscheidbarer Farbpalette +- CPU-Livevorschläge aus einer Intel-/AMD-Masterliste mit Hersteller- und Serienfiltern +- Vollständige Änderungshistorie und gerätebezogenes Audit-Log +- Abgesicherte JSON-API mit getrennten Lese-/Schreibschlüsseln, Ablaufdatum und API-Audit +- Archivierung und Wiederherstellung statt versehentlichem Löschen +- PostgreSQL-Trigramm- und JSONB-Indizes für große Bestände +- Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS +- Healthchecks, persistente Volumes und einfache Backup-/Restore-Skripte + +## Lokale Entwicklung + +Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose. +Die Anwendung verwendet auch lokal PostgreSQL. Für bestehende SQLite-Daten siehe +[Docker-Migration zu PostgreSQL](docs/postgresql-migration.md). + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +# POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD und POSTGRES_HOST +# in der Prozessumgebung setzen (lokal typischerweise POSTGRES_HOST=localhost). +python manage.py migrate +python manage.py seed_initial_data +python manage.py createsuperuser +python manage.py runserver +``` + +Ohne PostgreSQL-Verbindung startet die Datenbankfunktion nicht; es wird keine +SQLite-Datenbank mehr automatisch geöffnet oder angelegt. `.env` wird von Docker +Compose geladen, bei direktem Python-Start müssen die Variablen gesetzt sein. +Der [Migrationsleitfaden](docs/postgresql-migration.md) enthält auch einen lokalen +Docker-Start ohne HTTPS-Domain. + +Testdaten können gezielt erzeugt werden: + +```bash +python manage.py generate_test_devices 5000 +``` + +## Installation auf Netcup/Linux + +### 1. Server vorbereiten + +Empfohlen sind Ubuntu 24.04 oder Debian 12, mindestens 2 GB RAM, eine feste IPv4/IPv6-Adresse und eine Domain. In DNS einen A- und optional AAAA-Record der gewünschten Subdomain auf den Server zeigen lassen. Die Ports 22, 80 und 443 in der Firewall öffnen. + +Docker gemäß der offiziellen Docker-Anleitung installieren. Anschließend prüfen: + +```bash +docker --version +docker compose version +``` + +### 2. Projekt und Umgebungsvariablen + +```bash +git clone csr-inventory +cd csr-inventory +cp .env.example .env +``` + +In `.env` mindestens ändern: + +- `SECRET_KEY`: langer Zufallswert, z. B. mit `openssl rand -base64 48` +- `POSTGRES_PASSWORD`: starkes, einmaliges Datenbankpasswort +- `APP_HOST`: Domain ohne Protokoll/Port, z. B. `geraete.example.org` +- `ACME_EMAIL`: Kontaktadresse für öffentliche TLS-Zertifikate +- `ALLOWED_HOSTS`: dieselbe Domain sowie `127.0.0.1,localhost` für den internen Healthcheck +- `CSRF_TRUSTED_ORIGINS`: vollständige HTTPS-URL + +Die Datei `.env` niemals in Git einchecken. + +### 3. Starten + +```bash +docker compose up -d --build +docker compose ps +docker compose logs -f web traefik +``` + +Beim Start wartet der Webdienst auf PostgreSQL, führt Migrationen und das idempotente Seed-Command aus und sammelt statische Dateien. Traefik fordert anschließend automatisch ein TLS-Zertifikat an. + +### 4. Ersten Administrator anlegen + +```bash +docker compose exec web python manage.py createsuperuser +``` + +Danach unter `https://IHRE-DOMAIN/accounts/login/` anmelden. Weitere Konten und ihre Rollen `ADMIN`, `EDITOR` oder `VIEWER` werden direkt im Bereich „Benutzer“ verwaltet. Es gibt keine öffentliche Registrierung. + +## Aktualisierung + +Vor jeder Aktualisierung ein Backup erstellen. Danach: + +```bash +git pull +docker compose up -d --build +docker compose logs -f web +``` + +Migrationen und Seed-Daten werden beim Containerstart automatisch angewendet. + +## Sichere API für interne Skripte + +Die API läuft unter derselben HTTPS-Domain wie die Anwendung, zum Beispiel +`https://inventory.example.org/api/v1/`. Eine zusätzliche FastAPI-Anwendung oder +eine zweite Subdomain ist für diesen internen Anwendungsfall nicht nötig. Das hält +Betrieb, Rechteprüfung und Änderungsprotokoll an einer Stelle. + +Browser-Sitzung und API sind bewusst getrennt: Auch ein im Browser angemeldeter +Benutzer kann die API ohne API-Schlüssel nicht lesen. Schlüssel werden nur gehasht +gespeichert, sind widerrufbar, laufen standardmäßig nach 90 Tagen ab und ihr +Klartext wird nur beim Anlegen einmal angezeigt. + +Einen reinen Leseschlüssel für ein aktives Konto erstellen: + +```bash +docker compose exec web python manage.py create_api_key ausgabedokumente JohannesHundshammer --read-only +``` + +Einen Schreibschlüssel für ein Konto mit Rolle `EDITOR` oder `ADMIN` erstellen: + +```bash +docker compose exec web python manage.py create_api_key hardware-test-team2 JohannesHundshammer --write --expires-days 30 +``` + +Den ausgegebenen Schlüssel sofort in einem Secret-Store oder in einer nur für den +jeweiligen Benutzer lesbaren Umgebungsdatei speichern. Niemals in Quellcode, Git, +Tickets oder Logdateien schreiben. Im Django-Admin unter `/admin/` können +Administratoren Schlüssel sperren und die letzte Nutzung prüfen. + +Beispiel: Gerät 30 lesen: + +```bash +curl --fail --silent \ + -H "Authorization: Api-Key $CSR_API_KEY" \ + https://inventory.example.org/api/v1/devices/30/ +``` + +Für eine Änderung zuerst `revision` lesen und als `expected_revision` +zurücksenden. Bestehende Clients können weiterhin `updated_at` als +`expected_updated_at` senden. Veraltete Versionen werden mit HTTP 409 abgewiesen: + +```bash +curl --fail --silent --request PATCH \ + -H "Authorization: Api-Key $CSR_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"cpu":"Intel Core i5-8500","ram_gb":16,"hwid":"ABC-123","expected_revision":1}' \ + https://inventory.example.org/api/v1/devices/30/ +``` + +Mehrere Geräte atomar ändern (höchstens 500 pro Anfrage): + +```bash +curl --fail --silent --request POST \ + -H "Authorization: Api-Key $CSR_API_KEY" \ + -H "Content-Type: application/json" \ + --data '{"device_numbers":[30,31,32],"changes":{"status":2,"recipient":5}}' \ + https://inventory.example.org/api/v1/devices/bulk-update/ +``` + +Verfügbare Auswahl-IDs liefert `GET /api/v1/choices/`. Die Geräteliste unter +`GET /api/v1/devices/` unterstützt `number`, `number_prefix`, `status`, `type`, +`employee`, `changed_since`, `archived`, `page` und `page_size` (maximal 100). + +Die API nimmt ausschließlich bekannte Gerätefelder an. Benutzerpasswörter, +Passwort-Hashes und Kontoverwaltung sind absichtlich nicht Teil der API. Jede +Feldänderung enthält in der Änderungshistorie sowohl das zugeordnete Konto als +auch den Namen des verwendeten API-Clients. + +## Backup und Wiederherstellung + +Backup-Verzeichnis anlegen und Skripte ausführbar machen: + +```bash +chmod +x scripts/*.sh +./scripts/backup.sh +``` + +Das Ergebnis liegt beispielsweise unter `backups/backup_2026-09-19_0200.sql.gz`. Backups regelmäßig auf ein anderes System kopieren. + +Wiederherstellung zuerst in einer Testumgebung prüfen. Für eine leere Zieldatenbank: + +```bash +./scripts/restore.sh backups/backup_2026-09-19_0200.sql.gz +``` + +Beispiel für einen täglichen Cronjob um 02:00 Uhr: + +```cron +0 2 * * * cd /opt/csr-inventory && ./scripts/backup.sh >> /var/log/csr-backup.log 2>&1 +``` + +## Tests und Prüfungen + +```bash +python manage.py test +python manage.py check +python manage.py makemigrations --check --dry-run +``` + +Diese Befehle verwenden PostgreSQL. CI prüft PostgreSQL 17 einschließlich +gleichzeitiger Bearbeitungen. Ohne PostgreSQL können reine Unit-Tests explizit +mit `python manage.py test --settings=csr_inventory.test_settings` ausgeführt +werden; PostgreSQL-Sperrtests werden dabei übersprungen. Für Tests `DEBUG=true` +und `SECURE_SSL_REDIRECT=false` in der Prozessumgebung setzen. + +Docker-Prüfung auf einem Rechner mit Docker: + +```bash +docker compose config +docker compose build +docker compose up -d +curl -f https://IHRE-DOMAIN/health/ +``` + +## Importformat + +Der XLSX-Import erkennt typische deutsche Spaltennamen automatisch. Unterstützte Core-Spalten sind: + +`Nr.`, `Regal`, `Status`, `Datum`, `Empfänger`, `Ort`, `Ticket`, `Typ`, `Preis`, `Akku`, `WLAN`, `LAN`, `Zoll`, `RAM`, `SSD`, `HDD`, `CPU`, `HWID`, `Modell`, `Bearbeiter`, `Spender`, `Kommentar`. + +Vor dem Import erscheint eine Vorschau mit Spaltenzuordnung. Bereits vorhandene Gerätenummern führen zum vollständigen Abbruch; es bleibt kein Teilimport zurück. Mitarbeiter werden über ihr Kürzel zugeordnet. + +## Betrieb und Sicherheit + +- Nur Traefik veröffentlicht Ports; PostgreSQL ist nicht von außen erreichbar. +- Excel-Uploads werden nicht öffentlich über Traefik ausgeliefert. +- CSRF-Schutz, sichere Cookies, HSTS, serverseitige Rollenprüfung und Django-Passworthashing sind im Produktionsmodus aktiv. +- Die API akzeptiert keine Browser-Sitzungen, liefert nur JSON, begrenzt Seitengrößen und drosselt wiederholte Anfragen. +- Technische Fehler werden nur in Container-Logs geschrieben: `docker compose logs web`. +- Für mindestens monatliche Sicherheitsupdates die Basisimages neu bauen: `docker compose build --pull`. +- Für große Datenbestände nach umfangreichen Importen PostgreSQL analysieren: `docker compose exec db vacuumdb -U "$POSTGRES_USER" -d "$POSTGRES_DB" --analyze`. + +## Architektur + +- Django Templates und leichtes Vanilla-JavaScript; kein separater Frontend-Build +- Django ORM und Migrationen +- PostgreSQL 17 mit `pg_trgm` +- Gunicorn als WSGI-Server +- Traefik als Reverse Proxy mit automatischem HTTPS +- Keine Microservices, kein Redis, kein Elasticsearch + +## Traefik und LAN-Zertifikate + +Traefik verwendet einen File-Provider ohne Docker-Socket. Statische Dateien liefert WhiteNoise aus. Öffentliche Domains nutzen Let's Encrypt; für private IP-Adressen siehe [Traefik-Betrieb](docs/traefik.md). + +## Geräteablauf und Aufgaben + +Jedes Gerät hat einen eigenen Arbeitsschritt: Eingang → Inspektion → +bei Bedarf Reparatur → Lagerung → Ausgabe. Bearbeiter können auf der +Geräteseite Schritte mit Notiz dokumentieren und Aufgaben mit Fälligkeit +und zuständiger Person anlegen. Offene Aufgaben stehen unter „Aufgaben“. +Vor Lagerung oder Ausgabe müssen sie erledigt sein; für die Ausgabe ist +ein Empfänger erforderlich. Eine Rückgabe beginnt wieder beim Eingang. +Änderungen erscheinen in der Ablaufhistorie und im Auditprotokoll. Bei Reparatur, Lagerung und Ausgabe wird der bisherige Bestandsstatus automatisch auf Defekt, Fertig beziehungsweise Ausgegeben gesetzt. + +Die Gerätetypen Laptop, AiO, Desktop, Bildschirm und Drucker zeigen +nur passende Hardwarefelder. Bei einem Typwechsel werden nicht mehr +passende Werte unter `custom_values.legacy_hardware` erhalten und aus +den aktiven Feldern entfernt. `inventory_device` enthält nur gemeinsame +Gerätedaten. Laptop, Desktop, AiO, Monitor und Drucker besitzen eigene +Spezifikationstabellen; die bisherige Computertabelle bleibt für frei +definierte Gerätetypen. Monitore haben unter anderem Diagonale, Auflösung +und Anschlüsse, Laptops RAM, SSD/HDD, CPU, Akku und Anschlüsse. Drucker +haben WLAN/LAN, Anschlüsse, Druckverfahren, Duplex und Farbdruck. +Migration 0012 verschiebt bestehende Computerwerte in die passenden Tabellen. +Export, Filter und API behalten die bisherigen Feldnamen und ergänzen die +neuen Typfelder. Details stehen in [Datenbankschema](docs/datenbankschema.md). + +Über „Ablauf“ in der Hauptnavigation sind alle Arbeitsschritte direkt +erreichbar. Auf dem Board lassen sich Aufgaben anlegen und Geräte zum +nächsten Schritt bewegen. Ein Klick auf eine Tabellenzeile öffnet die +Geräteseite; Ablauf und Aufgaben stehen dort vor den Stammdaten. +In der Inspektion werden Mängel und passende technische Daten erfasst, +bei der Reparatur die durchgeführten Arbeiten und in der Lagerung der +Empfänger. Befunde und Reparaturen bleiben mit Zeit und Bearbeiter als +eigene Datensätze erhalten (Migration 0011). + +Vor dem Update einer bestehenden Installation eine Datenbanksicherung +erstellen, dann `python manage.py migrate` ausführen. Ein Löschen der +Datenbank ist nicht nötig und würde Geräte und Benutzerkonten entfernen. diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..fdd0a89 --- /dev/null +++ b/changes.md @@ -0,0 +1,175 @@ +# Änderungsprotokoll + +## 23. September 2026 + +### PostgreSQL und Docker + +- PostgreSQL ist jetzt die Standarddatenbank für Entwicklung und Produktion. Die Anwendung wechselt bei fehlender Konfiguration nicht mehr automatisch zu SQLite. +- Ein separater Docker-Dienst `migration` stellt die Werkzeuge für Export, Schema-Migration und Datenimport bereit. +- `docker-compose.dev.yml` ermöglicht den lokalen Betrieb unter `http://localhost:8000` ohne HTTPS-Domain. Der Webport ist nur an localhost gebunden. +- Erforderliche PostgreSQL-Variablen werden in Docker Compose auf fehlende Werte geprüft. +- SQLite bleibt ausdrücklich für isolierte Unit-Tests und temporäre Migrationskopien verfügbar. + +### Übernahme bestehender SQLite-Daten + +- `scripts/export_sqlite.py` erstellt einen konsistenten temporären Snapshot und exportiert dessen Daten. Die Quelldatenbank wird schreibgeschützt geöffnet und bleibt unverändert. +- Vorhandene Exportdateien werden nicht überschrieben. +- Das Kommando `import_sqlite_export` übernimmt den Export transaktional in eine vorbereitete, leere PostgreSQL-Datenbank. Vorhandene Inventardaten, Benutzer oder Gruppen führen zum Abbruch. +- Die beiden von einer früheren Migration angelegten Standard-Gerätetypen werden als Ausgangsdaten erkannt und innerhalb der Importtransaktion ersetzt. +- Benutzer, Passwort-Hashes, Rollen, API-Schlüssel-Hashes, Geräte, Archivstatus, Zusatzfelder, Einstellungen und Historie werden übernommen. Sitzungen werden nicht übertragen; eine erneute Anmeldung ist erforderlich. +- ID-Sequenzen und die Gerätenummernfolge werden beim Import berücksichtigt. Upload-Dateien müssen separat in das Medienvolume kopiert werden. +- Die vollständigen Befehle stehen im [Leitfaden zur PostgreSQL-Migration](docs/postgresql-migration.md). + +### Sicherheit und Datenintegrität + +- **Gleichzeitige Bearbeitung:** Geräte besitzen eine ganzzahlige `revision`. Browser-Formulare senden `expected_revision`; veraltete Versionen werden abgewiesen. Die Detailansicht sperrt den Datensatz während der Prüfung und Speicherung innerhalb einer Transaktion. +- **API-Kompatibilität:** PATCH-Anfragen unterstützen `expected_revision`. Bestehende Clients können weiterhin `expected_updated_at` verwenden. Zeitstempel steigen auch bei geringer Auflösung der Systemuhr strikt an. +- **Datenbankschema:** Migration `0006_device_revision` ergänzt das Versionsfeld und passt die Aktualisierung des Zeitstempels an. Bestehende Geräte erhalten zunächst Revision 1. +- **Archivierung und Wiederherstellung:** Datensatzänderung und Audit-Eintrag erfolgen gemeinsam in einer Transaktion mit Datensatzsperre. +- **Bestätigungsdialoge:** Mitarbeiternamen und andere Dialogtexte werden als Text eingefügt und nicht mehr als HTML interpretiert. +- **Tabellenexporte:** XLSX-Textwerte werden ausdrücklich als Textzellen geschrieben. Potenziell als Formeln interpretierte CSV-Textwerte erhalten ein vorangestelltes Apostroph. Numerische Werte bleiben Zahlen. +- **Eingabeprüfung:** Ungültige numerische Filter, fehlerhafte JSON-Strukturen und ungültige Spalteneinstellungen werden abgefangen. Nicht endliche Dezimalwerte wie `NaN` und `Infinity` werden bei der entsprechenden Werteingabe abgewiesen. Ganzzahlfelder akzeptieren keine stillschweigend abgeschnittenen Nachkommastellen mehr. + +### Bedienung und Konfiguration + +- Inline-Auswahllisten für Mitarbeiter und Auswahlwerte sind nicht mehr auf die ersten 15 Einträge beschränkt. +- Die aktuelle Auswahl bleibt im Inline-Editor erhalten, auch wenn sie deaktiviert wurde oder nicht in der Antwort enthalten ist. +- Das Verlassen eines unveränderten Inline-Feldes löst keinen Speichervorgang mehr aus. +- Der Seed-Befehl legt nur fehlende Einträge an. Angepasste Mitarbeiternamen, Statusfarben, Sortierungen und deaktivierte Einträge werden beim Containerstart nicht mehr überschrieben. +- Die geänderten JavaScript-Abschnitte für Bestätigungsdialoge und Inline-Bearbeitung wurden lesbarer strukturiert. + +### Backup und Wiederherstellung + +- Datenbankexport und Komprimierung werden getrennt auf Erfolg geprüft. Ein fehlgeschlagener `pg_dump` erzeugt keine als erfolgreich gemeldete Sicherung mehr. +- Sicherungen werden zunächst in einem temporären Verzeichnis erstellt, auf gültige Komprimierung geprüft und erst danach als fertige Datei abgelegt. +- Die Backup- und Restore-Skripte führen `.env` nicht mehr als Shellcode aus. Verbindungswerte werden innerhalb des Datenbankcontainers verwendet. +- Vor einer Wiederherstellung wird das Archiv vollständig entpackt und geprüft. SQL-Fehler brechen die Wiederherstellung innerhalb einer gemeinsamen Transaktion ab. +- Temporäre Dateien werden aufgeräumt; unter POSIX werden restriktive Dateirechte verwendet. +- `.gitattributes` legt LF-Zeilenenden für Shellskripte fest. + +### Prüfungen und aktueller Stand + +- **Python:** 53 Tests ausgeführt; 52 erfolgreich, ein PostgreSQL-Sperrtest mangels PostgreSQL-Laufzeit übersprungen. Der zuvor fehlgeschlagene Test zur Erkennung veralteter Änderungen besteht jetzt. +- **Frontend:** DOM-basierte Regressionstests für sichere Dialogtexte, erhaltene Auswahlwerte, unveränderte Felder und Speicherung mit Versionsprüfung erfolgreich. +- **Backup:** Vier Prüfungen für erfolgreichen Export/Import, fehlgeschlagenen Datenbankexport, beschädigte Archive und Fehler beim Restore erfolgreich. +- **Django:** Systemprüfungen erfolgreich; keine fehlenden Modellmigrationen erkannt. +- **Konfiguration:** YAML-Syntax der Docker-Compose-Dateien und des CI-Workflows geprüft; JavaScript-Syntax geprüft. +- **Datenexport:** Die vorhandenen SQLite-Daten wurden probeweise exportiert und in ein frisches temporäres SQLite-Schema geladen: 109 Datensätze aus neun Modellen. Die Prüfsumme der ursprünglichen SQLite-Datei blieb unverändert. +- **CI vorbereitet:** Der neue Workflow führt die Tests mit PostgreSQL 17 sowie die Backup- und Frontend-Prüfungen aus. + +**Stand der ursprünglichen Vorbereitung:** Auf dem Windows-Rechner war Docker nicht installiert. Die folgenden Schritte wurden anschließend auf dem Server durchgeführt; die lokale SQLite-Datenbank blieb dabei unverändert. + +### Serverinstallation auf 192.168.188.45 + +- Das Projekt wurde unter `/home/denis/csr-inventory` mit PostgreSQL, Gunicorn und Caddy installiert. +- Alle **53 Tests bestanden auf PostgreSQL**, einschließlich des zuvor lokal übersprungenen Sperrtests. Die Django-Produktionsprüfung meldete keine Beanstandungen. +- Die 109 Datensätze der lokalen Projektdatenbank wurden übernommen, darunter zwei Geräte und die vorhandenen Benutzerkonten. +- Die bisher laufende andere Inventarsoftware wurde nach Sicherung von Datenbank, Konfiguration und Uploads entfernt. Ihre Volumes und Quelldateien bleiben für eine mögliche Wiederherstellung erhalten. +- Die neue Anwendung ist unter **https://192.168.188.45/** erreichbar. Port 8001 leitet auf HTTPS weiter. +- Eine LAN-Konfiguration für Caddy ergänzt lokale Zertifikate und den Zugriff per IP-Adresse. Das öffentliche Stammzertifikat liegt unter `docs/homelab-root-ca.crt`; es wurde nicht automatisch in den Windows-Zertifikatsspeicher importiert. +- Tägliche Datenbank-Backups um 02:00 Uhr Berliner Zeit wurden über einen systemd-Timer eingerichtet. Eine Sicherung wurde erfolgreich in einer separaten Prüfdatenbank wiederhergestellt. +- HTTPS, Healthcheck, Loginseite, statische Dateien, API-Zugriffsschutz und die Weiterleitung wurden geprüft. Andere Anwendungen auf dem Server laufen unverändert weiter. +- Zugriff, Zertifikatsinstallation und Wartung sind in [docs/serverbetrieb.md](docs/serverbetrieb.md) beschrieben. + +## 26. September 2026 – Wechsel zu Traefik + +- Caddy durch Traefik v3.7.13 im Projekt und auf dem Server ersetzt. +- Router und Sicherheitsheader werden über Dateien konfiguriert; kein Docker-Socket und kein öffentliches Dashboard. +- HTTPS-Adresse und Weiterleitungen von Port 80 und 8001 bleiben erhalten. Statische Dateien liefert WhiteNoise aus. +- Die bisherige Stamm-CA wurde übernommen; bereits eingerichtetes Clientvertrauen bleibt gültig. +- Ein systemd-Timer prüft täglich die LAN-Zertifikate. Erneuerungen werden ohne Proxy-Neustart geladen; der CA-Schlüssel bleibt auf dem Host. +- Konfiguration, CA-Zustand und PostgreSQL wurden vor der Umstellung gesichert. Anwendung und Datenbank wurden nicht neu erstellt. +- Vier Zertifikatstests bestanden. HTTPS, Loginseite, CSS, Logo, API-Zugriffsschutz, Weiterleitungen und das Nachladen eines erneuerten Zertifikats wurden geprüft. +- Compose-Dateien, CI und Betriebsdokumentation aktualisiert; Details in [docs/traefik.md](docs/traefik.md). + +## 26. September 2026 – Geräteablauf und Aufgaben + +- Ein Gerät durchläuft Eingang, Inspektion, gegebenenfalls Reparatur, + Lagerung und Ausgabe. Übergänge werden mit Benutzer, Zeit und Notiz + protokolliert. Der Bestandsstatus wird bei Reparatur, Lagerung und Ausgabe + automatisch abgeglichen. +- Aufgaben haben Status, Fälligkeitsdatum und optionale Zuständigkeit. + Offene Aufgaben sperren Lagerung und Ausgabe. Die Aufgabenübersicht + zeigt offene und erledigte Arbeiten. +- Eingabe, Detailansicht und Tabellenzeilen zeigen Hardwarefelder passend + zum Gerätetyp; ein Drucker hat keine RAM-Eingabe mehr. +- Schema-Migrationen 0007/0008 ergänzen Ablauf und Aufgaben und ordnen + bestehende Geräte anhand ihres Status ein. Nicht passende ältere + Hardwarewerte bleiben unter `custom_values.legacy_hardware` erhalten. +- Bestehende Benutzer, Geräte und Nummern bleiben erhalten. Die + Hardware-Spalten der Basistabelle bleiben für bestehende Exporte und + API-Clients vorhanden; bei unpassenden Typen sind sie leer. + +- Auf Server 192.168.188.45 nach PostgreSQL-Sicherung migriert und ausgerollt. + Alle 58 Tests bestanden mit PostgreSQL; 2 Geräte und 4 Konten blieben erhalten. + +## 26. September 2026 – Datenbank und Bedienung überarbeitet + +- Gemeinsame Gerätedaten und typabhängige Hardwaredaten getrennt. Computer, + Bildschirme und Drucker erhalten jeweils eine eigene Spezifikationstabelle. + Migration 0009 übernimmt die bisherigen Werte vor dem Entfernen der alten + Hardwarespalten. Export, Suche und API verwenden weiter dieselben Feldnamen. +- Zusätzliche Datenbankregeln sichern gültige Ablauf- und Aufgabenstände ab; + Indizes beschleunigen häufige Aufgaben- und Verlaufsabfragen (Migration 0010). +- „Ablauf“ ist jetzt direkt über die Hauptnavigation erreichbar. Das Board + zeigt Geräte nach Arbeitsschritt und erlaubt neue Aufgaben sowie Übergänge + direkt auf der Karte. Tabellenzeilen öffnen die vollständige Geräteseite; + dort stehen Aufgaben und Ablauf vor dem Bearbeitungsformular. + +## 26. September 2026 – Eingaben je Arbeitsschritt + +- In der Inspektion können Mängel und die zum Gerätetyp passenden technischen + Daten direkt auf dem Ablauf-Board erfasst werden. Die Hardwarewerte werden + in den bestehenden typbezogenen Spezifikationstabellen gespeichert. +- Für Reparaturen werden die durchgeführten Arbeiten mit Zeit und Bearbeiter + als eigene Einträge dokumentiert. Inspektionsbefunde sind ebenso historisiert. +- In der Lagerung kann der Empfänger direkt eingetragen werden. Der vorhandene + Empfänger wird wiederverwendet oder neu angelegt; vor der Ausgabe bleibt + ein Empfänger verpflichtend. +- Migration 0011 ergänzt die Tabellen `inventory_inspectionrecord` und + `inventory_repairrecord`. Bestehende Geräte und Ablaufdaten bleiben erhalten. + +- Die Ablauf-Übersicht zählt offene Aufgaben aus den bereits geladenen + Aufgabendaten. Dadurch entfällt eine teure Aggregation pro Arbeitsschritt. + +## 28. September 2026 – Getrennte Gerätetyp-Tabellen + +- Eigene 1:1-Spezifikationstabellen für Laptop, Desktop und AiO ergänzt. + Bildschirm/Monitor und Drucker behalten ihre getrennten Tabellen; die + allgemeine Computertabelle dient nur noch frei definierten Typen. +- Monitore haben nun Auflösung und Anschlüsse. Drucker haben Anschlüsse, + Druckverfahren, Duplex und Farbdruck. Die Computer-Typen haben passende + Hardwarefelder einschließlich Anschlüssen. +- Formulare, Inspektions-Board, API, Suche, Filter sowie Excel-Import und + -Export verwenden die neuen Felder und Tabellen. +- Migration 0012 überträgt bestehende Laptop-, Desktop- und AiO-Werte. + Geräte, Aufgaben, Benutzer und Ablaufhistorien bleiben erhalten. + +- Für Datenbankwerkzeuge ist PostgreSQL über `127.0.0.1:15432` auf dem + Server und einen SSH-Tunnel erreichbar. Port 15432 ist nicht im LAN offen. + +## 28. September 2026 – Dynamische Geräteformulare + +- Bei „Neues Gerät“ erscheinen die passenden technischen Felder unmittelbar + nach Auswahl des Gerätetyps. Modell und technische Daten werden auch bei der + Mehrfachanlage in den jeweiligen Spezifikationstabellen gespeichert. +- Auf der Geräteseite wechseln die sichtbaren Hardwarefelder ebenfalls sofort + beim Typwechsel, ohne Aktualisieren der Seite. Nicht passende Eingaben werden + beim Speichern serverseitig ignoriert; bestehende Werte bleiben bei einem + Typwechsel in den bisherigen Verlaufsdaten erhalten. +- Übersicht, Geräteliste, Ablauf und Aufgaben aktualisieren sich nach Änderungen + in einem anderen offenen Tab automatisch. Beim Zurückkehren in einen älteren + Tab werden die Daten ebenfalls neu geladen, solange keine Eingabe bearbeitet wird. +- Das vorhandene CS-R-Logo ist jetzt das Favicon auf allen Seiten, einschließlich + Anmeldung und Fehlerseite. +- Ein Hell-/Dunkelmodus-Schalter steht in der Seitenleiste, auf Mobilgeräten in + der Kopfzeile und auf der Anmeldeseite bereit. Die Auswahl bleibt im Browser + gespeichert; ohne Auswahl gilt die Systemeinstellung. + +## 29. September 2026 – Repository aufgeräumt + +- Das alte Projektarchiv wurde in den lokalen, nicht versionierten Ordner + `backups/` verschoben. Es bleibt als Sicherung erhalten. +- Git- und Docker-Ausschlüsse für lokale Datenbanken, Uploads, Archive, + Caches und erzeugte Dateien wurden ergänzt. Quelltexte verwenden in Git + einheitliche LF-Zeilenenden. diff --git a/csr_inventory/__init__.py b/csr_inventory/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/csr_inventory/__init__.py @@ -0,0 +1 @@ + diff --git a/csr_inventory/settings.py b/csr_inventory/settings.py new file mode 100644 index 0000000..41e0402 --- /dev/null +++ b/csr_inventory/settings.py @@ -0,0 +1,144 @@ +from pathlib import Path +import os +from django.core.exceptions import ImproperlyConfigured + + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def env_bool(name: str, default: bool = False) -> bool: + return os.getenv(name, str(default)).lower() in {"1", "true", "yes", "on"} + + +SECRET_KEY = os.getenv("SECRET_KEY", "development-only-change-me") +DEBUG = env_bool("DEBUG", True) +if not DEBUG and SECRET_KEY == "development-only-change-me": + raise ImproperlyConfigured("SECRET_KEY muss im Produktionsbetrieb gesetzt sein.") +ALLOWED_HOSTS = [value.strip() for value in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if value.strip()] +CSRF_TRUSTED_ORIGINS = [value.strip() for value in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") if value.strip()] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_api_key", + "inventory", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "csr_inventory.urls" +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "inventory.context_processors.navigation_context", + ], + }, + } +] +WSGI_APPLICATION = "csr_inventory.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB", "csr_inventory"), + "USER": os.getenv("POSTGRES_USER", "csr_inventory"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", ""), + "HOST": os.getenv("POSTGRES_HOST", "localhost"), + "PORT": os.getenv("POSTGRES_PORT", "5432"), + "CONN_MAX_AGE": 60, + "OPTIONS": {"connect_timeout": 5}, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "de-de" +TIME_ZONE = os.getenv("TIME_ZONE", "Europe/Berlin") +USE_I18N = True +USE_TZ = True +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "inventory:device-list" +LOGOUT_REDIRECT_URL = "login" +DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", str(10 * 1024 * 1024))) +FILE_UPLOAD_MAX_MEMORY_SIZE = DATA_UPLOAD_MAX_MEMORY_SIZE + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = env_bool("SECURE_COOKIES", not DEBUG) +CSRF_COOKIE_SECURE = env_bool("SECURE_COOKIES", not DEBUG) +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_HTTPONLY = False +CSRF_COOKIE_SAMESITE = "Lax" +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "same-origin" +X_FRAME_OPTIONS = "DENY" +SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0 +SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG +SECURE_HSTS_PRELOAD = not DEBUG +SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", not DEBUG) + +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" if DEBUG + else "whitenoise.storage.CompressedManifestStaticFilesStorage" + }, +} + +# Die API verwendet absichtlich keine Browser-Sitzung. Ohne einen gültigen, +# widerrufbaren API-Schlüssel wird jede API-Anfrage abgewiesen. +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ["inventory.api_auth.InventoryAPIKeyAuthentication"], + "DEFAULT_PERMISSION_CLASSES": ["inventory.api_auth.InventoryAPIPermission"], + "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], + "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], + "DEFAULT_PAGINATION_CLASS": "inventory.api_pagination.PagedDeviceResults", + "PAGE_SIZE": 50, + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "inventory.api_auth.APIClientRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": os.getenv("API_ANON_RATE", "20/min"), + "api_client": os.getenv("API_CLIENT_RATE", "300/min"), + }, +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"standard": {"format": "{levelname} {asctime} {name}: {message}", "style": "{"}}, + "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "standard"}}, + "root": {"handlers": ["console"], "level": os.getenv("LOG_LEVEL", "WARNING")}, +} diff --git a/csr_inventory/sqlite_migration_settings.py b/csr_inventory/sqlite_migration_settings.py new file mode 100644 index 0000000..e5180d3 --- /dev/null +++ b/csr_inventory/sqlite_migration_settings.py @@ -0,0 +1,10 @@ +"""Used only by scripts/export_sqlite.py on its temporary database snapshot.""" +import os +from .settings import * # noqa: F403 + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.environ["SQLITE_MIGRATION_SNAPSHOT"], + } +} diff --git a/csr_inventory/test_settings.py b/csr_inventory/test_settings.py new file mode 100644 index 0000000..7484a38 --- /dev/null +++ b/csr_inventory/test_settings.py @@ -0,0 +1,12 @@ +"""Explicit SQLite fallback for unit tests when PostgreSQL is unavailable. + +Production and the default test command always use PostgreSQL. +""" +from .settings import * # noqa: F403 + +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} +SECURE_SSL_REDIRECT = False +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, +} diff --git a/csr_inventory/urls.py b/csr_inventory/urls.py new file mode 100644 index 0000000..9e32840 --- /dev/null +++ b/csr_inventory/urls.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/v1/", include("inventory.api_urls")), + path("accounts/login/", auth_views.LoginView.as_view(), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", include("inventory.urls")), +] + +handler403 = "inventory.error_views.permission_denied" +handler404 = "inventory.error_views.not_found" +handler500 = "inventory.error_views.server_error" diff --git a/csr_inventory/wsgi.py b/csr_inventory/wsgi.py new file mode 100644 index 0000000..a7791a7 --- /dev/null +++ b/csr_inventory/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "csr_inventory.settings") +application = get_wsgi_application() diff --git a/deploy/csr-inventory-tls.service b/deploy/csr-inventory-tls.service new file mode 100644 index 0000000..d4e5cf1 --- /dev/null +++ b/deploy/csr-inventory-tls.service @@ -0,0 +1,9 @@ +[Unit] +Description=Renew CS-R inventory LAN TLS certificate + +[Service] +Type=oneshot +WorkingDirectory=/home/denis/csr-inventory +EnvironmentFile=/home/denis/csr-inventory/.env +ExecStart=/usr/bin/python3 /home/denis/csr-inventory/scripts/renew_local_tls.py --hostname ${APP_HOST} +UMask=0077 diff --git a/deploy/csr-inventory-tls.timer b/deploy/csr-inventory-tls.timer new file mode 100644 index 0000000..f293c4f --- /dev/null +++ b/deploy/csr-inventory-tls.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Daily CS-R inventory TLS certificate check + +[Timer] +OnCalendar=*-*-* 01:30:00 Europe/Berlin +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3a14104 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,10 @@ +services: + web: + environment: + DEBUG: "true" + SECURE_COOKIES: "false" + SECURE_SSL_REDIRECT: "false" + ALLOWED_HOSTS: "localhost,127.0.0.1" + CSRF_TRUSTED_ORIGINS: "http://localhost:8000,http://127.0.0.1:8000" + ports: + - "127.0.0.1:8000:8000" diff --git a/docker-compose.lan.yml b/docker-compose.lan.yml new file mode 100644 index 0000000..e03f621 --- /dev/null +++ b/docker-compose.lan.yml @@ -0,0 +1,6 @@ +services: + traefik: + environment: + TRAEFIK_CERT_RESOLVER: "" + ports: + - "8001:80" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68ed706 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +services: + db: + image: postgres:17-alpine + restart: unless-stopped + ports: + - "127.0.0.1:15432:5432" + environment: + POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env} + POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 12 + + web: + build: . + restart: unless-stopped + env_file: .env + depends_on: + db: + condition: service_healthy + volumes: + - media_data:/app/media + - static_data:/app/staticfiles + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; r=urllib.request.Request('http://127.0.0.1:8000/health/', headers={'X-Forwarded-Proto':'https'}); urllib.request.urlopen(r, timeout=3)"] + interval: 15s + timeout: 5s + retries: 5 + + traefik: + image: traefik:v3.7.13 + restart: unless-stopped + depends_on: + web: + condition: service_healthy + environment: + APP_HOST: ${APP_HOST:?Set APP_HOST in .env} + TRAEFIK_CERT_RESOLVER: ${TRAEFIK_CERT_RESOLVER:-letsencrypt} + command: + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.websecure.address=:443 + - --entrypoints.websecure.http3=true + - --entrypoints.traefik.address=:8080 + - --ping=true + - --api.dashboard=false + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-} + - --certificatesresolvers.letsencrypt.acme.storage=/acme/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./traefik/dynamic:/etc/traefik/dynamic:ro + - ./tls/certs:/certs:ro + - traefik_acme:/acme + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: 15s + timeout: 5s + retries: 5 + + migration: + profiles: ["tools"] + build: . + env_file: .env + # Offline tools need access to the host backup directory. The web service + # continues to run as the unprivileged app user. + user: "0:0" + entrypoint: ["python"] + command: ["manage.py", "check"] + depends_on: + db: + condition: service_healthy + volumes: + - .:/legacy:ro + - ./backups:/transfer + - media_data:/app/media + +volumes: + postgres_data: + media_data: + static_data: + traefik_acme: diff --git a/docs/datenbankschema.md b/docs/datenbankschema.md new file mode 100644 index 0000000..bc676c0 --- /dev/null +++ b/docs/datenbankschema.md @@ -0,0 +1,29 @@ +# Geräteschema + +`inventory_device` enthält die gemeinsamen Felder: Inventarnummer, Gerätetyp, +Modell, Status, Arbeitsschritt, Datum, Regal, Bearbeiter, Empfänger, Ort, +Ticket, Preis und Notizen. Jeder Gerätedatensatz besitzt genau eine passende +Spezifikation in einer 1:1-Tabelle. Die Anwendung legt diese beim Erstellen +an und entfernt die alte Spezifikation bei einem Typwechsel. + +| Gerätetyp | Tabelle | Typische Felder | +| --- | --- | --- | +| Laptop | `inventory_laptopspecification` | CPU, HWID, RAM, SSD, HDD, Bildschirmgröße, Akku, WLAN, LAN, Anschlüsse | +| Desktop | `inventory_desktopspecification` | CPU, HWID, RAM, SSD, HDD, WLAN, LAN, Anschlüsse | +| AiO | `inventory_allinonespecification` | CPU, HWID, RAM, SSD, HDD, Bildschirmgröße, WLAN, LAN, Anschlüsse | +| Bildschirm/Monitor | `inventory_monitorspecification` | Bildschirmgröße, Auflösung, Anschlüsse | +| Drucker/Printer | `inventory_printerspecification` | WLAN, LAN, Anschlüsse, Druckverfahren, Duplex, Farbdruck | +| Frei definierter Computertyp | `inventory_computerspecification` | Allgemeine Computerhardware als Fallback | + +Die Werte für `Anschlüsse` sind Freitext, etwa `2× HDMI, DisplayPort, +USB-C`; damit sind auch unbekannte Anschlussarten erfassbar. Gerätetypen +werden derzeit über ihre Bezeichnung zugeordnet. Weitere frei definierte +Typen verwenden die allgemeine Computertabelle, bis ein eigenes Schema +ergänzt wird. + +Inspektionsbefunde und Reparaturarbeiten stehen unabhängig davon in +`inventory_inspectionrecord` und `inventory_repairrecord`. Aufgaben liegen +in `inventory_devicetask`, dokumentierte Arbeitsschritte in +`inventory_workflowevent`. Migration 0012 verschiebt vorhandene Laptop-, +Desktop- und AiO-Werte aus der allgemeinen Computertabelle. Bestehende +Inventarnummern, Aufgaben und Benutzer bleiben erhalten. diff --git a/docs/homelab-root-ca.crt b/docs/homelab-root-ca.crt new file mode 100644 index 0000000..fc4491a --- /dev/null +++ b/docs/homelab-root-ca.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBojCCAUmgAwIBAgIQUS4ubv+VPmHfsOZnHA55kDAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X +DTI2MDkyMzE3MjEyMVoXDTM2MDgwMTE3MjEyMVowMDEuMCwGA1UEAxMlQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABNfvDgBuLlx110lhzZllEhvq9OJ9y/BxFUJnR8RK5idzm1R9H/U/ +PlszTxrP5QkxaJgBgjecOJS+19ziBEYdBXyjRTBDMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSUYvaXe7IkfVqEf49jgl5npdiw +BTAKBggqhkjOPQQDAgNHADBEAiBjTG79jTLkoYy5yhXPPbegqDlpt0+sWrp71ZZc +f/hkXgIgLG0JFVISRSCzm+bM5DM/iAwX4nyNrGKoVGuRaNtlD6w= +-----END CERTIFICATE----- diff --git a/docs/postgresql-migration.md b/docs/postgresql-migration.md new file mode 100644 index 0000000..302d692 --- /dev/null +++ b/docs/postgresql-migration.md @@ -0,0 +1,113 @@ +# SQLite → PostgreSQL mit Docker + +Die Anwendung verwendet standardmäßig PostgreSQL. Es gibt keinen automatischen +Rückfall auf SQLite. Die folgenden Befehle sind vorbereitet; sie wurden auf dem +aktuellen Windows-Rechner ohne Docker nicht ausgeführt. + +## Vorbereiten + +Docker mit Compose muss verfügbar sein. Die Befehle im Projektverzeichnis ausführen. +Die bisherige Anwendung während Export, Import und Prüfung stoppen, damit keine +Änderungen nach dem Export verloren gehen. `db.sqlite3` und das Verzeichnis `media` +behalten und zusätzlich extern sichern. Niemals `docker compose down -v` ausführen, +wenn Volumes noch benötigte Daten enthalten. + +`.env.example` nach `.env` kopieren und mindestens `SECRET_KEY`, +`POSTGRES_PASSWORD`, Domain, erlaubte Hosts und CSRF-Origin ersetzen. +`POSTGRES_HOST=db` beibehalten. Keine Zugangsdaten in Git ablegen. + +```sh +docker compose config --quiet +docker compose --profile tools build web migration +docker compose stop web traefik +docker compose up -d --wait db +``` + +Die Zieldatenbank muss leer sein. Vor dem Import den Webdienst noch **nicht** +starten: Sein Startskript legt sonst bereits Seed-Daten an. Das Importkommando +verweigert einen Import, sobald Benutzer, Gruppen oder Inventardaten vorhanden sind. +Die zwei von Migration 0003 angelegten Standard-Gerätetypen werden als leere +Ausgangsbasis erkannt und innerhalb der Importtransaktion durch die Exportdaten +ersetzt. Ein vorhandenes PostgreSQL-System niemals zum Zweck des Imports leeren; stattdessen +eine neue Datenbank bzw. ein separates Compose-Projekt verwenden. + +## Exportieren, Schema anlegen, importieren + +```sh +docker compose run --rm migration scripts/export_sqlite.py /legacy/db.sqlite3 /transfer/sqlite-export.json +docker compose run --rm migration manage.py migrate --noinput +docker compose run --rm migration manage.py import_sqlite_export /transfer/sqlite-export.json +``` + +Der Export liest SQLite über eine schreibgeschützte Verbindung, erstellt einen +konsistenten temporären Snapshot und aktualisiert nur dessen Schema. Die originale +SQLite-Datei bleibt unverändert. Existiert die Exportdatei bereits, wird sie nicht +überschrieben; für einen weiteren Export einen neuen Dateinamen verwenden. + +Der Export enthält Benutzer und Passwort-Hashes, Rollen, API-Schlüssel-Hashes, +Geräte, Archivstatus, Zusatzfelder, Einstellungen, Nummernfolge und Historie. +Content Types und Standardberechtigungen werden durch Migrationen angelegt und +über natürliche Schlüssel zugeordnet. Sitzungen werden nicht übernommen; Benutzer +müssen sich erneut anmelden. Der Import läuft transaktional und setzt die +PostgreSQL-ID-Sequenzen durch Djangos `loaddata` zurück. Die Gerätenummernfolge +wird mindestens auf die höchste vorhandene Gerätenummer gesetzt. + +`backups/sqlite-export.json` enthält vertrauliche Daten und ist kein öffentliches +Download-Artefakt. Der Export setzt auf Linux Dateirechte `0600`. Der offline +gestartete Migrationscontainer läuft für den Zugriff auf das Backup-Verzeichnis +als root; der Webcontainer läuft weiterhin als unprivilegierter `app`-Benutzer. + +## Uploads übernehmen + +Der Datenbankexport enthält Dateiverweise, nicht die Dateien selbst. Bei einem +**neuen, leeren** `media_data`-Volume die bisherigen Uploads kopieren: + +```sh +docker compose run --rm --entrypoint sh migration -c 'cp -a /legacy/media/. /app/media/ && chown -R app:app /app/media' +``` + +Ein bereits verwendetes Medienvolume zuerst sichern und seine Inhalte separat +abgleichen; der Kopierbefehl darf nicht ungeprüft über vorhandene Dateien laufen. + +## Prüfen und starten + +```sh +docker compose run --rm migration manage.py check +docker compose run --rm migration manage.py makemigrations --check --dry-run +docker compose run --rm -e DEBUG=true -e SECURE_SSL_REDIRECT=false migration manage.py test --noinput +docker compose up -d --build web traefik +docker compose ps +``` + +Die Tests verwenden eine separate PostgreSQL-Testdatenbank. Danach Anmeldung, +Geräteanzahl einschließlich Archiv, höchste Gerätenummer, Rollen, Historie und +Upload-Verweise mit dem bisherigen System vergleichen. Ein PostgreSQL-Backup +anlegen und testweise in eine separate leere Datenbank wiederherstellen: + +```sh +sh scripts/backup.sh +``` + +Erst nach erfolgreicher Prüfung das alte System endgültig stilllegen. Für einen +Rückwechsel den neuen Webdienst stoppen und die alte Anwendung mit ihrer +unveränderten SQLite-Datei starten. Neue PostgreSQL-Änderungen werden nicht +automatisch zurück nach SQLite übertragen. + +## Lokaler Docker-Betrieb ohne HTTPS-Domain + +Nach dem Import alternativ nur Datenbank und Webdienst starten: + +```sh +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build db web +``` + +Die Anwendung ist dann unter `http://localhost:8000` erreichbar. Der Port ist nur +an localhost gebunden. Diese Entwicklungsüberschreibung nicht auf dem VPS nutzen. + +## Bearbeitungsversionen + +Geräte besitzen nun eine ganzzahlige `revision`. Browser-Formulare senden +`expected_revision`; API-PATCH-Anfragen können ebenfalls dieses Feld verwenden. +Bestehende API-Clients mit `expected_updated_at` bleiben kompatibel. Veraltete +Versionen werden abgewiesen. Zeitstempel steigen auch bei geringer Auflösung der +Systemuhr strikt an. Migration `0006` setzt bestehende Geräte auf Revision 1. diff --git a/docs/serverbetrieb.md b/docs/serverbetrieb.md new file mode 100644 index 0000000..c0dec25 --- /dev/null +++ b/docs/serverbetrieb.md @@ -0,0 +1,160 @@ +# Serverbetrieb auf 192.168.188.45 + +Stand: 26. September 2026. Die Anwendung wurde auf dem Debian-Server `homelab` +installiert und gegen PostgreSQL geprüft. + +## Zugriff + +- Anwendung: **https://192.168.188.45/** +- Anmeldung: https://192.168.188.45/accounts/login/ +- Der bisherige Port `http://192.168.188.45:8001/` leitet auf HTTPS weiter. +- Die Benutzerkonten und Passwort-Hashes aus der lokalen Projektdatenbank wurden übernommen. Bestehende Anmeldedaten bleiben gültig. +- Installationsverzeichnis: `/home/denis/csr-inventory` + +Die Anwendung läuft mit deaktiviertem Debugmodus, sicheren Cookies und einer +separaten PostgreSQL-Datenbank. Nur Traefik veröffentlicht Webports; PostgreSQL und +Gunicorn sind nicht direkt über veröffentlichte Docker-Ports erreichbar. + +## HTTPS im lokalen Netzwerk + +Traefik verwendet für die private IP-Adresse eine lokale Zertifizierungsstelle. +Das öffentliche Stammzertifikat liegt in [homelab-root-ca.crt](homelab-root-ca.crt) +und auf dem Server unter `/home/denis/csr-inventory-root-ca.crt`. + +Damit Windows-Browser dem Zertifikat vertrauen, kann es für den aktuellen Benutzer +importiert werden. Dieser Schritt wurde auf dem Arbeitsplatz **nicht** automatisch +ausgeführt. Im lokalen Projektverzeichnis: + +```powershell +Import-Certificate -FilePath .\docs\homelab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root +``` + +SHA-256-Fingerabdruck des Stammzertifikats: + +```text +7B:15:0B:52:62:81:68:3D:93:AA:CE:96:63:FD:23:9E:37:E2:AE:F3:6C:F3:E7:AB:E4:91:A6:F7:F9:8F:ED:05 +``` + +Die Serverdatei `docker-compose.override.yml` entspricht `docker-compose.lan.yml` +und veröffentlicht zusätzlich den Weiterleitungsport 8001. Traefik verwendet +den File-Provider ohne Docker-Socket und ohne veröffentlichtes Dashboard. +Statische Dateien liefert Django über WhiteNoise aus. + +Die bisherige Stamm-CA bleibt erhalten. Ihr privater Schlüssel liegt ausschließlich +auf dem Host unter `tls/ca/root.key`. Traefik erhält nur die Serverzertifikate. +Der Timer `csr-inventory-tls.timer` prüft täglich um 01:30 Uhr die Gültigkeit und +erneuert 90-Tage-Zertifikate bei weniger als 30 Tagen Restlaufzeit. Traefik lädt +neue Zertifikate automatisch. Einrichtung und Wartung: [Traefik](traefik.md). + +## Verwaltung + +Der SSH-Benutzer `denis` hat keine direkten Docker- oder sudo-Rechte. Zur Verwaltung +wie bei der Installation eine Root-Sitzung öffnen: + +```sh +ssh denis@192.168.188.45 +su - +cd /home/denis/csr-inventory +docker compose ps +docker compose logs --tail 100 web traefik +``` + +Nach Änderungen am Anwendungscode neu bauen und starten: + +```sh +sh scripts/backup.sh +docker compose up -d --build --wait web traefik +``` + +Die individuellen Anwendungs- und Datenbankgeheimnisse stehen ausschließlich in +der geschützten `.env` auf dem Server. Bei Aktualisierungen diese Datei, die +Serverüberschreibung und die persistenten Volumes beibehalten. + +## Datenbankzugriff mit DBeaver + +PostgreSQL wird nur auf `127.0.0.1:15432` des Servers veröffentlicht. +Für DBeaver einen PostgreSQL-Anschluss mit **Host** `127.0.0.1`, **Port** +`15432`, **Datenbank** `csr_inventory` und **Benutzer** `csr_inventory` +anlegen. Im Reiter **SSH** einen Tunnel zu `192.168.188.45:22` mit dem +SSH-Benutzer `denis` einrichten. Die Datenbank bleibt über die LAN-Adresse +auf Port 15432 unerreichbar. + +Das Datenbankpasswort ist `POSTGRES_PASSWORD` in der geschützten `.env`. +Als Root auf dem Server kann es mit +`sed -n 's/^POSTGRES_PASSWORD=//p' /home/denis/csr-inventory/.env` +für die Eingabe in DBeaver angezeigt werden. Das SSH-Passwort ist getrennt +vom Datenbankpasswort. In DBeaver die Verbindung über die Host-Felder +konfigurieren, nicht über eine fest eingetragene JDBC-URL. + +## Datensicherung + +- PostgreSQL-Sicherungen: `/home/denis/csr-inventory/backups/` +- Automatische Sicherung: täglich um **02:00 Uhr Europe/Berlin**. +- Timer: `csr-inventory-backup.timer` +- Dienst: `csr-inventory-backup.service` +- Der tägliche Job sichert die Datenbank. Upload-Dateien liegen im persistenten Media-Volume. Die TLS-Zertifizierungsstelle und Serverzertifikate liegen im Projektverzeichnis `tls/`. Diese Daten und die geschützte `.env` müssen separat gesichert werden. + +```sh +systemctl list-timers csr-inventory-backup.timer +journalctl -u csr-inventory-backup.service --no-pager +sh scripts/backup.sh +``` + +Eine erste PostgreSQL-Sicherung wurde erfolgreich in eine separate temporäre +Datenbank zurückgespielt und die Geräteanzahl verglichen. Die Prüfdatenbank wurde +anschließend entfernt. + +## Ablösung der bisherigen Inventarsoftware + +Die Container `computerspende-inventar-web-1` und +`computerspende-inventar-db-1` sowie deren Compose-Netzwerk wurden entfernt. +Die anderen Anwendungen auf dem Server laufen weiter. + +Vor der Entfernung wurden Datenbank, Anwendungskonfiguration und Uploads unter +`/root/csr-retired-inventory-20260923/` gesichert. Die alten Docker-Volumes und das +bisherige Quellverzeichnis wurden für eine mögliche Wiederherstellung erhalten. +Die Daten dieser anderen Anwendung wurden nicht mit der neuen Datenbank vermischt. + +## Durchgeführte Prüfungen + +- Alle **53 Tests mit PostgreSQL erfolgreich**, einschließlich gleichzeitiger Bearbeitungen. +- Django-Produktionsprüfung ohne Beanstandungen. +- 109 exportierte Datensätze übernommen, darunter zwei Geräte und die vorhandenen Benutzerkonten. +- HTTPS-Zertifikatsprüfung, Healthcheck, Loginseite und statische Dateien vom Server und vom Arbeitsplatz geprüft. +- API-Zugriff ohne Schlüssel wird mit HTTP 401 abgewiesen. +- Weiterleitung vom bisherigen Port 8001 geprüft. +- Datenbank-Backup und Wiederherstellung in eine separate Prüfdatenbank erfolgreich. + +## Datenbank- und UI-Update vom 26. September 2026 + +Vor Migration 0009/0010 wurde eine neue PostgreSQL-Sicherung erstellt. +61 Tests bestanden mit PostgreSQL 17. Die drei vorhandenen Geräte blieben +erhalten und besitzen jetzt jeweils einen Datensatz in +`inventory_computerspecification`. Die alten Hardware-Spalten in +`inventory_device` wurden entfernt. Beide Migrationen sind angewendet; +der Web-Container ist gesund. Der neue Ablauf unter `/ablauf/` antwortet +für angemeldete Benutzer erfolgreich. Healthcheck, Login, CSS und API-Schutz +wurden nach dem Neustart geprüft. + +## Arbeitsschritt-Formulare vom 26. September 2026 + +Vor Migration 0011 wurde erneut eine PostgreSQL-Sicherung erstellt. +Die Migration ergänzt getrennte Tabellen für Inspektionsbefunde und +Reparaturarbeiten. Im Ablauf-Board können Bearbeiter während der +Inspektion Mängel und technische Daten, während der Reparatur die Arbeiten +und während der Lagerung den Empfänger eintragen. 63 Tests bestanden auf +PostgreSQL 17. Nach dem Neustart waren alle drei vorhandenen Geräte +erhalten; das Board antwortete angemeldet mit HTTP 200 und der Web-Container +war gesund. Healthcheck, Loginseite und CSS antworteten ebenfalls mit 200. + +## Gerätetyp-Schema vom 28. September 2026 + +Vor Migration 0012 wurde eine PostgreSQL-Sicherung erstellt. 64 Tests +bestanden mit PostgreSQL 17. Die Migration verschob die vier vorhandenen +Geräte in drei Laptop-Spezifikationen und eine Desktop-Spezifikation; +die allgemeine Computertabelle enthält danach keine dieser Geräte mehr. +Die Tabellen für AiO, Monitor und Drucker sind vorhanden. Geräteliste, +Ablauf, Übersicht, Detailseite, Healthcheck und Loginseite wurden nach dem +Neustart erfolgreich geprüft. Das Ablauf-Board antwortete angemeldet in +etwa 0,3 Sekunden. Das Schema ist unter [Datenbankschema](datenbankschema.md) +beschrieben. diff --git a/docs/traefik.md b/docs/traefik.md new file mode 100644 index 0000000..3e765fd --- /dev/null +++ b/docs/traefik.md @@ -0,0 +1,58 @@ +# Traefik-Betrieb + +Traefik v3.7.13 ersetzt Caddy. Die Routerdefinition liegt unter +`traefik/dynamic/routes.yml`; ein Docker-Socket ist nicht erforderlich. +Nur HTTP/HTTPS werden veröffentlicht, das Dashboard ist deaktiviert. +WhiteNoise liefert statische Dateien aus, Uploads bleiben geschützt. + +## Öffentliche Domain + +In `.env` `APP_HOST` ohne Protokoll oder Port und `ACME_EMAIL` setzen. +DNS muss auf den Server zeigen; TCP 80 und 443 müssen erreichbar sein. +`docker compose up -d --build --wait` aktiviert HTTPS über Let's Encrypt. +ACME-Zustand liegt im Volume `traefik_acme` und muss gesichert werden. + +## Privates LAN + +Auf Linux mit Python 3 und OpenSSL, im Projektverzeichnis als Administrator: + +```sh +# APP_HOST in .env auf die LAN-IP setzen. +cp docker-compose.lan.yml docker-compose.override.yml +python3 scripts/renew_local_tls.py --hostname 192.168.188.45 +docker compose up -d --build --wait +install -m 644 deploy/csr-inventory-tls.service deploy/csr-inventory-tls.timer /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now csr-inventory-tls.timer +systemctl start csr-inventory-tls.service +``` + +Die mitgelieferten Units setzen `/home/denis/csr-inventory` voraus; bei anderen +Installationspfaden beide Pfade im Dienst anpassen. Der LAN-Override deaktiviert +ACME und ergänzt Port 8001 als HTTPS-Weiterleitung. + +Ohne vorhandene CA erzeugt das Skript eine neue. Zur Übernahme einer bestehenden +CA vor dem ersten Lauf deren `root.crt` und `root.key` nach `tls/ca/` kopieren. +Der CA-Schlüssel bleibt auf dem Host und darf nicht verteilt werden. Nur das +öffentliche Stammzertifikat wird in den Vertrauensspeicher der Clients importiert. +Auf dem Server 192.168.188.45 wird die bisherige Stamm-CA weiterverwendet. + +Der Timer prüft täglich um 01:30 Europe/Berlin. Zertifikate gelten 90 Tage und +werden bei höchstens 30 Tagen Restlaufzeit erneuert. Ein Standardzertifikat +ermöglicht auch den IP-Zugriff ohne SNI. Die Dateikonfiguration wird atomar +ersetzt; Traefik lädt sie ohne Neustart. Frühere Zertifikate bleiben erhalten. + +```sh +systemctl list-timers csr-inventory-tls.timer +journalctl -u csr-inventory-tls.service --no-pager +docker compose logs --tail 100 traefik +# Erneuerung bei Bedarf erzwingen: +python3 scripts/renew_local_tls.py --hostname 192.168.188.45 --force +``` + +`tls/`, `.env`, Uploads und Datenbank separat geschützt sichern. Der tägliche +Datenbankjob umfasst keine TLS-Schlüssel. `tls/` und die erzeugte `tls.yml` +sind vom Git-Repository und Docker-Build ausgeschlossen. + +Referenzen: [File-Provider](https://doc.traefik.io/traefik/providers/file/), +[TLS-Dateikonfiguration](https://doc.traefik.io/traefik/reference/routing-configuration/other-providers/file/). diff --git a/inventory/__init__.py b/inventory/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inventory/__init__.py @@ -0,0 +1 @@ + diff --git a/inventory/admin.py b/inventory/admin.py new file mode 100644 index 0000000..5b0077d --- /dev/null +++ b/inventory/admin.py @@ -0,0 +1,120 @@ +from django.contrib import admin +from django import forms +from django.core.exceptions import ValidationError +from django.utils import timezone +from rest_framework_api_key.admin import APIKeyModelAdmin +from rest_framework_api_key.models import APIKey + +from .models import AuditLog, AllInOneSpecification, ChoiceValue, ComputerSpecification, DesktopSpecification, Device, DeviceTask, Employee, FieldDefinition, FieldOption, ImportJob, InspectionRecord, InventoryAPIKey, LaptopSpecification, MonitorSpecification, PrinterSpecification, RepairRecord, SavedView, UserPreference, UserProfile, WorkflowEvent +from .permissions import can_edit + + +try: + admin.site.unregister(APIKey) +except admin.sites.NotRegistered: + pass + + +class InventoryAPIKeyAdminForm(forms.ModelForm): + class Meta: + model = InventoryAPIKey + fields = "__all__" + + def clean_expiry_date(self): + expiry = self.cleaned_data.get("expiry_date") + if not expiry: + raise ValidationError("Ein Ablaufdatum ist aus Sicherheitsgründen erforderlich.") + if expiry <= timezone.now(): + raise ValidationError("Das Ablaufdatum muss in der Zukunft liegen.") + return expiry + + def clean(self): + cleaned = super().clean() + user = cleaned.get("service_user") + if cleaned.get("can_write") and user and not can_edit(user): + self.add_error("service_user", "Schreibzugriff erfordert ein Bearbeiter- oder Administratorkonto.") + return cleaned + + +@admin.register(Device) +class DeviceAdmin(admin.ModelAdmin): + list_display = ("number", "stage", "status", "device_type", "model", "employee", "date", "archived_at") + search_fields = ("=number", "model", "laptop_specification__cpu", "desktop_specification__cpu", "aio_specification__cpu", "computer_specification__cpu", "ticket", "comment") + list_filter = ("stage", "status", "device_type", "employee", "archived_at") + readonly_fields = ("number", "created_at", "updated_at") + + +@admin.register(DeviceTask) +class DeviceTaskAdmin(admin.ModelAdmin): + list_display = ("title", "device", "state", "assigned_to", "due_date", "completed_at") + list_filter = ("state", "due_date") + search_fields = ("title", "device__number") + + +@admin.register(WorkflowEvent) +class WorkflowEventAdmin(admin.ModelAdmin): + list_display = ("device", "from_stage", "to_stage", "user", "created_at") + readonly_fields = ("device", "from_stage", "to_stage", "note", "user", "created_at") + + +@admin.register(InspectionRecord, RepairRecord) +class StageRecordAdmin(admin.ModelAdmin): + list_display = ("device", "recorded_by", "created_at") + + def get_readonly_fields(self, request, obj=None): + return tuple(field.name for field in self.model._meta.fields) + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(LaptopSpecification, DesktopSpecification, AllInOneSpecification, ComputerSpecification, MonitorSpecification, PrinterSpecification) +class SpecificationAdmin(admin.ModelAdmin): + list_display = ("device",) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(ChoiceValue) +class ChoiceValueAdmin(admin.ModelAdmin): + list_display = ("kind", "label", "active", "sort_order") + list_filter = ("kind", "active") + + +@admin.register(InventoryAPIKey) +class InventoryAPIKeyAdmin(APIKeyModelAdmin): + form = InventoryAPIKeyAdminForm + list_display = ( + "prefix", "name", "service_user", "can_read", "can_write", "created", + "expiry_date", "_has_expired", "revoked", "last_used_at", + ) + list_filter = ("can_read", "can_write", "revoked", "created") + search_fields = ("name", "prefix", "service_user__username") + + def get_readonly_fields(self, request, obj=None): + return super().get_readonly_fields(request, obj) + ("created_by", "last_used_at", "last_used_ip") + + def save_model(self, request, obj, form=None, change=False): + if not obj.pk: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + +admin.site.register(Employee) +admin.site.register(UserProfile) +admin.site.register(FieldDefinition) +admin.site.register(FieldOption) +admin.site.register(AuditLog) +admin.site.register(UserPreference) +admin.site.register(SavedView) +admin.site.register(ImportJob) diff --git a/inventory/api.py b/inventory/api.py new file mode 100644 index 0000000..e3e66e0 --- /dev/null +++ b/inventory/api.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from collections.abc import Mapping + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.utils.dateparse import parse_datetime +from rest_framework import generics, serializers, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import ChoiceValue, Device, Employee +from .query import SPECIFICATION_RELATIONS +from .services import update_device_field + + +class SecureAPIResponseMixin: + def finalize_response(self, request, response, *args, **kwargs): + response = super().finalize_response(request, response, *args, **kwargs) + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" + response["Vary"] = "Authorization" + return response + + +class ChoiceSummarySerializer(serializers.ModelSerializer): + class Meta: + model = ChoiceValue + fields = ["id", "label", "color"] + + +class EmployeeSummarySerializer(serializers.ModelSerializer): + class Meta: + model = Employee + fields = ["id", "code", "name"] + + +class DeviceSerializer(serializers.ModelSerializer): + battery = serializers.CharField(read_only=True) + wifi = serializers.CharField(read_only=True) + lan = serializers.CharField(read_only=True) + display_inches = serializers.DecimalField(max_digits=4, decimal_places=1, read_only=True, allow_null=True) + ram_gb = serializers.IntegerField(read_only=True, allow_null=True) + ssd_gb = serializers.IntegerField(read_only=True, allow_null=True) + hdd_gb = serializers.IntegerField(read_only=True, allow_null=True) + cpu = serializers.CharField(read_only=True) + hwid = serializers.CharField(read_only=True) + ports = serializers.CharField(read_only=True) + resolution = serializers.CharField(read_only=True) + print_technology = serializers.CharField(read_only=True) + duplex = serializers.CharField(read_only=True) + color_printing = serializers.CharField(read_only=True) + status = ChoiceSummarySerializer(read_only=True) + recipient = ChoiceSummarySerializer(read_only=True) + location = ChoiceSummarySerializer(read_only=True) + device_type = ChoiceSummarySerializer(read_only=True) + employee = EmployeeSummarySerializer(read_only=True) + + class Meta: + model = Device + fields = [ + "number", "stage", "shelf", "status", "date", "recipient", "location", "ticket", + "device_type", "price", "battery", "wifi", "lan", "display_inches", + "ram_gb", "ssd_gb", "hdd_gb", "cpu", "hwid", "ports", "resolution", "print_technology", + "duplex", "color_printing", "model", "employee", + "donor", "comment", "archived_at", "created_at", "updated_at", "revision", + ] + + +class StrictSerializer(serializers.Serializer): + def to_internal_value(self, data): + if isinstance(data, Mapping): + unknown = set(data) - set(self.fields) + if unknown: + names = ", ".join(sorted(unknown)) + raise serializers.ValidationError({"unbekannte_felder": f"Nicht erlaubt: {names}"}) + return super().to_internal_value(data) + + +def active_choice_field(kind, *, allow_null=True): + return serializers.PrimaryKeyRelatedField( + queryset=ChoiceValue.objects.filter(kind=kind, active=True), + required=False, + allow_null=allow_null, + ) + + +class DeviceChangesSerializer(StrictSerializer): + shelf = serializers.CharField(max_length=80, required=False, allow_blank=True) + status = active_choice_field(ChoiceValue.Kind.STATUS, allow_null=False) + date = serializers.DateField(required=False) + recipient = active_choice_field(ChoiceValue.Kind.RECIPIENT) + location = active_choice_field(ChoiceValue.Kind.LOCATION) + ticket = serializers.CharField(max_length=120, required=False, allow_blank=True) + device_type = active_choice_field(ChoiceValue.Kind.DEVICE_TYPE) + price = serializers.DecimalField(max_digits=10, decimal_places=2, required=False, allow_null=True, min_value=0) + battery = serializers.ChoiceField(choices=Device.TriState.choices, required=False) + wifi = serializers.ChoiceField(choices=Device.TriState.choices, required=False) + lan = serializers.ChoiceField(choices=Device.TriState.choices, required=False) + display_inches = serializers.DecimalField(max_digits=4, decimal_places=1, required=False, allow_null=True, min_value=0) + ram_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0) + ssd_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0) + hdd_gb = serializers.IntegerField(required=False, allow_null=True, min_value=0) + cpu = serializers.CharField(max_length=200, required=False, allow_blank=True) + hwid = serializers.CharField(max_length=255, required=False, allow_blank=True) + ports = serializers.CharField(max_length=255, required=False, allow_blank=True) + resolution = serializers.CharField(max_length=80, required=False, allow_blank=True) + print_technology = serializers.CharField(max_length=80, required=False, allow_blank=True) + duplex = serializers.ChoiceField(choices=Device.TriState.choices, required=False) + color_printing = serializers.ChoiceField(choices=Device.TriState.choices, required=False) + model = serializers.CharField(max_length=200, required=False, allow_blank=True) + employee = serializers.PrimaryKeyRelatedField( + queryset=Employee.objects.filter(active=True), required=False, allow_null=True, + ) + donor = serializers.CharField(max_length=200, required=False, allow_blank=True) + comment = serializers.CharField(required=False, allow_blank=True) + + def validate(self, attrs): + if not attrs: + raise serializers.ValidationError("Mindestens ein zu änderndes Feld ist erforderlich.") + return attrs + + +class DevicePatchSerializer(DeviceChangesSerializer): + expected_updated_at = serializers.DateTimeField(required=False, write_only=True) + expected_revision = serializers.IntegerField(required=False, min_value=1, write_only=True) + + def validate(self, attrs): + if not {"expected_updated_at", "expected_revision"} & set(attrs): + raise serializers.ValidationError("expected_revision oder expected_updated_at ist erforderlich.") + if not set(attrs) - {"expected_updated_at", "expected_revision"}: + raise serializers.ValidationError("Mindestens ein zu änderndes Feld ist erforderlich.") + return attrs + + +class BulkUpdateSerializer(StrictSerializer): + device_numbers = serializers.ListField( + child=serializers.IntegerField(min_value=1), min_length=1, max_length=500, + ) + changes = DeviceChangesSerializer() + + def validate_device_numbers(self, numbers): + if len(numbers) != len(set(numbers)): + raise serializers.ValidationError("Gerätenummern dürfen nicht doppelt vorkommen.") + return numbers + + +def raw_service_value(value): + if isinstance(value, (ChoiceValue, Employee)): + return value.pk + return value + + +def apply_changes(device, changes, *, user, api_client, expected_updated_at=None, expected_revision=None): + current = device + for index, (field, value) in enumerate(changes.items()): + current = update_device_field( + current, + field, + raw_service_value(value), + user, + expected_updated_at if index == 0 and expected_updated_at else None, + api_client=api_client, + expected_revision=expected_revision if index == 0 else None, + ) + return current + + +class DeviceListView(SecureAPIResponseMixin, generics.ListAPIView): + serializer_class = DeviceSerializer + + def get_queryset(self): + queryset = Device.objects.select_related( + "status", "recipient", "location", "device_type", "employee", + *SPECIFICATION_RELATIONS, + ).order_by("number") + params = self.request.query_params + if params.get("archived", "false").lower() not in {"1", "true", "yes"}: + queryset = queryset.filter(archived_at__isnull=True) + if number := params.get("number"): + if number.isascii() and number.isdigit() and len(number) <= 10: + queryset = queryset.filter(number=int(number)) + else: + return queryset.none() + if prefix := params.get("number_prefix"): + if prefix.isascii() and prefix.isdigit() and len(prefix) <= 10: + queryset = queryset.filter(number__startswith=prefix) + else: + return queryset.none() + for parameter, field in (("status", "status_id"), ("type", "device_type_id"), ("employee", "employee_id")): + value = params.get(parameter) + if value: + if not value.isascii() or not value.isdigit() or len(value) > 19 or int(value) > 9223372036854775807: + return queryset.none() + queryset = queryset.filter(**{field: int(value)}) + if changed_since := params.get("changed_since"): + try: + parsed = parse_datetime(changed_since) + except ValueError: + parsed = None + if parsed: + queryset = queryset.filter(updated_at__gte=parsed) + else: + return queryset.none() + return queryset + + +class DeviceDetailView(SecureAPIResponseMixin, APIView): + def get_object(self, number): + return generics.get_object_or_404( + Device.objects.select_related("status", "recipient", "location", "device_type", "employee", *SPECIFICATION_RELATIONS), + number=number, + ) + + def get(self, request, number): + return Response(DeviceSerializer(self.get_object(number)).data) + + @transaction.atomic + def patch(self, request, number): + serializer = DevicePatchSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + values = dict(serializer.validated_data) + expected = values.pop("expected_updated_at", None) + revision = values.pop("expected_revision", None) + device = self.get_object(number) + try: + device = apply_changes( + device, + values, + user=request.user, + api_client=request.auth.name, + expected_updated_at=expected, + expected_revision=revision, + ) + except RuntimeError as exc: + if str(exc) == "conflict": + return Response( + {"detail": "Der Datensatz wurde zwischenzeitlich geändert. Bitte neu laden."}, + status=status.HTTP_409_CONFLICT, + ) + raise + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) + device.refresh_from_db() + return Response(DeviceSerializer(device).data) + + +class BulkUpdateView(SecureAPIResponseMixin, APIView): + @transaction.atomic + def post(self, request): + serializer = BulkUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + numbers = serializer.validated_data["device_numbers"] + changes = serializer.validated_data["changes"] + devices = list(Device.objects.select_for_update().filter(number__in=numbers, archived_at__isnull=True)) + found = {device.number for device in devices} + missing = sorted(set(numbers) - found) + if missing: + raise serializers.ValidationError({"device_numbers": f"Nicht gefunden oder archiviert: {missing}"}) + try: + for device in devices: + apply_changes(device, changes, user=request.user, api_client=request.auth.name) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.messages) + return Response({"updated": len(devices), "device_numbers": sorted(found)}) + + +class ChoicesView(SecureAPIResponseMixin, APIView): + def get(self, request): + result = {} + for kind, label in ( + (ChoiceValue.Kind.STATUS, "statuses"), + (ChoiceValue.Kind.RECIPIENT, "recipients"), + (ChoiceValue.Kind.LOCATION, "locations"), + (ChoiceValue.Kind.DEVICE_TYPE, "device_types"), + ): + values = ChoiceValue.objects.filter(kind=kind, active=True).order_by("sort_order", "label") + result[label] = ChoiceSummarySerializer(values, many=True).data + employees = Employee.objects.filter(active=True).order_by("code") + result["employees"] = EmployeeSummarySerializer(employees, many=True).data + result["tri_state_values"] = [{"value": value, "label": label} for value, label in Device.TriState.choices] + return Response(result) diff --git a/inventory/api_auth.py b/inventory/api_auth.py new file mode 100644 index 0000000..3df50f8 --- /dev/null +++ b/inventory/api_auth.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import timedelta +from ipaddress import ip_address + +from django.utils import timezone +from rest_framework import authentication, exceptions, permissions, throttling +from rest_framework_api_key.permissions import KeyParser + +from .models import InventoryAPIKey +from .permissions import can_edit + + +class InventoryAPIKeyAuthentication(authentication.BaseAuthentication): + """Authentifiziert ausschließlich `Authorization: Api-Key `.""" + + key_parser = KeyParser() + + def authenticate(self, request): + raw_key = self.key_parser.get(request) + if not raw_key: + return None + try: + api_key = InventoryAPIKey.objects.get_from_key(raw_key) + except (InventoryAPIKey.DoesNotExist, ValueError): + raise exceptions.AuthenticationFailed("Ungültiger API-Schlüssel.") + if api_key.has_expired: + raise exceptions.AuthenticationFailed("Der API-Schlüssel ist abgelaufen.") + if not api_key.service_user.is_active: + raise exceptions.AuthenticationFailed("Das zugehörige Konto ist deaktiviert.") + + # Letzte Nutzung höchstens alle fünf Minuten schreiben, damit häufige + # Lesezugriffe nicht unnötig viele Datenbankänderungen erzeugen. + now = timezone.now() + if not api_key.last_used_at or api_key.last_used_at < now - timedelta(minutes=5): + remote_addr = request.META.get("REMOTE_ADDR", "") + try: + last_used_ip = str(ip_address(remote_addr)) if remote_addr else None + except ValueError: + last_used_ip = None + InventoryAPIKey.objects.filter(pk=api_key.pk).update(last_used_at=now, last_used_ip=last_used_ip) + api_key.last_used_at = now + api_key.last_used_ip = last_used_ip + + return api_key.service_user, api_key + + def authenticate_header(self, request): + return 'Api-Key realm="inventory-api"' + + +class InventoryAPIPermission(permissions.BasePermission): + message = "Dieser API-Schlüssel besitzt nicht die erforderliche Berechtigung." + + def has_permission(self, request, view): + api_key = request.auth + if not isinstance(api_key, InventoryAPIKey): + return False + if request.method in permissions.SAFE_METHODS: + return api_key.can_read + return api_key.can_write and can_edit(request.user) + + +class APIClientRateThrottle(throttling.UserRateThrottle): + scope = "api_client" diff --git a/inventory/api_pagination.py b/inventory/api_pagination.py new file mode 100644 index 0000000..aba4269 --- /dev/null +++ b/inventory/api_pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class PagedDeviceResults(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/inventory/api_urls.py b/inventory/api_urls.py new file mode 100644 index 0000000..45f557d --- /dev/null +++ b/inventory/api_urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView + + +app_name = "inventory-api" + +urlpatterns = [ + path("devices/", DeviceListView.as_view(), name="device-list"), + path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"), + path("devices//", DeviceDetailView.as_view(), name="device-detail"), + path("choices/", ChoicesView.as_view(), name="choices"), +] diff --git a/inventory/apps.py b/inventory/apps.py new file mode 100644 index 0000000..b2dee4b --- /dev/null +++ b/inventory/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class InventoryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "inventory" + + def ready(self) -> None: + from . import signals # noqa: F401 diff --git a/inventory/context_processors.py b/inventory/context_processors.py new file mode 100644 index 0000000..a4806a9 --- /dev/null +++ b/inventory/context_processors.py @@ -0,0 +1,10 @@ +from .models import UserProfile +from .permissions import user_role + + +def navigation_context(request): + role = user_role(request.user) if request.user.is_authenticated else None + return { + "current_role": role, + "current_role_label": dict(UserProfile.Role.choices).get(role, ""), + } diff --git a/inventory/cpu_catalog.py b/inventory/cpu_catalog.py new file mode 100644 index 0000000..014c1af --- /dev/null +++ b/inventory/cpu_catalog.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import re +from collections.abc import Iterable +from pathlib import Path + + +CPU_VENDOR_CHOICES = ( + ("intel", "Intel"), + ("amd", "AMD"), +) + +CPU_FAMILY_CHOICES = ( + ("intel_i3", "Intel Core i3"), + ("intel_i5", "Intel Core i5"), + ("intel_i7", "Intel Core i7"), + ("intel_i9", "Intel Core i9"), + ("intel_ultra_5", "Intel Core Ultra 5"), + ("intel_ultra_7", "Intel Core Ultra 7"), + ("intel_ultra_9", "Intel Core Ultra 9"), + ("amd_ryzen_3", "AMD Ryzen 3"), + ("amd_ryzen_5", "AMD Ryzen 5"), + ("amd_ryzen_7", "AMD Ryzen 7"), + ("amd_ryzen_9", "AMD Ryzen 9"), + ("amd_ryzen_ai", "AMD Ryzen AI"), + ("amd_threadripper", "AMD Threadripper"), + ("amd_fx", "AMD FX"), +) + + +def _load_catalog() -> tuple[str, ...]: + values = [] + for line in Path(__file__).with_name("cpu_models.txt").read_text(encoding="utf-8").splitlines(): + value = line.strip() + if not value: + continue + if value.startswith("Core Ultra"): + value = f"Intel {value}" + values.append(value) + return tuple(dict.fromkeys(values)) + + +CPU_MODELS = _load_catalog() + + +def _tokens(value: str) -> list[str]: + return re.findall(r"[a-z]+\d*|\d+[a-z]*", value.casefold()) + + +def _short_tokens(value: str) -> list[str]: + tokens = _tokens(value) + while tokens and tokens[0] in {"intel", "amd", "core"}: + tokens.pop(0) + return tokens + + +def _match_score(model: str, term: str, preferred: bool) -> int: + query_tokens = _tokens(term) + if not query_tokens: + return 100 if preferred else 1 + + model_tokens = _tokens(model) + short_tokens = _short_tokens(model) + query_text = " ".join(query_tokens) + model_text = " ".join(model_tokens) + short_text = " ".join(short_tokens) + compact_query = "".join(query_tokens) + compact_model = "".join(model_tokens) + compact_short = "".join(short_tokens) + + score = 0 + if query_text == model_text: + score = 12_000 + elif query_text == short_text: + score = 11_000 + elif compact_query in {compact_model, compact_short}: + score = 10_000 + elif all(token in model_tokens for token in query_tokens): + score = 9_000 + elif all(any(candidate.startswith(token) for candidate in model_tokens) for token in query_tokens): + score = 8_000 + elif compact_query in compact_model: + score = 7_000 + elif all(token in model_text for token in query_tokens): + score = 6_000 + else: + return 0 + + if preferred: + score += 250 + score -= min(len(model), 200) + return score + + +def suggest_cpus(term: str, existing_values: Iterable[str] = (), limit: int = 15) -> list[str]: + existing = {value.strip() for value in existing_values if value and value.strip()} + candidates = list(dict.fromkeys([*existing, *CPU_MODELS])) + ranked = [ + (_match_score(model, term, model in existing), index, model) + for index, model in enumerate(candidates) + ] + ranked = [item for item in ranked if item[0] > 0] + ranked.sort(key=lambda item: (-item[0], item[1], item[2].casefold())) + return [model for _, _, model in ranked[:limit]] diff --git a/inventory/cpu_models.txt b/inventory/cpu_models.txt new file mode 100644 index 0000000..b944b10 --- /dev/null +++ b/inventory/cpu_models.txt @@ -0,0 +1,640 @@ +Core Ultra 9 285K +Core Ultra 9 285 +Core Ultra 9 285T +Core Ultra 7 270K Plus +Core Ultra 7 265K +Core Ultra 7 265KF +Core Ultra 7 265 +Core Ultra 7 265F +Core Ultra 7 265T +Core Ultra 5 250K Plus +Core Ultra 5 250KF Plus +Core Ultra 5 245K +Core Ultra 5 245KF +Core Ultra 5 245 +Core Ultra 5 245T +Core Ultra 5 235 +Core Ultra 5 235T +Core Ultra 5 225 +Core Ultra 5 225F +Core Ultra 5 225T +Intel Core i9-14900KS +Intel Core i9-14900K +Intel Core i9-14900KF +Intel Core i9-14900 +Intel Core i9-14900F +Intel Core i9-14900T +Intel Core i9-13900KS +Intel Core i9-13900K +Intel Core i9-13900KF +Intel Core i9-13900 +Intel Core i9-13900F +Intel Core i9-13900T +Intel Core i9-12900KS +Intel Core i9-12900K +Intel Core i9-12900KF +Intel Core i9-12900 +Intel Core i9-12900F +Intel Core i9-12900T +Intel Core i9-11900K +Intel Core i9-11900KF +Intel Core i9-11900 +Intel Core i9-11900F +Intel Core i9-11900T +Intel Core i9-11900KB +Intel Core i9-10900K +Intel Core i9-10900KF +Intel Core i9-10910 +Intel Core i9-10900 +Intel Core i9-10900F +Intel Core i9-10900T +Intel Core i9-10850K +Intel Core i9-10980XE +Intel Core i9-10940X +Intel Core i9-10920X +Intel Core i9-10900X +Intel Core i9-9900KS +Intel Core i9-9900K +Intel Core i9-9900KF +Intel Core i9-9900 +Intel Core i9-9900T +Intel Core i9-9980XE +Intel Core i9-9960X +Intel Core i9-9940X +Intel Core i9-9920X +Intel Core i9-9900X +Intel Core i9-7980XE +Intel Core i9-7960X +Intel Core i9-7940X +Intel Core i9-7920X +Intel Core i9-7900X +Intel Core i7-14790F +Intel Core i7-14700K +Intel Core i7-14700KF +Intel Core i7-14700 +Intel Core i7-14700F +Intel Core i7-14700T +Intel Core i7-13790F +Intel Core i7-13700K +Intel Core i7-13700KF +Intel Core i7-13700 +Intel Core i7-13700F +Intel Core i7-13700T +Intel Core i7-12700K +Intel Core i7-12700KF +Intel Core i7-12700 +Intel Core i7-12700F +Intel Core i7-12700T +Intel Core i7-11700K +Intel Core i7-11700KF +Intel Core i7-11700 +Intel Core i7-11700F +Intel Core i7-11700T +Intel Core i7-11700B +Intel Core i7-10700K +Intel Core i7-10700KF +Intel Core i7-10700 +Intel Core i7-10700F +Intel Core i7-10700T +Intel Core i7-9700K +Intel Core i7-9700KF +Intel Core i7-9700 +Intel Core i7-9700F +Intel Core i7-9700T +Intel Core i7-9800X +Intel Core i7-8086K +Intel Core i7-8700K +Intel Core i7-8700 +Intel Core i7-8700T +Intel Core i7-7700K +Intel Core i7-7700 +Intel Core i7-7700T +Intel Core i7-7820X +Intel Core i7-7800X +Intel Core i7-7740X +Intel Core i7-6785R +Intel Core i7-6700K +Intel Core i7-6700 +Intel Core i7-6700T +Intel Core i7-6950X +Intel Core i7-6900K +Intel Core i7-6850K +Intel Core i7-6800K +Intel Core i7-5775C +Intel Core i7-5775R +Intel Core i7-5960X +Intel Core i7-5930K +Intel Core i7-5820K +Intel Core i7-4790K +Intel Core i7-4790 +Intel Core i7-4790S +Intel Core i7-4790T +Intel Core i7-4785T +Intel Core i7-4771 +Intel Core i7-4770K +Intel Core i7-4770 +Intel Core i7-4770S +Intel Core i7-4770T +Intel Core i7-4770R +Intel Core i7-4765T +Intel Core i7-4960X +Intel Core i7-4930K +Intel Core i7-4820K +Intel Core i7-3770K +Intel Core i7-3770 +Intel Core i7-3770S +Intel Core i7-3770T +Intel Core i7-2700K +Intel Core i7-2600K +Intel Core i7-2600 +Intel Core i7-2600S +Intel Core i5-14600K +Intel Core i5-14600KF +Intel Core i5-14600 +Intel Core i5-14600T +Intel Core i5-14500 +Intel Core i5-14500T +Intel Core i5-14490F +Intel Core i5-14400 +Intel Core i5-14400F +Intel Core i5-14400T +Intel Core i5-13600K +Intel Core i5-13600KF +Intel Core i5-13600 +Intel Core i5-13600T +Intel Core i5-13500 +Intel Core i5-13500T +Intel Core i5-13490F +Intel Core i5-13400 +Intel Core i5-13400F +Intel Core i5-13400T +Intel Core i5-12600K +Intel Core i5-12600KF +Intel Core i5-12600 +Intel Core i5-12600T +Intel Core i5-12500 +Intel Core i5-12500T +Intel Core i5-12490F +Intel Core i5-12400 +Intel Core i5-12400F +Intel Core i5-12400T +Intel Core i5-11600K +Intel Core i5-11600KF +Intel Core i5-11600 +Intel Core i5-11600T +Intel Core i5-11500 +Intel Core i5-11500T +Intel Core i5-11400 +Intel Core i5-11400F +Intel Core i5-11400T +Intel Core i5-11500B +Intel Core i5-10600K +Intel Core i5-10600KF +Intel Core i5-10600 +Intel Core i5-10600T +Intel Core i5-10505 +Intel Core i5-10500 +Intel Core i5-10500T +Intel Core i5-10400 +Intel Core i5-10400F +Intel Core i5-10400T +Intel Core i5-9600K +Intel Core i5-9600KF +Intel Core i5-9600 +Intel Core i5-9600T +Intel Core i5-9500 +Intel Core i5-9500F +Intel Core i5-9500T +Intel Core i5-9400 +Intel Core i5-9400F +Intel Core i5-9400T +Intel Core i5-8600K +Intel Core i5-8600 +Intel Core i5-8600T +Intel Core i5-8500 +Intel Core i5-8500T +Intel Core i5-8400 +Intel Core i5-8400T +Intel Core i5-7600K +Intel Core i5-7600 +Intel Core i5-7600T +Intel Core i5-7500 +Intel Core i5-7500T +Intel Core i5-7400 +Intel Core i5-7400T +Intel Core i5-7640X +Intel Core i5-6685R +Intel Core i5-6600K +Intel Core i5-6600 +Intel Core i5-6600T +Intel Core i5-6585R +Intel Core i5-6500 +Intel Core i5-6500T +Intel Core i5-6402P +Intel Core i5-6400 +Intel Core i5-6400T +Intel Core i5-5675C +Intel Core i5-5675R +Intel Core i5-5575R +Intel Core i5-4690K +Intel Core i5-4690 +Intel Core i5-4690S +Intel Core i5-4690T +Intel Core i5-4670K +Intel Core i5-4670 +Intel Core i5-4670S +Intel Core i5-4670T +Intel Core i5-4670R +Intel Core i5-4590 +Intel Core i5-4590S +Intel Core i5-4590T +Intel Core i5-4570 +Intel Core i5-4570S +Intel Core i5-4570T +Intel Core i5-4570R +Intel Core i5-4460 +Intel Core i5-4460S +Intel Core i5-4460T +Intel Core i5-4440 +Intel Core i5-4440S +Intel Core i5-4430 +Intel Core i5-4430S +Intel Core i5-3570K +Intel Core i5-3570 +Intel Core i5-3570S +Intel Core i5-3570T +Intel Core i5-3550 +Intel Core i5-3550S +Intel Core i5-3475S +Intel Core i5-3470 +Intel Core i5-3470S +Intel Core i5-3470T +Intel Core i5-3450 +Intel Core i5-3450S +Intel Core i5-3350P +Intel Core i5-3340 +Intel Core i5-3340S +Intel Core i5-3335S +Intel Core i5-3330 +Intel Core i5-3330S +Intel Core i5-2550K +Intel Core i5-2500K +Intel Core i5-2500 +Intel Core i5-2500S +Intel Core i5-2500T +Intel Core i5-2450P +Intel Core i5-2405S +Intel Core i5-2400 +Intel Core i5-2400S +Intel Core i5-2390T +Intel Core i5-2380P +Intel Core i5-2320 +Intel Core i5-2310 +Intel Core i5-2300 +Intel Core i3-14100 +Intel Core i3-14100F +Intel Core i3-14100T +Intel Core i3-13100 +Intel Core i3-13100F +Intel Core i3-13100T +Intel Core i3-12300 +Intel Core i3-12300T +Intel Core i3-12100 +Intel Core i3-12100F +Intel Core i3-12100T +Intel Core i3-11100B +Intel Core i3-10325 +Intel Core i3-10320 +Intel Core i3-10305 +Intel Core i3-10305T +Intel Core i3-10300 +Intel Core i3-10300T +Intel Core i3-10105 +Intel Core i3-10105F +Intel Core i3-10105T +Intel Core i3-10100 +Intel Core i3-10100F +Intel Core i3-10100T +Intel Core i3-9350K +Intel Core i3-9350KF +Intel Core i3-9320 +Intel Core i3-9300 +Intel Core i3-9300T +Intel Core i3-9100 +Intel Core i3-9100F +Intel Core i3-9100T +Intel Core i3-8350K +Intel Core i3-8300 +Intel Core i3-8300T +Intel Core i3-8100 +Intel Core i3-8100F +Intel Core i3-8100T +Intel Core i3-7350K +Intel Core i3-7320 +Intel Core i3-7300 +Intel Core i3-7300T +Intel Core i3-7100 +Intel Core i3-7100T +Intel Core i3-6320 +Intel Core i3-6300 +Intel Core i3-6300T +Intel Core i3-6100 +Intel Core i3-6100T +Intel Core i3-6098P +Intel Core i3-4370 +Intel Core i3-4370T +Intel Core i3-4360 +Intel Core i3-4360T +Intel Core i3-4350 +Intel Core i3-4350T +Intel Core i3-4340 +Intel Core i3-4330 +Intel Core i3-4330T +Intel Core i3-4170 +Intel Core i3-4170T +Intel Core i3-4160 +Intel Core i3-4160T +Intel Core i3-4150 +Intel Core i3-4150T +Intel Core i3-4130 +Intel Core i3-4130T +Intel Core i3-3250 +Intel Core i3-3250T +Intel Core i3-3245 +Intel Core i3-3240 +Intel Core i3-3240T +Intel Core i3-3225 +Intel Core i3-3220 +Intel Core i3-3220T +Intel Core i3-3210 +Intel Core i3-2130 +Intel Core i3-2125 +Intel Core i3-2120 +Intel Core i3-2120T +Intel Core i3-2105 +Intel Core i3-2102 +Intel Core i3-2100 +Intel Core i3-2100T +AMD Ryzen 9 9950X3D2 Dual Edition +AMD Ryzen 9 PRO 9965X3D +AMD Ryzen 9 PRO 9965 +AMD Ryzen 9 9950X3D +AMD Ryzen 9 9950X +AMD Ryzen 9 PRO 9955 +AMD Ryzen 9 PRO 9945 +AMD Ryzen 9 9900X3D +AMD Ryzen 9 9900X +AMD Ryzen 9 7950X3D +AMD Ryzen 9 7950X +AMD Ryzen 9 PRO 7945 +AMD Ryzen 9 7900X3D +AMD Ryzen 9 7900X +AMD Ryzen 9 7900 +AMD Ryzen 9 5950X +AMD Ryzen 9 PRO 5945 +AMD Ryzen 9 5900XT +AMD Ryzen 9 5900X +AMD Ryzen 9 5900 +AMD Ryzen 9 3950X +AMD Ryzen 9 PRO 3900 +AMD Ryzen 9 3900XT +AMD Ryzen 9 3900X +AMD Ryzen 9 3900 +AMD Ryzen 7 9850X3D +AMD Ryzen 7 9800X3D +AMD Ryzen 7 PRO 9755X3D +AMD Ryzen 7 PRO 9755 +AMD Ryzen 7 PRO 9745 +AMD Ryzen 7 9700X +AMD Ryzen 7 9700F +AMD Ryzen 7 8700G +AMD Ryzen 7 PRO 8700G +AMD Ryzen 7 PRO 8700GE +AMD Ryzen 7 8700F +AMD Ryzen 7 7800X3D +AMD Ryzen 7 PRO 7745 +AMD Ryzen 7 7700X3D +AMD Ryzen 7 7700X +AMD Ryzen 7 7700 +AMD Ryzen 7 5800X3D +AMD Ryzen 7 5800XT +AMD Ryzen 7 5800X +AMD Ryzen 7 5800 +AMD Ryzen 7 PRO 5845 +AMD Ryzen 7 5755G +AMD Ryzen 7 5755GE +AMD Ryzen 7 5750G +AMD Ryzen 7 5750GE +AMD Ryzen 7 5705G +AMD Ryzen 7 5705GE +AMD Ryzen 7 5700G +AMD Ryzen 7 5700GE +AMD Ryzen 7 5700X3D +AMD Ryzen 7 5700X +AMD Ryzen 7 5700 +AMD Ryzen 7 4750G PRO +AMD Ryzen 7 4750GE PRO +AMD Ryzen 7 4700LE +AMD Ryzen 7 4700G +AMD Ryzen 7 4700GE +AMD Ryzen 7 3800XT +AMD Ryzen 7 3800X +AMD Ryzen 7 PRO 3700 +AMD Ryzen 7 3700X +AMD Ryzen 7 2700X +AMD Ryzen 7 2700 +AMD Ryzen 7 2700E +AMD Ryzen 7 1800X +AMD Ryzen 7 PRO 1700X +AMD Ryzen 7 1700X +AMD Ryzen 7 PRO 1700 +AMD Ryzen 7 1700 +AMD Ryzen 5 PRO 9655 +AMD Ryzen 5 PRO 9645 +AMD Ryzen 5 9600X +AMD Ryzen 5 9600 +AMD Ryzen 5 9500F +AMD Ryzen 5 8600G +AMD Ryzen 5 PRO 8600G +AMD Ryzen 5 PRO 8600GE +AMD Ryzen 5 8500G +AMD Ryzen 5 PRO 8500G +AMD Ryzen 5 8500GE +AMD Ryzen 5 PRO 8500GE +AMD Ryzen 5 8400F +AMD Ryzen 5 7600X3D +AMD Ryzen 5 7600X +AMD Ryzen 5 7600 +AMD Ryzen 5 PRO 7645 +AMD Ryzen 5 7500X3D +AMD Ryzen 5 7500 +AMD Ryzen 5 7500F +AMD Ryzen 5 7400 +AMD Ryzen 5 7400F +AMD Ryzen 5 5655G PRO +AMD Ryzen 5 5655GE PRO +AMD Ryzen 5 5650G PRO +AMD Ryzen 5 5650GE PRO +AMD Ryzen 5 PRO 5645 +AMD Ryzen 5 5605G +AMD Ryzen 5 5605GE +AMD Ryzen 5 5600X3D +AMD Ryzen 5 5600XT +AMD Ryzen 5 5600X +AMD Ryzen 5 5600T +AMD Ryzen 5 5600 +AMD Ryzen 5 5600F +AMD Ryzen 5 5600GT +AMD Ryzen 5 5600G +AMD Ryzen 5 5600GE +AMD Ryzen 5 5500X3D +AMD Ryzen 5 5500F +AMD Ryzen 5 5500GT +AMD Ryzen 5 5500 +AMD Ryzen 5 PRO 4655G +AMD Ryzen 5 PRO 4655GE +AMD Ryzen 5 PRO 4650G +AMD Ryzen 5 PRO 4650GE +AMD Ryzen 5 4600G +AMD Ryzen 5 4600GE +AMD Ryzen 5 4500 +AMD Ryzen 5 PRO 3600 +AMD Ryzen 5 3600XT +AMD Ryzen 5 3600X +AMD Ryzen 5 3600 +AMD Ryzen 5 3500X +AMD Ryzen 5 3500 +AMD Ryzen 5 PRO 3400G +AMD Ryzen 5 3400G +AMD Ryzen 5 PRO 3400GE +AMD Ryzen 5 PRO 3350G +AMD Ryzen 5 PRO 3350GE +AMD Ryzen 5 2600X +AMD Ryzen 5 2600 +AMD Ryzen 5 2600E +AMD Ryzen 5 2500X +AMD Ryzen 5 2400G +AMD Ryzen 5 2400GE +AMD Ryzen 5 PRO 2400G +AMD Ryzen 5 PRO 2400GE +AMD Ryzen 5 1600 AF +AMD Ryzen 5 1600X +AMD Ryzen 5 PRO 1600 +AMD Ryzen 5 1600 +AMD Ryzen 5 1500X +AMD Ryzen 5 PRO 1500 +AMD Ryzen 5 1400 +AMD Ryzen 3 8300G +AMD Ryzen 3 PRO 8300G +AMD Ryzen 3 8300GE +AMD Ryzen 3 PRO 8300GE +AMD Ryzen 3 PRO 5355G +AMD Ryzen 3 PRO 5355GE +AMD Ryzen 3 PRO 5350G +AMD Ryzen 3 PRO 5350GE +AMD Ryzen 3 5305G +AMD Ryzen 3 5305GE +AMD Ryzen 3 5300G +AMD Ryzen 3 5300GE +AMD Ryzen 3 5100 +AMD Ryzen 3 PRO 4355G +AMD Ryzen 3 PRO 4355GE +AMD Ryzen 3 PRO 4350G +AMD Ryzen 3 PRO 4350GE +AMD Ryzen 3 4300G +AMD Ryzen 3 4300GE +AMD Ryzen 3 4100 +AMD Ryzen 3 3300X +AMD Ryzen 3 PRO 3200G +AMD Ryzen 3 3200G +AMD Ryzen 3 3200GE +AMD Ryzen 3 PRO 3200GE +AMD Ryzen 3 3100 +AMD Ryzen 3 2300X +AMD Ryzen 3 2200G +AMD Ryzen 3 2200GE +AMD Ryzen 3 PRO 2200G +AMD Ryzen 3 PRO 2200GE +AMD Ryzen 3 PRO 2100GE +AMD Ryzen 3 1200 AF +AMD Ryzen 3 1300X +AMD Ryzen 3 PRO 1300 +AMD Ryzen 3 PRO 1200 +AMD Ryzen 3 1200 +AMD Ryzen AI 7 450G +AMD Ryzen AI 7 450GE +AMD Ryzen AI 7 PRO 450G +AMD Ryzen AI 7 PRO 450GE +AMD Ryzen AI 5 440G +AMD Ryzen AI 5 440GE +AMD Ryzen AI 5 PRO 440G +AMD Ryzen AI 5 PRO 440GE +AMD Ryzen AI 5 435G +AMD Ryzen AI 5 435GE +AMD Ryzen AI 5 PRO 435G +AMD Ryzen AI 5 PRO 435GE +AMD FX-9590 +AMD FX-9370 +AMD FX-8370 +AMD FX-8370E +AMD FX-8350 +AMD FX-8320 +AMD FX-8320E +AMD FX-8310 +AMD FX-8300 +AMD FX-8150 +AMD FX-8140 +AMD FX-8120 +AMD FX-8100 +AMD FX-6350 +AMD FX-6330 +AMD FX-6300 +AMD FX-6200 +AMD FX-6130 +AMD FX-6120 +AMD FX-6100 +AMD FX-4350 +AMD FX-4320 +AMD FX-4300 +AMD FX-4200 +AMD FX-4170 +AMD FX-4150 +AMD FX-4130 +AMD FX-4100 +AMD Ryzen Threadripper PRO 9995WX +AMD Ryzen Threadripper PRO 9985WX +AMD Ryzen Threadripper PRO 9975WX +AMD Ryzen Threadripper PRO 9965WX +AMD Ryzen Threadripper PRO 9955WX +AMD Ryzen Threadripper PRO 9945WX +AMD Ryzen Threadripper 9980X +AMD Ryzen Threadripper 9970X +AMD Ryzen Threadripper 9960X +AMD Ryzen Threadripper PRO 7995WX +AMD Ryzen Threadripper PRO 7985WX +AMD Ryzen Threadripper PRO 7975WX +AMD Ryzen Threadripper PRO 7965WX +AMD Ryzen Threadripper PRO 7955WX +AMD Ryzen Threadripper PRO 7945WX +AMD Ryzen Threadripper 7980X +AMD Ryzen Threadripper 7970X +AMD Ryzen Threadripper 7960X +AMD Ryzen Threadripper PRO 5995WX +AMD Ryzen Threadripper PRO 5975WX +AMD Ryzen Threadripper PRO 5965WX +AMD Ryzen Threadripper PRO 5955WX +AMD Ryzen Threadripper PRO 5945WX +AMD Ryzen Threadripper PRO 3995WX +AMD Ryzen Threadripper PRO 3975WX +AMD Ryzen Threadripper PRO 3955WX +AMD Ryzen Threadripper PRO 3945WX +AMD Ryzen Threadripper 3990X +AMD Ryzen Threadripper 3970X +AMD Ryzen Threadripper 3960X +AMD Ryzen Threadripper 2990WX +AMD Ryzen Threadripper 2970WX +AMD Ryzen Threadripper 2950X +AMD Ryzen Threadripper 2920X +AMD Ryzen Threadripper 1950X +AMD Ryzen Threadripper 1920X +AMD Ryzen Threadripper 1900X diff --git a/inventory/device_types.py b/inventory/device_types.py new file mode 100644 index 0000000..ba993e8 --- /dev/null +++ b/inventory/device_types.py @@ -0,0 +1,61 @@ +"""Hardware fields applicable to each of the built-in device types.""" + +COMPUTER_FIELDS = frozenset({ + "cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports", +}) +HARDWARE_FIELDS = COMPUTER_FIELDS | {"resolution", "print_technology", "duplex", "color_printing"} + +TYPE_FIELDS = { + "laptop": COMPUTER_FIELDS, + "aio": COMPUTER_FIELDS - {"battery"}, + "desktop": COMPUTER_FIELDS - {"battery", "display_inches"}, + "bildschirm": frozenset({"display_inches", "resolution", "ports"}), + "monitor": frozenset({"display_inches", "resolution", "ports"}), + "drucker": frozenset({"wifi", "lan", "ports", "print_technology", "duplex", "color_printing"}), + "printer": frozenset({"wifi", "lan", "ports", "print_technology", "duplex", "color_printing"}), + "all-in-one": COMPUTER_FIELDS - {"battery"}, + "all in one": COMPUTER_FIELDS - {"battery"}, +} + + +def applicable_fields(device_type): + """Unknown custom types retain all fields until configured explicitly.""" + if device_type is None: + return COMPUTER_FIELDS + return TYPE_FIELDS.get(device_type.label.strip().casefold(), COMPUTER_FIELDS) + + +def field_applies(device_type, field): + return field in applicable_fields(device_type) + + +def clear_inapplicable(device): + """Move incompatible legacy values into JSON before clearing active fields.""" + old_values = {} + legacy = dict(device.custom_values.get("legacy_hardware") or {}) + for name in HARDWARE_FIELDS - applicable_fields(device.device_type): + value = getattr(device, name) + if value not in (None, "", "UNKNOWN"): + old_values[name] = value + legacy[name] = str(value) + setattr(device, name, "UNKNOWN" if name in {"battery", "wifi", "lan", "duplex", "color_printing"} else "" if name in {"cpu", "hwid", "ports", "resolution", "print_technology"} else None) + if old_values: + device.custom_values = {**device.custom_values, "legacy_hardware": legacy} + return old_values + + +def bulk_create_specifications(devices): + """Create specification rows after the intentional bulk device insert.""" + from .models import HARDWARE_DEFAULTS, SPECIFICATION_MODELS, specification_model + + buckets = {model: [] for model in SPECIFICATION_MODELS} + for device in devices: + if device._hardware_cache is None: + device._hardware_cache = dict(HARDWARE_DEFAULTS) + model = specification_model(device.device_type) + buckets[model].append(model(device_id=device.pk, **{ + name: getattr(device, name) for name in model.hardware_field_names + })) + for model, specifications in buckets.items(): + if specifications: + model.objects.bulk_create(specifications, batch_size=1000) diff --git a/inventory/error_views.py b/inventory/error_views.py new file mode 100644 index 0000000..1f4d618 --- /dev/null +++ b/inventory/error_views.py @@ -0,0 +1,13 @@ +from django.shortcuts import render + + +def permission_denied(request, exception=None): + return render(request, "403.html", status=403) + + +def not_found(request, exception=None): + return render(request, "404.html", status=404) + + +def server_error(request): + return render(request, "500.html", status=500) diff --git a/inventory/forms.py b/inventory/forms.py new file mode 100644 index 0000000..a1a7688 --- /dev/null +++ b/inventory/forms.py @@ -0,0 +1,313 @@ +from django import forms +from copy import deepcopy +from django.db import models +from django.contrib.auth.models import User +from django.contrib.auth.password_validation import validate_password + +from .models import ChoiceValue, Device, Employee, FieldDefinition, FieldOption, UserProfile +from .device_types import HARDWARE_FIELDS, applicable_fields + + +GERMAN_DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"] +GERMAN_DATE_WIDGET = {"type": "text", "placeholder": "TT.MM.JJJJ", "inputmode": "numeric", "data-german-date": ""} +STATUS_COLOR_CHOICES = [ + ("#2563eb", "Blau"), ("#0891b2", "Türkis"), ("#15803d", "Grün"), + ("#4d7c0f", "Limette"), ("#b45309", "Orange"), ("#a16207", "Gelb"), + ("#dc2626", "Rot"), ("#be185d", "Pink"), ("#7c3aed", "Violett"), + ("#4f46e5", "Indigo"), ("#64748b", "Grau"), ("#334155", "Anthrazit"), +] + + +class QuickAddForm(forms.Form): + number = forms.IntegerField(label="Erste Nr.", min_value=1, max_value=2147483647, help_text="Die vorgeschlagene Startnummer kann geändert werden.") + quantity = forms.IntegerField(label="Anzahl", min_value=1, initial=1, help_text="Alle Geräte erhalten aufsteigende Nummern.") + shelf = forms.CharField(label="Regal", max_length=80, required=False) + status = forms.ModelChoiceField(label="Status", queryset=ChoiceValue.objects.none()) + date = forms.DateField( + label="Datum", input_formats=GERMAN_DATE_INPUT_FORMATS, + widget=forms.DateInput(format="%d.%m.%Y", attrs=GERMAN_DATE_WIDGET), + ) + device_type = forms.ModelChoiceField(label="Typ", queryset=ChoiceValue.objects.none(), required=False) + employee = forms.ModelChoiceField( + label="Bearbeiter", queryset=Employee.objects.none(), required=True, + empty_label="Bearbeiter auswählen", error_messages={"required": "Bitte einen Bearbeiter auswählen."}, + ) + donor = forms.CharField(label="Spender", max_length=200, required=False) + model = forms.CharField(label="Modell", max_length=200, required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["status"].queryset = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True) + self.fields["device_type"].queryset = ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True) + self.fields["employee"].queryset = Employee.objects.filter(active=True) + selected_type = None + if self.is_bound and str(self.data.get("device_type", "")).isdecimal(): + selected_type = self.fields["device_type"].queryset.filter(pk=self.data["device_type"]).first() + for name in sorted(HARDWARE_FIELDS): + field = deepcopy(DeviceForm.base_fields[name]) + if name in {"battery", "wifi", "lan", "duplex", "color_printing"}: + field.required = False + field.initial = Device.TriState.UNKNOWN + if self.is_bound and name not in applicable_fields(selected_type): + field.disabled = True + self.fields[name] = field + + @property + def hardware_fields(self): + return [self[name] for name in self.fields if name in HARDWARE_FIELDS] + + def selected_hardware_values(self): + values = {} + for name in applicable_fields(self.cleaned_data["device_type"]): + value = self.cleaned_data[name] + if name in {"battery", "wifi", "lan", "duplex", "color_printing"} and not value: + value = Device.TriState.UNKNOWN + values[name] = value + return values + + def clean_number(self): + number = self.cleaned_data["number"] + if Device.objects.filter(number=number).exists(): + raise forms.ValidationError(f"Die Gerätenummer {number} ist bereits vergeben.") + return number + + def clean(self): + cleaned = super().clean() + first_number = cleaned.get("number") + quantity = cleaned.get("quantity") + if first_number is None or quantity is None: + return cleaned + last_number = first_number + quantity - 1 + if last_number > 2147483647: + self.add_error("quantity", "Die letzte Gerätenummer wäre zu groß.") + return cleaned + conflict = Device.objects.filter(number__range=(first_number, last_number)).order_by("number").values_list("number", flat=True).first() + if conflict is not None: + self.add_error("number", f"Die Gerätenummer {conflict} ist bereits vergeben.") + return cleaned + + +class DeviceForm(forms.ModelForm): + battery = forms.ChoiceField(label="Akku", choices=Device.TriState.choices, required=False) + wifi = forms.ChoiceField(label="WLAN", choices=Device.TriState.choices, required=False) + lan = forms.ChoiceField(label="LAN", choices=Device.TriState.choices, required=False) + display_inches = forms.DecimalField(label="Zoll", max_digits=4, decimal_places=1, min_value=0, required=False, widget=forms.NumberInput(attrs={"step": "0.1"})) + ram_gb = forms.IntegerField(label="RAM (GB)", min_value=0, required=False) + ssd_gb = forms.IntegerField(label="SSD (GB)", min_value=0, required=False) + hdd_gb = forms.IntegerField(label="HDD (GB)", min_value=0, required=False) + cpu = forms.CharField(label="Prozessor", max_length=200, required=False) + hwid = forms.CharField(label="HWID", max_length=255, required=False) + ports = forms.CharField(label="Anschlüsse", max_length=255, required=False) + resolution = forms.CharField(label="Auflösung", max_length=80, required=False) + print_technology = forms.CharField(label="Druckverfahren", max_length=80, required=False) + duplex = forms.ChoiceField(label="Duplex", choices=Device.TriState.choices, required=False) + color_printing = forms.ChoiceField(label="Farbdruck", choices=Device.TriState.choices, required=False) + + class Meta: + model = Device + exclude = ["number", "archived_at", "archived_by", "custom_values", "stage"] + labels = { + "shelf": "Regal", "status": "Bestandsstatus", "date": "Datum", "recipient": "Empfänger", + "location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis", + "battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll", + "ram_gb": "RAM (GB)", "ssd_gb": "SSD (GB)", "hdd_gb": "HDD (GB)", + "cpu": "Prozessor", "hwid": "HWID", "model": "Modell", "employee": "Bearbeiter", + "donor": "Spender", "comment": "Kommentar", + } + widgets = { + "date": forms.DateInput(format="%d.%m.%Y", attrs=GERMAN_DATE_WIDGET), + "comment": forms.Textarea(attrs={"rows": 4}), + "price": forms.NumberInput(attrs={"step": "0.01"}), + "display_inches": forms.NumberInput(attrs={"step": "0.1"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.is_bound: + for name in HARDWARE_FIELDS: + self.initial.setdefault(name, getattr(self.instance, name)) + selected_type = self.instance.device_type if self.instance.device_type_id else None + if self.is_bound and str(self.data.get("device_type", "")).isdecimal(): + selected_type = ChoiceValue.objects.filter( + pk=self.data.get("device_type"), kind=ChoiceValue.Kind.DEVICE_TYPE, + ).first() + if self.is_bound: + for name in HARDWARE_FIELDS - applicable_fields(selected_type): + self.fields[name].disabled = True + self.fields["date"].input_formats = GERMAN_DATE_INPUT_FORMATS + mapping = { + "status": ChoiceValue.Kind.STATUS, + "recipient": ChoiceValue.Kind.RECIPIENT, + "location": ChoiceValue.Kind.LOCATION, + "device_type": ChoiceValue.Kind.DEVICE_TYPE, + } + for field_name, kind in mapping.items(): + current = getattr(self.instance, f"{field_name}_id", None) + queryset = ChoiceValue.objects.filter(kind=kind, active=True) + if current: + queryset = ChoiceValue.objects.filter(kind=kind).filter(models.Q(active=True) | models.Q(pk=current)) + self.fields[field_name].queryset = queryset + current_employee = getattr(self.instance, "employee_id", None) + employee_queryset = Employee.objects.filter(active=True) + if current_employee: + employee_queryset = Employee.objects.filter(models.Q(active=True) | models.Q(pk=current_employee)) + self.fields["employee"].queryset = employee_queryset + for field_name in ["ram_gb", "ssd_gb", "hdd_gb", "display_inches"]: + if field_name in self.fields: + self.fields[field_name].widget.attrs["list"] = f"suggestions-{field_name}" + if "cpu" in self.fields: + self.fields["cpu"].widget.attrs.update({"data-cpu-autocomplete": "", "autocomplete": "off"}) + + def save(self, commit=True): + device = super().save(commit=False) + for name in applicable_fields(device.device_type): + value = self.cleaned_data[name] + if name in {"battery", "wifi", "lan", "duplex", "color_printing"} and not value: + value = Device.TriState.UNKNOWN + setattr(device, name, value) + if commit: + device.save() + self.save_m2m() + return device + + +class InspectionForm(forms.Form): + defects = forms.CharField(label="Festgestellte Mängel", widget=forms.Textarea(attrs={"rows": 2}), max_length=4000) + model = forms.CharField(label="Modell", max_length=200, required=False) + + def __init__(self, *args, device, **kwargs): + super().__init__(*args, **kwargs) + self.device = device + self.initial.setdefault("model", device.model) + for name in ("cpu", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "hwid", "ports", "resolution", "print_technology", "duplex", "color_printing"): + if name in applicable_fields(device.device_type): + self.fields[name] = deepcopy(DeviceForm.base_fields[name]) + self.initial.setdefault(name, getattr(device, name)) + + @property + def specification_fields(self): + return [self[name] for name in self.fields if name != "defects"] + + +class RepairForm(forms.Form): + work_done = forms.CharField(label="Durchgeführte Arbeiten", max_length=4000, widget=forms.Textarea(attrs={"rows": 2})) + + +class StorageRecipientForm(forms.Form): + recipient = forms.CharField(label="Empfänger", max_length=120) + + +class EmployeeForm(forms.ModelForm): + class Meta: + model = Employee + fields = ["code", "name", "active"] + labels = {"code": "Kürzel", "name": "Name", "active": "Aktiv"} + help_texts = {"code": "Kurzes eindeutiges Kürzel, zum Beispiel JH."} + + +class ChoiceValueForm(forms.ModelForm): + color = forms.ChoiceField( + label="Farbe", choices=STATUS_COLOR_CHOICES, required=False, + widget=forms.RadioSelect, help_text="Wird für Statuswerte in Liste und Übersicht verwendet.", + ) + + class Meta: + model = ChoiceValue + fields = ["kind", "label", "color", "numeric_value", "active", "sort_order"] + labels = { + "kind": "Kategorie", "label": "Bezeichnung", "numeric_value": "Zahlenwert", + "active": "Aktiv", "sort_order": "Sortierreihenfolge", + } + + def clean(self): + cleaned = super().clean() + if cleaned.get("kind") == ChoiceValue.Kind.STATUS and not cleaned.get("color"): + cleaned["color"] = STATUS_COLOR_CHOICES[0][0] + return cleaned + + +class FieldDefinitionForm(forms.ModelForm): + options_text = forms.CharField( + required=False, label="Optionen", help_text="Eine Option pro Zeile (nur für Auswahlfelder).", + widget=forms.Textarea(attrs={"rows": 5}), + ) + + class Meta: + model = FieldDefinition + fields = [ + "name", "key", "field_type", "unit", "required", "active", "show_in_list", + "filterable", "searchable", "sort_order", "group", "allow_custom_values", + ] + labels = { + "name": "Name", "key": "Interner Schlüssel", "field_type": "Feldtyp", "unit": "Einheit", + "required": "Pflichtfeld", "active": "Aktiv", "show_in_list": "In der Geräteliste anzeigen", + "filterable": "Filterbar", "searchable": "Durchsuchbar", "sort_order": "Sortierreihenfolge", + "group": "Gruppe", "allow_custom_values": "Eigene Werte erlauben", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["options_text"].initial = "\n".join(self.instance.options.values_list("label", flat=True)) + self.fields["key"].disabled = True + self.fields["key"].help_text = "Der interne Key bleibt nach dem Anlegen stabil." + + def save(self, commit=True): + instance = super().save(commit=commit) + if commit: + labels = [line.strip() for line in self.cleaned_data.get("options_text", "").splitlines() if line.strip()] + for order, label in enumerate(labels): + FieldOption.objects.update_or_create(field=instance, label=label, defaults={"sort_order": order, "active": True}) + instance.options.exclude(label__in=labels).update(active=False) + return instance + + +class ExcelUploadForm(forms.Form): + file = forms.FileField(label="Excel-Datei (.xlsx)") + + def clean_file(self): + uploaded = self.cleaned_data["file"] + if not uploaded.name.lower().endswith(".xlsx"): + raise forms.ValidationError("Bitte eine .xlsx-Datei auswählen.") + if uploaded.size > 10 * 1024 * 1024: + raise forms.ValidationError("Die Datei darf höchstens 10 MB groß sein.") + return uploaded + + +class UserManagementForm(forms.ModelForm): + role = forms.ChoiceField(label="Rolle", choices=UserProfile.Role.choices) + password = forms.CharField(label="Passwort", required=False, widget=forms.PasswordInput, help_text="Bei bestehenden Konten leer lassen, um das Passwort nicht zu ändern.") + + class Meta: + model = User + fields = ["username", "first_name", "last_name", "email", "is_active"] + labels = { + "username": "Benutzername", "first_name": "Vorname", "last_name": "Nachname", + "email": "E-Mail-Adresse", "is_active": "Aktiv", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + profile, _ = UserProfile.objects.get_or_create(user=self.instance) + self.fields["role"].initial = profile.role + + def clean_password(self): + password = self.cleaned_data.get("password", "") + if not self.instance.pk and not password: + raise forms.ValidationError("Für ein neues Konto ist ein Passwort erforderlich.") + if password: + validate_password(password, self.instance if self.instance.pk else None) + return password + + def save(self, commit=True): + user = super().save(commit=False) + password = self.cleaned_data.get("password") + if password: + user.set_password(password) + if commit: + user.save() + profile, _ = UserProfile.objects.get_or_create(user=user) + profile.role = UserProfile.Role.ADMIN if user.is_superuser else self.cleaned_data["role"] + profile.save() + return user diff --git a/inventory/import_export.py b/inventory/import_export.py new file mode 100644 index 0000000..d74bf5a --- /dev/null +++ b/inventory/import_export.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import csv +import zipfile +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from io import BytesIO, StringIO +from typing import Any, Iterable +from xml.sax.saxutils import escape + +from django.db import transaction +from django.http import HttpResponse +from openpyxl import Workbook, load_workbook +from openpyxl.cell import WriteOnlyCell + +from .models import AuditLog, ChoiceValue, Device, Employee, NumberSequence +from .device_types import clear_inapplicable +from .services import add_audit + + +COLUMN_MAP = { + "nr.": "number", "nr": "number", "nummer": "number", "regal": "shelf", "status": "status", + "datum": "date", "empfänger": "recipient", "empfaenger": "recipient", "ort": "location", + "ticket": "ticket", "typ": "device_type", "preis": "price", "akku": "battery", "wlan": "wifi", + "lan": "lan", "zoll": "display_inches", "ram": "ram_gb", "ssd": "ssd_gb", "hdd": "hdd_gb", + "cpu": "cpu", "hwid": "hwid", "anschlüsse": "ports", "anschluesse": "ports", + "auflösung": "resolution", "aufloesung": "resolution", "druckverfahren": "print_technology", + "duplex": "duplex", "farbdruck": "color_printing", + "modell": "model", "bearbeiter": "employee", "spender": "donor", "kommentar": "comment", +} +EXPORT_COLUMNS = [ + ("number", "Nr."), ("shelf", "Regal"), ("status", "Status"), ("date", "Datum"), + ("recipient", "Empfänger"), ("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), + ("price", "Preis"), ("battery", "Akku"), ("wifi", "WLAN"), ("lan", "LAN"), + ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("hdd_gb", "HDD"), + ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"), ("resolution", "Auflösung"), + ("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"), + ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), + ("comment", "Kommentar"), +] + + +def normalize_header(value: Any) -> str: + return str(value or "").strip().lower() + + +def analyze_workbook(file_obj) -> tuple[list[str], list[list[str]]]: + workbook = load_workbook(file_obj, read_only=True, data_only=True) + sheet = workbook.active + rows = sheet.iter_rows(values_only=True) + headers = [str(value or "").strip() for value in next(rows, [])] + preview = [[format_cell(value) for value in row[: len(headers)]] for _, row in zip(range(10), rows)] + if not headers or not any(headers): + raise ValueError("Die Datei enthält keine Kopfzeile.") + return headers, preview + + +def suggested_mapping(headers: list[str]) -> dict[int, str]: + return {index: COLUMN_MAP.get(normalize_header(header), "") for index, header in enumerate(headers)} + + +def format_cell(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (date, datetime)): + return value.isoformat() + return str(value) + + +def parse_date(value: Any) -> date: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text = str(value).strip() + for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + raise ValueError(f"Ungültiges Datum: {text}") + + +def decimal_value(value: Any) -> Decimal | None: + if value in (None, ""): + return None + try: + parsed = Decimal(str(value).replace("€", "").replace(" ", "").replace(",", ".")) + if not parsed.is_finite(): + raise InvalidOperation + return parsed + except InvalidOperation as exc: + raise ValueError(f"Ungültige Zahl: {value}") from exc + + +def integer_value(value: Any) -> int | None: + decimal = decimal_value(value) + if decimal is not None and decimal != decimal.to_integral_value(): + raise ValueError(f"Eine ganze Zahl ist erforderlich: {value}") + return int(decimal) if decimal is not None else None + + +def tri_state(value: Any) -> str: + normalized = str(value or "").strip().upper() + return {"JA": Device.TriState.YES, "YES": Device.TriState.YES, "NEIN": Device.TriState.NO, "NO": Device.TriState.NO}.get(normalized, Device.TriState.UNKNOWN) + + +def resolve_choice(kind: str, value: Any, *, create: bool = True) -> ChoiceValue | None: + label = str(value or "").strip() + if not label: + return None + obj = ChoiceValue.objects.filter(kind=kind, label__iexact=label).first() + if obj: + return obj + if not create: + raise ValueError(f"Unbekannter Auswahlwert: {label}") + return ChoiceValue.objects.create(kind=kind, label=label, active=True) + + +@transaction.atomic +def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]: + workbook = load_workbook(file_obj, read_only=True, data_only=True) + sheet = workbook.active + next(sheet.iter_rows(values_only=True), None) + created = 0 + errors: list[str] = [] + maximum = 0 + for row_number, row in enumerate(sheet.iter_rows(values_only=True, min_row=2), start=2): + if not any(value not in (None, "") for value in row): + continue + data = {field: row[index] if index < len(row) else None for index, field in mapping.items() if field} + try: + number = integer_value(data.get("number")) + if number and Device.objects.filter(number=number).exists(): + raise ValueError(f"Gerätenummer {number} existiert bereits") + status = resolve_choice(ChoiceValue.Kind.STATUS, data.get("status") or "Eingang") + employee = None + employee_code = str(data.get("employee") or "").strip() + if employee_code: + employee = Employee.objects.filter(code__iexact=employee_code).first() + if not employee: + raise ValueError(f"Unbekanntes Mitarbeiterkürzel: {employee_code}") + device = Device( + number=number or 0, shelf=str(data.get("shelf") or "").strip(), status=status, + date=parse_date(data["date"]) if data.get("date") else date.today(), + recipient=resolve_choice(ChoiceValue.Kind.RECIPIENT, data.get("recipient")), + location=resolve_choice(ChoiceValue.Kind.LOCATION, data.get("location")), + ticket=str(data.get("ticket") or "").strip(), + device_type=resolve_choice(ChoiceValue.Kind.DEVICE_TYPE, data.get("device_type")), + price=decimal_value(data.get("price")), battery=tri_state(data.get("battery")), + wifi=tri_state(data.get("wifi")), lan=tri_state(data.get("lan")), + display_inches=decimal_value(data.get("display_inches")), ram_gb=integer_value(data.get("ram_gb")), + ssd_gb=integer_value(data.get("ssd_gb")), hdd_gb=integer_value(data.get("hdd_gb")), + cpu=str(data.get("cpu") or "").strip(), hwid=str(data.get("hwid") or "").strip(), + ports=str(data.get("ports") or "").strip(), resolution=str(data.get("resolution") or "").strip(), + print_technology=str(data.get("print_technology") or "").strip(), + duplex=tri_state(data.get("duplex")), color_printing=tri_state(data.get("color_printing")), + model=str(data.get("model") or "").strip(), + employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(), + ) + clear_inapplicable(device) + device.full_clean(exclude=["number", "custom_values"]) + device.save() + maximum = max(maximum, device.number) + add_audit(user, device, AuditLog.Action.IMPORT, new="Excel-Import") + created += 1 + except Exception as exc: + errors.append(f"Zeile {row_number}: {exc}") + if errors: + raise ValueError("\n".join(errors[:50])) + NumberSequence.ensure_at_least(maximum) + return {"created": created, "errors": []} + + +def export_value(device: Device, field: str) -> Any: + value = getattr(device, field) + if field in {"battery", "wifi", "lan", "duplex", "color_printing"}: + return dict(Device.TriState.choices).get(value, value) + if hasattr(value, "label"): + return value.label + if hasattr(value, "code"): + return value.code + return value if value is not None else "" + + +def csv_safe_value(value: Any) -> Any: + if isinstance(value, str) and (value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n"))): + return "'" + value + return value + + +def xlsx_safe_value(sheet, value: Any): + if isinstance(value, str): + cell = WriteOnlyCell(sheet, value=value) + cell.data_type = "s" + return cell + return value + + +def csv_response(devices: Iterable[Device]) -> HttpResponse: + response = HttpResponse(content_type="text/csv; charset=utf-8") + response["Content-Disposition"] = 'attachment; filename="geraete.csv"' + response.write("\ufeff") + writer = csv.writer(response, delimiter=";") + writer.writerow([label for _, label in EXPORT_COLUMNS]) + for device in devices.iterator(chunk_size=1000): + writer.writerow([csv_safe_value(export_value(device, field)) for field, _ in EXPORT_COLUMNS]) + return response + + +def xlsx_response(devices: Iterable[Device]) -> HttpResponse: + workbook = Workbook(write_only=True) + sheet = workbook.create_sheet("Geräte") + sheet.append([label for _, label in EXPORT_COLUMNS]) + for device in devices.iterator(chunk_size=1000): + sheet.append([xlsx_safe_value(sheet, export_value(device, field)) for field, _ in EXPORT_COLUMNS]) + output = BytesIO() + workbook.save(output) + response = HttpResponse(output.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + response["Content-Disposition"] = 'attachment; filename="geraete.xlsx"' + return response + + +def _ods_cell(value: Any, *, header: bool = False) -> str: + style = ' table:style-name="Kopfzeile"' if header else "" + if isinstance(value, datetime): + value = value.date() + if isinstance(value, date): + return f'{value.strftime("%d.%m.%Y")}' + if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool): + number = str(value) + return f'{escape(number)}' + text = escape(str(value if value is not None else "")) + return f'{text}' + + +def ods_response(devices: Iterable[Device]) -> HttpResponse: + content = StringIO() + content.write('') + content.write('') + content.write('') + content.write('') + content.write('') + for _, label in EXPORT_COLUMNS: + content.write(_ods_cell(label, header=True)) + content.write('') + for device in devices.iterator(chunk_size=1000): + content.write('') + for field, _ in EXPORT_COLUMNS: + content.write(_ods_cell(export_value(device, field))) + content.write('') + content.write('') + + mime_type = "application/vnd.oasis.opendocument.spreadsheet" + manifest = f''' + + + +''' + output = BytesIO() + with zipfile.ZipFile(output, "w") as archive: + archive.writestr("mimetype", mime_type, compress_type=zipfile.ZIP_STORED) + archive.writestr("content.xml", content.getvalue(), compress_type=zipfile.ZIP_DEFLATED) + archive.writestr("META-INF/manifest.xml", manifest, compress_type=zipfile.ZIP_DEFLATED) + response = HttpResponse(output.getvalue(), content_type=mime_type) + response["Content-Disposition"] = 'attachment; filename="geraete.ods"' + return response diff --git a/inventory/management/__init__.py b/inventory/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inventory/management/__init__.py @@ -0,0 +1 @@ + diff --git a/inventory/management/commands/__init__.py b/inventory/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inventory/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/inventory/management/commands/create_api_key.py b/inventory/management/commands/create_api_key.py new file mode 100644 index 0000000..3ae1fb3 --- /dev/null +++ b/inventory/management/commands/create_api_key.py @@ -0,0 +1,46 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from inventory.models import InventoryAPIKey, UserProfile +from inventory.permissions import can_edit + + +class Command(BaseCommand): + help = "Erstellt einen zeitlich begrenzten API-Schlüssel und zeigt ihn genau einmal an." + + def add_arguments(self, parser): + parser.add_argument("name", help="Eindeutiger Zweck, z. B. inventur-laptop-team2") + parser.add_argument("username", help="Aktives Django-Konto für die Audit-Zuordnung") + parser.add_argument("--write", action="store_true", help="Schreibzugriff erlauben") + parser.add_argument("--read-only", action="store_true", help="Nur Lesezugriff (Standard)") + parser.add_argument("--expires-days", type=int, default=90, help="Gültigkeit in Tagen (Standard: 90)") + + def handle(self, *args, **options): + if options["write"] and options["read_only"]: + raise CommandError("--write und --read-only können nicht gemeinsam verwendet werden.") + if options["expires_days"] < 1 or options["expires_days"] > 365: + raise CommandError("Die Gültigkeit muss zwischen 1 und 365 Tagen liegen.") + user_model = get_user_model() + try: + user = user_model.objects.get(username=options["username"], is_active=True) + except user_model.DoesNotExist as exc: + raise CommandError("Das angegebene aktive Konto wurde nicht gefunden.") from exc + if options["write"] and not can_edit(user): + raise CommandError( + f"Schreibzugriff erfordert die Rolle {UserProfile.Role.EDITOR} oder {UserProfile.Role.ADMIN}." + ) + api_key, secret = InventoryAPIKey.objects.create_key( + name=options["name"], + service_user=user, + can_read=True, + can_write=options["write"], + expiry_date=timezone.now() + timedelta(days=options["expires_days"]), + ) + self.stdout.write(self.style.SUCCESS(f"API-Schlüssel '{api_key.name}' wurde erstellt.")) + self.stdout.write(f"Präfix: {api_key.prefix}") + self.stdout.write(f"Läuft ab: {api_key.expiry_date.isoformat()}") + self.stdout.write(self.style.WARNING("Nur jetzt sichtbar – sicher speichern und niemals in Git einchecken:")) + self.stdout.write(secret) diff --git a/inventory/management/commands/generate_test_devices.py b/inventory/management/commands/generate_test_devices.py new file mode 100644 index 0000000..1311ede --- /dev/null +++ b/inventory/management/commands/generate_test_devices.py @@ -0,0 +1,53 @@ +import random +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.db.models import Max +from django.utils import timezone + +from inventory.models import ChoiceValue, Device, Employee, NumberSequence +from inventory.device_types import bulk_create_specifications, clear_inapplicable + + +MODELS = ["Lenovo ThinkPad T480", "Dell Latitude 7490", "HP EliteBook 840 G5", "Lenovo ThinkCentre M720", "Dell OptiPlex 7060", "HP ProDesk 600 G4", "Lenovo ThinkPad P50"] +CPUS = ["Intel Core i5-8250U", "Intel Core i5-8350U", "Intel Core i7-8650U", "Intel Core i5-6500", "Intel Core i7-6700", "AMD Ryzen 5 3500U"] + + +class Command(BaseCommand): + help = "Erzeugt realistische Testgeräte, z. B. generate_test_devices 5000." + + def add_arguments(self, parser): + parser.add_argument("count", type=int) + + @transaction.atomic + def handle(self, *args, **options): + count = options["count"] + if count < 1 or count > 100000: + raise CommandError("Anzahl muss zwischen 1 und 100000 liegen.") + statuses = list(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True)) + types = list(ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True)) + employees = list(Employee.objects.filter(active=True)) + if not statuses: + raise CommandError("Bitte zuerst seed_initial_data ausführen.") + start = (Device.objects.aggregate(value=Max("number"))["value"] or 0) + 1 + today = timezone.localdate() + devices = [] + for offset in range(count): + model = random.choice(MODELS) + device = Device( + number=start + offset, shelf=f"{random.choice('ABCDEFGH')}{random.randint(1, 30)}", + status=random.choice(statuses), device_type=random.choice(types) if types else None, + date=today - timedelta(days=random.randint(0, 1500)), model=model, cpu=random.choice(CPUS), + ram_gb=random.choice([4, 8, 8, 16, 32]), ssd_gb=random.choice([0, 128, 256, 512, 1000]), + hdd_gb=random.choice([0, 0, 500, 1000, 2000]), employee=random.choice(employees) if employees else None, + wifi=random.choice(Device.TriState.values), lan=random.choice(Device.TriState.values), + ) + clear_inapplicable(device) + device.custom_values = {} + devices.append(device) + for index in range(0, count, 1000): + saved = Device.objects.bulk_create(devices[index:index + 1000], batch_size=1000) + bulk_create_specifications(saved) + NumberSequence.ensure_at_least(start + count - 1) + self.stdout.write(self.style.SUCCESS(f"{count} Testgeräte angelegt.")) diff --git a/inventory/management/commands/import_sqlite_export.py b/inventory/management/commands/import_sqlite_export.py new file mode 100644 index 0000000..3d4cfcc --- /dev/null +++ b/inventory/management/commands/import_sqlite_export.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from django.apps import apps +from django.core.management import BaseCommand, CommandError, call_command +from django.core.serializers.base import DeserializationError +from django.db import DatabaseError, connection, transaction + +from inventory.models import ChoiceValue, Device, NumberSequence +from django.db.models import Max + + +class Command(BaseCommand): + help = "Import a SQLite export into an empty, migrated PostgreSQL database." + + def add_arguments(self, parser): + parser.add_argument("fixture", type=Path) + + def handle(self, *args, **options): + if connection.vendor != "postgresql": + raise CommandError("The destination must be PostgreSQL.") + fixture = options["fixture"].resolve() + if not fixture.is_file(): + raise CommandError("Export file does not exist.") + count = self.load_export(fixture, verbosity=options["verbosity"]) + self.stdout.write(self.style.SUCCESS(f"Imported {count} devices. SQLite source retained.")) + + @transaction.atomic + def load_export(self, fixture, *, verbosity=1): + # Migration-created content types and permissions are expected. Business + # data, users and groups must be empty: never merge or overwrite records. + models = [*apps.get_app_config("inventory").get_models(), + apps.get_model("auth", "User"), apps.get_model("auth", "Group")] + for model in models: + if model is ChoiceValue: + # Migration 0003 inserts these two rows even into a fresh DB. + baseline = {("device_type", "Bildschirm", True, 3, "", None), + ("device_type", "Drucker", True, 4, "", None)} + rows = set(model.objects.values_list("kind", "label", "active", "sort_order", "color", "numeric_value")) + if rows <= baseline: + continue + if model.objects.exists(): + raise CommandError(f"Destination is not empty ({model._meta.label}). No data imported.") + # Only the verified migration defaults can reach this point. Remove them + # within the import transaction so fixture IDs and labels cannot collide. + ChoiceValue.objects.all().delete() + try: + call_command("loaddata", str(fixture), verbosity=verbosity) + except (DeserializationError, DatabaseError) as exc: + raise CommandError("Invalid export or database constraint violation. No data imported.") from exc + NumberSequence.ensure_at_least(Device.objects.aggregate(value=Max("number"))["value"] or 0) + return Device.objects.count() diff --git a/inventory/management/commands/seed_initial_data.py b/inventory/management/commands/seed_initial_data.py new file mode 100644 index 0000000..c298104 --- /dev/null +++ b/inventory/management/commands/seed_initial_data.py @@ -0,0 +1,55 @@ +from decimal import Decimal + +from django.core.management.base import BaseCommand +from django.db import transaction + +from inventory.models import ChoiceValue, Employee, NumberSequence + + +CHOICES = { + ChoiceValue.Kind.STATUS: ["Eingang", "Entsorgt", "Fertig", "Defekt", "Reserviert", "Ausgegeben", "Intern"], + ChoiceValue.Kind.RECIPIENT: ["Landkreis", "Jugendamt", "Bürgergeld", "Rentner", "Geflüchtete", "NGO", "Stadt", "Sonstige", "Verein", "Schüler/Studenten"], + ChoiceValue.Kind.LOCATION: ["R", "L"], + ChoiceValue.Kind.DEVICE_TYPE: ["Laptop", "AiO", "Desktop", "Bildschirm", "Drucker"], +} +NUMERIC_CHOICES = { + ChoiceValue.Kind.RAM: [2, 4, 6, 8, 12, 16, 24, 32, 64, 128], + ChoiceValue.Kind.SSD: [0, 64, 120, 128, 240, 250, 256, 480, 500, 512, 960, 1000, 2000, 4000], + ChoiceValue.Kind.HDD: [0, 64, 120, 128, 240, 250, 256, 480, 500, 512, 960, 1000, 2000, 4000], + ChoiceValue.Kind.DISPLAY: [10.1, 11.6, 12.5, 13.3, 14, 15, 15.6, 16, 17, 17.3], +} +EMPLOYEES = { + "AC": "Ahmad Chaja", "A_H": "Ahmad Hakim", "HA": "Aaron Holmer", "JH": "Johannes Hundshammer", + "TS": "Thomas Stemberger", "YN": "Yurii Naumenko", "VL": "Viktor Lysenko", "SB": "Stefan Bauer", + "KA": "Kanani", "RS": "Ruth Stepper", "SK": "Name aktuell unbekannt", "RG": "Rainer Gerhardt", + "HN": "Heinrich", "KK": "Karsten Kopplin", "LL": "Lucas Lenninger", "AL": "Arseni Litvin", "GW": "Gregor Witczak", +} +STATUS_COLORS = { + "Eingang": "#2563eb", "Entsorgt": "#64748b", "Fertig": "#15803d", + "Defekt": "#dc2626", "Reserviert": "#b45309", "Ausgegeben": "#7c3aed", "Intern": "#0891b2", +} + + +class Command(BaseCommand): + help = "Legt die initialen Auswahlwerte und Mitarbeiter idempotent an." + + @transaction.atomic + def handle(self, *args, **options): + count = 0 + for kind, labels in CHOICES.items(): + for order, label in enumerate(labels): + defaults = {"active": True, "sort_order": order} + if kind == ChoiceValue.Kind.STATUS: + defaults["color"] = STATUS_COLORS.get(label, "#2563eb") + _, created = ChoiceValue.objects.get_or_create(kind=kind, label=label, defaults=defaults) + count += int(created) + for kind, values in NUMERIC_CHOICES.items(): + for order, value in enumerate(values): + label = f"{value:g}" + _, created = ChoiceValue.objects.get_or_create(kind=kind, label=label, defaults={"numeric_value": Decimal(str(value)), "active": True, "sort_order": order}) + count += int(created) + for code, name in EMPLOYEES.items(): + _, created = Employee.objects.get_or_create(code=code, defaults={"name": name, "active": True}) + count += int(created) + NumberSequence.objects.get_or_create(name="device") + self.stdout.write(self.style.SUCCESS(f"Seed-Daten bereit ({count} neue Datensätze).")) diff --git a/inventory/migrations/0001_initial.py b/inventory/migrations/0001_initial.py new file mode 100644 index 0000000..36024d7 --- /dev/null +++ b/inventory/migrations/0001_initial.py @@ -0,0 +1,233 @@ +# Generated by Django 5.2.17 on 2026-09-19 12:26 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Employee', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=12, unique=True)), + ('name', models.CharField(max_length=160)), + ('active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='FieldDefinition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120)), + ('key', models.SlugField(max_length=80, unique=True)), + ('field_type', models.CharField(choices=[('text', 'Text'), ('long_text', 'Langer Text'), ('integer', 'Ganzzahl'), ('decimal', 'Dezimalzahl'), ('boolean', 'Ja/Nein'), ('date', 'Datum'), ('select', 'Auswahl'), ('multi_select', 'Mehrfachauswahl')], max_length=20)), + ('unit', models.CharField(blank=True, max_length=30)), + ('required', models.BooleanField(default=False)), + ('active', models.BooleanField(db_index=True, default=True)), + ('show_in_list', models.BooleanField(default=False)), + ('filterable', models.BooleanField(default=False)), + ('searchable', models.BooleanField(default=False)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('group', models.CharField(default='Zusatzfelder', max_length=80)), + ('allow_custom_values', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['sort_order', 'name'], + }, + ), + migrations.CreateModel( + name='NumberSequence', + fields=[ + ('name', models.CharField(max_length=30, primary_key=True, serialize=False)), + ('last_value', models.PositiveIntegerField(default=0)), + ], + ), + migrations.CreateModel( + name='ChoiceValue', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('kind', models.CharField(choices=[('status', 'Status'), ('recipient', 'Empfänger'), ('location', 'Ort'), ('device_type', 'Typ'), ('ram', 'RAM-Vorschlag'), ('ssd', 'SSD-Vorschlag'), ('hdd', 'HDD-Vorschlag'), ('display', 'Displaygröße')], db_index=True, max_length=30)), + ('label', models.CharField(max_length=120)), + ('numeric_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('active', models.BooleanField(db_index=True, default=True)), + ('sort_order', models.PositiveIntegerField(default=0)), + ], + options={ + 'ordering': ['kind', 'sort_order', 'label'], + 'indexes': [models.Index(fields=['kind', 'active', 'sort_order'], name='inventory_c_kind_320c2f_idx')], + 'constraints': [models.UniqueConstraint(fields=('kind', 'label'), name='unique_choice_label_per_kind')], + }, + ), + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('number', models.PositiveIntegerField(db_index=True, editable=False, unique=True)), + ('shelf', models.CharField(blank=True, db_index=True, max_length=80)), + ('date', models.DateField(db_index=True, default=django.utils.timezone.localdate)), + ('ticket', models.CharField(blank=True, db_index=True, max_length=120)), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('battery', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)), + ('wifi', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)), + ('lan', models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10)), + ('display_inches', models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True)), + ('ram_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ('ssd_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ('hdd_gb', models.PositiveIntegerField(blank=True, db_index=True, null=True)), + ('cpu', models.CharField(blank=True, db_index=True, max_length=200)), + ('model', models.CharField(blank=True, db_index=True, max_length=200)), + ('donor', models.CharField(blank=True, max_length=200)), + ('comment', models.TextField(blank=True)), + ('custom_values', models.JSONField(blank=True, default=dict)), + ('archived_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_devices', to=settings.AUTH_USER_MODEL)), + ('device_type', models.ForeignKey(blank=True, limit_choices_to={'kind': 'device_type'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='type_devices', to='inventory.choicevalue')), + ('location', models.ForeignKey(blank=True, limit_choices_to={'kind': 'location'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='location_devices', to='inventory.choicevalue')), + ('recipient', models.ForeignKey(blank=True, limit_choices_to={'kind': 'recipient'}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='recipient_devices', to='inventory.choicevalue')), + ('status', models.ForeignKey(limit_choices_to={'kind': 'status'}, on_delete=django.db.models.deletion.PROTECT, related_name='status_devices', to='inventory.choicevalue')), + ('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='inventory.employee')), + ], + options={ + 'ordering': ['-number'], + }, + ), + migrations.CreateModel( + name='FieldOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('label', models.CharField(max_length=120)), + ('active', models.BooleanField(default=True)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='inventory.fielddefinition')), + ], + options={ + 'ordering': ['sort_order', 'label'], + }, + ), + migrations.CreateModel( + name='ImportJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('file', models.FileField(upload_to='imports/%Y/%m/')), + ('original_name', models.CharField(max_length=255)), + ('status', models.CharField(choices=[('UPLOADED', 'Hochgeladen'), ('COMPLETED', 'Abgeschlossen'), ('FAILED', 'Fehlgeschlagen')], default='UPLOADED', max_length=12)), + ('headers', models.JSONField(default=list)), + ('preview_rows', models.JSONField(default=list)), + ('result', models.JSONField(default=dict)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='import_jobs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SavedView', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120)), + ('filters', models.JSONField(default=dict)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_views', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserPreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('visible_columns', models.JSONField(blank=True, default=list)), + ('column_order', models.JSONField(blank=True, default=list)), + ('column_widths', models.JSONField(blank=True, default=dict)), + ('page_size', models.PositiveSmallIntegerField(default=50)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_preferences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('role', models.CharField(choices=[('ADMIN', 'Administrator'), ('EDITOR', 'Bearbeiter'), ('VIEWER', 'Betrachter')], default='VIEWER', max_length=10)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('CREATE', 'Erstellt'), ('UPDATE', 'Geändert'), ('ARCHIVE', 'Archiviert'), ('RESTORE', 'Wiederhergestellt'), ('IMPORT', 'Importiert')], max_length=10)), + ('field', models.CharField(blank=True, max_length=100)), + ('old_value', models.TextField(blank=True)), + ('new_value', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_entries', to=settings.AUTH_USER_MODEL)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_entries', to='inventory.device')), + ], + options={ + 'ordering': ['-created_at', '-id'], + 'indexes': [models.Index(fields=['device', '-created_at'], name='inventory_a_device__78ae29_idx')], + }, + ), + migrations.AddIndex( + model_name='device', + index=models.Index(fields=['archived_at', '-number'], name='inventory_d_archive_2483fe_idx'), + ), + migrations.AddIndex( + model_name='device', + index=models.Index(fields=['status', 'archived_at'], name='inventory_d_status__f41a2e_idx'), + ), + migrations.AddIndex( + model_name='device', + index=models.Index(fields=['device_type', 'archived_at'], name='inventory_d_device__ec71d5_idx'), + ), + migrations.AddIndex( + model_name='device', + index=models.Index(fields=['employee', 'archived_at'], name='inventory_d_employe_e23a79_idx'), + ), + migrations.AddIndex( + model_name='device', + index=models.Index(fields=['recipient', 'archived_at'], name='inventory_d_recipie_5290ea_idx'), + ), + migrations.AddConstraint( + model_name='fieldoption', + constraint=models.UniqueConstraint(fields=('field', 'label'), name='unique_custom_field_option'), + ), + migrations.AddConstraint( + model_name='savedview', + constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_saved_view_name'), + ), + ] diff --git a/inventory/migrations/0002_postgres_search_indexes.py b/inventory/migrations/0002_postgres_search_indexes.py new file mode 100644 index 0000000..759ecb6 --- /dev/null +++ b/inventory/migrations/0002_postgres_search_indexes.py @@ -0,0 +1,30 @@ +from django.db import migrations + + +def add_postgres_indexes(apps, schema_editor): + if schema_editor.connection.vendor != "postgresql": + return + statements = [ + "CREATE EXTENSION IF NOT EXISTS pg_trgm", + "CREATE INDEX IF NOT EXISTS inventory_device_cpu_trgm ON inventory_device USING gin (cpu gin_trgm_ops)", + "CREATE INDEX IF NOT EXISTS inventory_device_model_trgm ON inventory_device USING gin (model gin_trgm_ops)", + "CREATE INDEX IF NOT EXISTS inventory_device_ticket_trgm ON inventory_device USING gin (ticket gin_trgm_ops)", + "CREATE INDEX IF NOT EXISTS inventory_device_donor_trgm ON inventory_device USING gin (donor gin_trgm_ops)", + "CREATE INDEX IF NOT EXISTS inventory_device_custom_values_gin ON inventory_device USING gin (custom_values jsonb_path_ops)", + ] + with schema_editor.connection.cursor() as cursor: + for statement in statements: + cursor.execute(statement) + + +def remove_postgres_indexes(apps, schema_editor): + if schema_editor.connection.vendor != "postgresql": + return + with schema_editor.connection.cursor() as cursor: + for name in ["inventory_device_cpu_trgm", "inventory_device_model_trgm", "inventory_device_ticket_trgm", "inventory_device_donor_trgm", "inventory_device_custom_values_gin"]: + cursor.execute(f"DROP INDEX IF EXISTS {name}") + + +class Migration(migrations.Migration): + dependencies = [("inventory", "0001_initial")] + operations = [migrations.RunPython(add_postgres_indexes, remove_postgres_indexes)] diff --git a/inventory/migrations/0003_audit_revert_and_device_types.py b/inventory/migrations/0003_audit_revert_and_device_types.py new file mode 100644 index 0000000..ba9de82 --- /dev/null +++ b/inventory/migrations/0003_audit_revert_and_device_types.py @@ -0,0 +1,51 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def add_device_types(apps, schema_editor): + ChoiceValue = apps.get_model("inventory", "ChoiceValue") + for order, label in enumerate(("Bildschirm", "Drucker"), start=3): + ChoiceValue.objects.update_or_create( + kind="device_type", label=label, + defaults={"active": True, "sort_order": order}, + ) + + +def remove_device_types(apps, schema_editor): + ChoiceValue = apps.get_model("inventory", "ChoiceValue") + ChoiceValue.objects.filter(kind="device_type", label__in=("Bildschirm", "Drucker")).delete() + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("inventory", "0002_postgres_search_indexes"), + ] + + operations = [ + migrations.AddField( + model_name="auditlog", name="field_key", + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name="auditlog", name="old_data", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="auditlog", name="new_data", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="auditlog", name="reverted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="auditlog", name="reverted_by", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name="reverted_audit_entries", to=settings.AUTH_USER_MODEL, + ), + ), + migrations.RunPython(add_device_types, remove_device_types), + ] diff --git a/inventory/migrations/0004_choicevalue_color.py b/inventory/migrations/0004_choicevalue_color.py new file mode 100644 index 0000000..01dff44 --- /dev/null +++ b/inventory/migrations/0004_choicevalue_color.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +STATUS_COLORS = { + "Eingang": "#2563eb", "Entsorgt": "#64748b", "Fertig": "#15803d", + "Defekt": "#dc2626", "Reserviert": "#b45309", "Ausgegeben": "#7c3aed", "Intern": "#0891b2", +} + + +def set_status_colors(apps, schema_editor): + ChoiceValue = apps.get_model("inventory", "ChoiceValue") + for label, color in STATUS_COLORS.items(): + ChoiceValue.objects.filter(kind="status", label=label).update(color=color) + + +class Migration(migrations.Migration): + dependencies = [("inventory", "0003_audit_revert_and_device_types")] + operations = [ + migrations.AddField( + model_name="choicevalue", name="color", + field=models.CharField(blank=True, max_length=20), + ), + migrations.RunPython(set_status_colors, migrations.RunPython.noop), + ] diff --git a/inventory/migrations/0005_auditlog_api_client_device_hwid_inventoryapikey.py b/inventory/migrations/0005_auditlog_api_client_device_hwid_inventoryapikey.py new file mode 100644 index 0000000..4af5f4e --- /dev/null +++ b/inventory/migrations/0005_auditlog_api_client_device_hwid_inventoryapikey.py @@ -0,0 +1,50 @@ +# Generated by Django 5.2.17 on 2026-09-21 13:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_choicevalue_color'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='auditlog', + name='api_client', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='device', + name='hwid', + field=models.CharField(blank=True, db_index=True, max_length=255), + ), + migrations.CreateModel( + name='InventoryAPIKey', + fields=[ + ('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)), + ('prefix', models.CharField(editable=False, max_length=8, unique=True)), + ('hashed_key', models.CharField(editable=False, max_length=150)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)), + ('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')), + ('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')), + ('can_read', models.BooleanField(default=True)), + ('can_write', models.BooleanField(default=False)), + ('last_used_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('last_used_ip', models.GenericIPAddressField(blank=True, editable=False, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_inventory_api_keys', to=settings.AUTH_USER_MODEL)), + ('service_user', models.ForeignKey(help_text='Technisches oder persönliches Konto, dem API-Änderungen zugerechnet werden.', on_delete=django.db.models.deletion.PROTECT, related_name='inventory_api_keys', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'API-Schlüssel', + 'verbose_name_plural': 'API-Schlüssel', + 'ordering': ('-created',), + 'abstract': False, + }, + ), + ] diff --git a/inventory/migrations/0006_device_revision.py b/inventory/migrations/0006_device_revision.py new file mode 100644 index 0000000..56078bc --- /dev/null +++ b/inventory/migrations/0006_device_revision.py @@ -0,0 +1,16 @@ +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [("inventory", "0005_auditlog_api_client_device_hwid_inventoryapikey")] + operations = [ + migrations.AddField( + model_name="device", name="revision", + field=models.PositiveBigIntegerField(default=1, editable=False), + ), + migrations.AlterField( + model_name="device", name="updated_at", + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + ] diff --git a/inventory/migrations/0007_workflow_tasks.py b/inventory/migrations/0007_workflow_tasks.py new file mode 100644 index 0000000..959dd99 --- /dev/null +++ b/inventory/migrations/0007_workflow_tasks.py @@ -0,0 +1,67 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def initial_stages(apps, schema_editor): + Device = apps.get_model("inventory", "Device") + for device in Device.objects.select_related("status").iterator(): + label = device.status.label.casefold() + if label == "ausgegeben": + stage = "ISSUED" + elif label in {"fertig", "reserviert"}: + stage = "STORAGE" + elif label == "defekt": + stage = "REPAIR" + else: + stage = "INTAKE" + Device.objects.filter(pk=device.pk).update(stage=stage) + + +class Migration(migrations.Migration): + dependencies = [ + ("inventory", "0006_device_revision"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="device", name="stage", + field=models.CharField(max_length=12, choices=[ + ("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"), + ("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben"), + ], default="INTAKE", db_index=True), + ), + migrations.CreateModel( + name="DeviceTask", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("title", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("state", models.CharField(max_length=8, choices=[("OPEN", "Offen"), ("DONE", "Erledigt")], default="OPEN", db_index=True)), + ("completed_at", models.DateTimeField(null=True, blank=True)), + ("due_date", models.DateField(null=True, blank=True)), + ("assigned_to", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="assigned_device_tasks", null=True, blank=True)), + ("completed_by", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="completed_device_tasks", null=True, blank=True)), + ("created_by", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, related_name="created_device_tasks", null=True)), + ("device", models.ForeignKey(to="inventory.device", on_delete=django.db.models.deletion.CASCADE, related_name="tasks")), + ], + options={"ordering": ["state", "-created_at"]}, + ), + migrations.CreateModel( + name="WorkflowEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("from_stage", models.CharField(max_length=12, choices=[("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"), ("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben")], blank=True)), + ("to_stage", models.CharField(max_length=12, choices=[("INTAKE", "Eingang"), ("INSPECTION", "Inspektion"), ("REPAIR", "Reparatur"), ("STORAGE", "Lagerung"), ("ISSUED", "Ausgegeben")])), + ("note", models.CharField(max_length=500, blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("device", models.ForeignKey(to="inventory.device", on_delete=django.db.models.deletion.CASCADE, related_name="workflow_events")), + ("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.SET_NULL, null=True)), + ], + options={"ordering": ["-created_at", "-id"]}, + ), + migrations.RunPython(initial_stages, migrations.RunPython.noop), + ] diff --git a/inventory/migrations/0008_normalize_hardware_by_type.py b/inventory/migrations/0008_normalize_hardware_by_type.py new file mode 100644 index 0000000..a3666d0 --- /dev/null +++ b/inventory/migrations/0008_normalize_hardware_by_type.py @@ -0,0 +1,36 @@ +from django.db import migrations + + +COMPUTER = {"cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan"} +ALLOWED = { + "laptop": COMPUTER, + "aio": COMPUTER - {"battery"}, + "desktop": COMPUTER - {"battery", "display_inches"}, + "bildschirm": {"display_inches"}, + "drucker": {"wifi", "lan"}, +} + + +def normalize(apps, schema_editor): + Device = apps.get_model("inventory", "Device") + for device in Device.objects.select_related("device_type").iterator(): + if device.device_type is None: + continue + fields = COMPUTER - ALLOWED.get(device.device_type.label.strip().casefold(), COMPUTER) + if not fields: + continue + changes = {} + legacy = dict((device.custom_values or {}).get("legacy_hardware") or {}) + for field in fields: + value = getattr(device, field) + if value not in (None, "", "UNKNOWN"): + legacy[field] = str(value) + changes[field] = None if Device._meta.get_field(field).null else "UNKNOWN" if field in {"battery", "wifi", "lan"} else "" + if legacy: + changes["custom_values"] = {**(device.custom_values or {}), "legacy_hardware": legacy} + Device.objects.filter(pk=device.pk).update(**changes) + + +class Migration(migrations.Migration): + dependencies = [("inventory", "0007_workflow_tasks")] + operations = [migrations.RunPython(normalize, migrations.RunPython.noop)] diff --git a/inventory/migrations/0009_split_device_specifications.py b/inventory/migrations/0009_split_device_specifications.py new file mode 100644 index 0000000..c65b6fb --- /dev/null +++ b/inventory/migrations/0009_split_device_specifications.py @@ -0,0 +1,237 @@ +# Generated by Django 5.2.17 on 2026-09-26 10:54 + +import django.db.models.deletion +from django.db import migrations, models + + +def copy_to_specifications(apps, schema_editor): + Device = apps.get_model("inventory", "Device") + Computer = apps.get_model("inventory", "ComputerSpecification") + Monitor = apps.get_model("inventory", "MonitorSpecification") + Printer = apps.get_model("inventory", "PrinterSpecification") + for device in Device.objects.select_related("device_type").iterator(chunk_size=500): + label = device.device_type.label.strip().casefold() if device.device_type else "" + if label == "drucker": + Printer.objects.create(device_id=device.pk, wifi=device.wifi, lan=device.lan) + elif label == "bildschirm": + Monitor.objects.create(device_id=device.pk, display_inches=device.display_inches) + else: + Computer.objects.create( + device_id=device.pk, cpu=device.cpu, hwid=device.hwid, + ram_gb=device.ram_gb, ssd_gb=device.ssd_gb, hdd_gb=device.hdd_gb, + display_inches=device.display_inches, battery=device.battery, + wifi=device.wifi, lan=device.lan, + ) + + +def copy_back_to_devices(apps, schema_editor): + Device = apps.get_model("inventory", "Device") + Computer = apps.get_model("inventory", "ComputerSpecification") + Monitor = apps.get_model("inventory", "MonitorSpecification") + Printer = apps.get_model("inventory", "PrinterSpecification") + for device in Device.objects.select_related("device_type").iterator(chunk_size=500): + label = device.device_type.label.strip().casefold() if device.device_type else "" + if label == "drucker": + spec = Printer.objects.filter(device_id=device.pk).first() + elif label == "bildschirm": + spec = Monitor.objects.filter(device_id=device.pk).first() + else: + spec = Computer.objects.filter(device_id=device.pk).first() + if spec: + Device.objects.filter(pk=device.pk).update(**{ + name: getattr(spec, name) for name in ( + "cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", + ) if hasattr(spec, name) + }) + + +def computer_search_index(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + schema_editor.execute( + "CREATE INDEX IF NOT EXISTS inventory_computer_cpu_trgm " + "ON inventory_computerspecification USING gin (cpu gin_trgm_ops)" + ) + + +def drop_computer_search_index(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + schema_editor.execute("DROP INDEX IF EXISTS inventory_computer_cpu_trgm") + + +class Migration(migrations.Migration): + + dependencies = [ + ("inventory", "0008_normalize_hardware_by_type"), + ] + + operations = [ + migrations.CreateModel( + name="ComputerSpecification", + fields=[ + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="computer_specification", + serialize=False, + to="inventory.device", + ), + ), + ("cpu", models.CharField(blank=True, db_index=True, max_length=200)), + ("hwid", models.CharField(blank=True, db_index=True, max_length=255)), + ( + "ram_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "ssd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "hdd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "display_inches", + models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True + ), + ), + ( + "battery", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "wifi", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "lan", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ], + ), + migrations.CreateModel( + name="MonitorSpecification", + fields=[ + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="monitor_specification", + serialize=False, + to="inventory.device", + ), + ), + ( + "display_inches", + models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True + ), + ), + ], + ), + migrations.CreateModel( + name="PrinterSpecification", + fields=[ + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="printer_specification", + serialize=False, + to="inventory.device", + ), + ), + ( + "wifi", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "lan", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ], + ), + migrations.RunPython(copy_to_specifications, copy_back_to_devices), + migrations.RemoveField( + model_name="device", + name="battery", + ), + migrations.RemoveField( + model_name="device", + name="cpu", + ), + migrations.RemoveField( + model_name="device", + name="display_inches", + ), + migrations.RemoveField( + model_name="device", + name="hdd_gb", + ), + migrations.RemoveField( + model_name="device", + name="hwid", + ), + migrations.RemoveField( + model_name="device", + name="lan", + ), + migrations.RemoveField( + model_name="device", + name="ram_gb", + ), + migrations.RemoveField( + model_name="device", + name="ssd_gb", + ), + migrations.RemoveField( + model_name="device", + name="wifi", + ), + migrations.RunPython(computer_search_index, drop_computer_search_index), + ] diff --git a/inventory/migrations/0010_workflow_integrity.py b/inventory/migrations/0010_workflow_integrity.py new file mode 100644 index 0000000..90d4171 --- /dev/null +++ b/inventory/migrations/0010_workflow_integrity.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.17 on 2026-09-26 11:04 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("inventory", "0009_split_device_specifications"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddIndex( + model_name="devicetask", + index=models.Index( + fields=["state", "due_date"], name="inventory_d_state_bfd57d_idx" + ), + ), + migrations.AddIndex( + model_name="devicetask", + index=models.Index( + fields=["device", "state"], name="inventory_d_device__f5a9da_idx" + ), + ), + migrations.AddIndex( + model_name="workflowevent", + index=models.Index( + fields=["device", "-created_at"], name="inventory_w_device__f6b3a5_idx" + ), + ), + migrations.AddConstraint( + model_name="device", + constraint=models.CheckConstraint( + condition=models.Q( + ( + "stage__in", + ["INTAKE", "INSPECTION", "REPAIR", "STORAGE", "ISSUED"], + ) + ), + name="valid_device_stage", + ), + ), + migrations.AddConstraint( + model_name="devicetask", + constraint=models.CheckConstraint( + condition=models.Q(("state__in", ["OPEN", "DONE"])), + name="valid_device_task_state", + ), + ), + migrations.AddConstraint( + model_name="devicetask", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("completed_at__isnull", True), ("state", "OPEN")), + models.Q(("completed_at__isnull", False), ("state", "DONE")), + _connector="OR", + ), + name="device_task_completion_matches_state", + ), + ), + ] diff --git a/inventory/migrations/0011_inspectionrecord_repairrecord.py b/inventory/migrations/0011_inspectionrecord_repairrecord.py new file mode 100644 index 0000000..26d4932 --- /dev/null +++ b/inventory/migrations/0011_inspectionrecord_repairrecord.py @@ -0,0 +1,98 @@ +# Generated by Django 5.2.17 on 2026-09-26 12:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("inventory", "0010_workflow_integrity"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="InspectionRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("defects", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="inspections", + to="inventory.device", + ), + ), + ( + "recorded_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "indexes": [ + models.Index( + fields=["device", "-created_at"], + name="inventory_i_device__4a3af8_idx", + ) + ], + }, + ), + migrations.CreateModel( + name="RepairRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("work_done", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "device", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="repairs", + to="inventory.device", + ), + ), + ( + "recorded_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at", "-id"], + "indexes": [ + models.Index( + fields=["device", "-created_at"], + name="inventory_r_device__58bce5_idx", + ) + ], + }, + ), + ] diff --git a/inventory/migrations/0012_allinonespecification_desktopspecification_and_more.py b/inventory/migrations/0012_allinonespecification_desktopspecification_and_more.py new file mode 100644 index 0000000..5d23d6b --- /dev/null +++ b/inventory/migrations/0012_allinonespecification_desktopspecification_and_more.py @@ -0,0 +1,302 @@ +# Generated by Django 5.2.17 on 2026-09-28 19:27 + +import django.db.models.deletion +from django.db import migrations, models + + +TYPE_TABLES = { + "laptop": "LaptopSpecification", + "desktop": "DesktopSpecification", + "aio": "AllInOneSpecification", + "all-in-one": "AllInOneSpecification", + "all in one": "AllInOneSpecification", +} + + +def move_computer_rows(apps, schema_editor): + Computer = apps.get_model("inventory", "ComputerSpecification") + targets = {name: apps.get_model("inventory", name) for name in set(TYPE_TABLES.values())} + for source in Computer.objects.select_related("device__device_type").iterator(chunk_size=500): + label = source.device.device_type.label.strip().casefold() if source.device.device_type else "" + target_name = TYPE_TABLES.get(label) + if not target_name: + continue + target = targets[target_name] + fields = [field.name for field in target._meta.fields if field.name != "device"] + target.objects.create(device_id=source.device_id, **{name: getattr(source, name) for name in fields}) + source.delete() + + +def restore_computer_rows(apps, schema_editor): + Computer = apps.get_model("inventory", "ComputerSpecification") + for name in set(TYPE_TABLES.values()): + target = apps.get_model("inventory", name) + for source in target.objects.iterator(chunk_size=500): + fields = [field.name for field in Computer._meta.fields if field.name != "device"] + Computer.objects.create(device_id=source.device_id, **{ + field: getattr(source, field) for field in fields if hasattr(source, field) + }) + + +def create_cpu_indexes(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + for table, name in (("inventory_laptopspecification", "inventory_laptop_cpu_trgm"), + ("inventory_desktopspecification", "inventory_desktop_cpu_trgm"), + ("inventory_allinonespecification", "inventory_aio_cpu_trgm")): + schema_editor.execute(f"CREATE INDEX {name} ON {table} USING gin (cpu gin_trgm_ops)") + + +def drop_cpu_indexes(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + for name in ("inventory_laptop_cpu_trgm", "inventory_desktop_cpu_trgm", "inventory_aio_cpu_trgm"): + schema_editor.execute(f"DROP INDEX IF EXISTS {name}") + + +class Migration(migrations.Migration): + + dependencies = [ + ("inventory", "0011_inspectionrecord_repairrecord"), + ] + + operations = [ + migrations.CreateModel( + name="AllInOneSpecification", + fields=[ + ("cpu", models.CharField(blank=True, db_index=True, max_length=200)), + ("hwid", models.CharField(blank=True, db_index=True, max_length=255)), + ( + "ram_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "ssd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "hdd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "wifi", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "lan", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ("ports", models.CharField(blank=True, max_length=255)), + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="aio_specification", + serialize=False, + to="inventory.device", + ), + ), + ( + "display_inches", + models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="DesktopSpecification", + fields=[ + ("cpu", models.CharField(blank=True, db_index=True, max_length=200)), + ("hwid", models.CharField(blank=True, db_index=True, max_length=255)), + ( + "ram_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "ssd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "hdd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "wifi", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "lan", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ("ports", models.CharField(blank=True, max_length=255)), + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="desktop_specification", + serialize=False, + to="inventory.device", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LaptopSpecification", + fields=[ + ("cpu", models.CharField(blank=True, db_index=True, max_length=200)), + ("hwid", models.CharField(blank=True, db_index=True, max_length=255)), + ( + "ram_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "ssd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "hdd_gb", + models.PositiveIntegerField(blank=True, db_index=True, null=True), + ), + ( + "wifi", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ( + "lan", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ("ports", models.CharField(blank=True, max_length=255)), + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="laptop_specification", + serialize=False, + to="inventory.device", + ), + ), + ( + "display_inches", + models.DecimalField( + blank=True, decimal_places=1, max_digits=4, null=True + ), + ), + ( + "battery", + models.CharField( + choices=[ + ("YES", "JA"), + ("NO", "NEIN"), + ("UNKNOWN", "UNBEKANNT"), + ], + default="UNKNOWN", + max_length=10, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="computerspecification", + name="ports", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="monitorspecification", + name="ports", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="monitorspecification", + name="resolution", + field=models.CharField(blank=True, max_length=80), + ), + migrations.AddField( + model_name="printerspecification", + name="color_printing", + field=models.CharField( + choices=[("YES", "JA"), ("NO", "NEIN"), ("UNKNOWN", "UNBEKANNT")], + default="UNKNOWN", + max_length=10, + ), + ), + migrations.AddField( + model_name="printerspecification", + name="duplex", + field=models.CharField( + choices=[("YES", "JA"), ("NO", "NEIN"), ("UNKNOWN", "UNBEKANNT")], + default="UNKNOWN", + max_length=10, + ), + ), + migrations.AddField( + model_name="printerspecification", + name="ports", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="printerspecification", + name="print_technology", + field=models.CharField(blank=True, max_length=80), + ), + migrations.RunPython(move_computer_rows, restore_computer_rows), + migrations.RunPython(create_cpu_indexes, drop_cpu_indexes), + ] diff --git a/inventory/migrations/__init__.py b/inventory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory/models.py b/inventory/models.py new file mode 100644 index 0000000..847c53f --- /dev/null +++ b/inventory/models.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.db.models import Max +from django.utils import timezone +from rest_framework_api_key.models import AbstractAPIKey, BaseAPIKeyManager + + +class TimeStampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class UserProfile(TimeStampedModel): + class Role(models.TextChoices): + ADMIN = "ADMIN", "Administrator" + EDITOR = "EDITOR", "Bearbeiter" + VIEWER = "VIEWER", "Betrachter" + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") + role = models.CharField(max_length=10, choices=Role.choices, default=Role.VIEWER) + + def __str__(self) -> str: + return f"{self.user.username} ({self.get_role_display()})" + + +class ChoiceValue(TimeStampedModel): + class Kind(models.TextChoices): + STATUS = "status", "Status" + RECIPIENT = "recipient", "Empfänger" + LOCATION = "location", "Ort" + DEVICE_TYPE = "device_type", "Typ" + RAM = "ram", "RAM-Vorschlag" + SSD = "ssd", "SSD-Vorschlag" + HDD = "hdd", "HDD-Vorschlag" + DISPLAY = "display", "Displaygröße" + + kind = models.CharField(max_length=30, choices=Kind.choices, db_index=True) + label = models.CharField(max_length=120) + color = models.CharField(max_length=20, blank=True) + numeric_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + active = models.BooleanField(default=True, db_index=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["kind", "sort_order", "label"] + constraints = [models.UniqueConstraint(fields=["kind", "label"], name="unique_choice_label_per_kind")] + indexes = [models.Index(fields=["kind", "active", "sort_order"])] + + def __str__(self) -> str: + return self.label + + +class Employee(TimeStampedModel): + code = models.CharField(max_length=12, unique=True) + name = models.CharField(max_length=160) + active = models.BooleanField(default=True, db_index=True) + + class Meta: + ordering = ["code"] + + def __str__(self) -> str: + return f"{self.code} – {self.name}" + + +class FieldDefinition(TimeStampedModel): + class FieldType(models.TextChoices): + TEXT = "text", "Text" + LONG_TEXT = "long_text", "Langer Text" + INTEGER = "integer", "Ganzzahl" + DECIMAL = "decimal", "Dezimalzahl" + BOOLEAN = "boolean", "Ja/Nein" + DATE = "date", "Datum" + SELECT = "select", "Auswahl" + MULTI_SELECT = "multi_select", "Mehrfachauswahl" + + name = models.CharField(max_length=120) + key = models.SlugField(max_length=80, unique=True) + field_type = models.CharField(max_length=20, choices=FieldType.choices) + unit = models.CharField(max_length=30, blank=True) + required = models.BooleanField(default=False) + active = models.BooleanField(default=True, db_index=True) + show_in_list = models.BooleanField(default=False) + filterable = models.BooleanField(default=False) + searchable = models.BooleanField(default=False) + sort_order = models.PositiveIntegerField(default=0) + group = models.CharField(max_length=80, default="Zusatzfelder") + allow_custom_values = models.BooleanField(default=False) + + class Meta: + ordering = ["sort_order", "name"] + + def __str__(self) -> str: + return self.name + + +class FieldOption(TimeStampedModel): + field = models.ForeignKey(FieldDefinition, on_delete=models.CASCADE, related_name="options") + label = models.CharField(max_length=120) + active = models.BooleanField(default=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "label"] + constraints = [models.UniqueConstraint(fields=["field", "label"], name="unique_custom_field_option")] + + def __str__(self) -> str: + return self.label + + +class NumberSequence(models.Model): + name = models.CharField(max_length=30, primary_key=True) + last_value = models.PositiveIntegerField(default=0) + + @classmethod + def next_device_number(cls) -> int: + with transaction.atomic(): + sequence, _ = cls.objects.select_for_update().get_or_create(name="device") + maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0 + sequence.last_value = max(sequence.last_value, maximum) + 1 + sequence.save(update_fields=["last_value"]) + return sequence.last_value + + @classmethod + def suggested_device_number(cls) -> int: + sequence_value = cls.objects.filter(name="device").values_list("last_value", flat=True).first() or 0 + maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0 + return max(sequence_value, maximum) + 1 + + @classmethod + def ensure_at_least(cls, value: int) -> None: + with transaction.atomic(): + sequence, _ = cls.objects.select_for_update().get_or_create(name="device") + if sequence.last_value < value: + sequence.last_value = value + sequence.save(update_fields=["last_value"]) + + +HARDWARE_DEFAULTS = { + "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", + "display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None, + "cpu": "", "hwid": "", "ports": "", "resolution": "", + "print_technology": "", "duplex": "UNKNOWN", "color_printing": "UNKNOWN", +} + + +class HardwareValue: + """Compatibility attribute backed by one type-specific specification row.""" + + def __init__(self, name): + self.name = name + + def __get__(self, instance, owner): + if instance is None: + return self + instance._load_hardware() + return instance._hardware_cache[self.name] + + def __set__(self, instance, value): + instance._load_hardware() + instance._hardware_cache[self.name] = value + instance._hardware_dirty = True + + +class Device(TimeStampedModel): + # A revision is independent of the resolution of the operating system clock. + revision = models.PositiveBigIntegerField(default=1, editable=False) + updated_at = models.DateTimeField(default=timezone.now, editable=False) + + class TriState(models.TextChoices): + YES = "YES", "JA" + NO = "NO", "NEIN" + UNKNOWN = "UNKNOWN", "UNBEKANNT" + + class Stage(models.TextChoices): + INTAKE = "INTAKE", "Eingang" + INSPECTION = "INSPECTION", "Inspektion" + REPAIR = "REPAIR", "Reparatur" + STORAGE = "STORAGE", "Lagerung" + ISSUED = "ISSUED", "Ausgegeben" + + stage = models.CharField(max_length=12, choices=Stage.choices, default=Stage.INTAKE, db_index=True) + + number = models.PositiveIntegerField(unique=True, db_index=True, editable=False) + shelf = models.CharField(max_length=80, blank=True, db_index=True) + status = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, related_name="status_devices", limit_choices_to={"kind": ChoiceValue.Kind.STATUS}) + date = models.DateField(default=timezone.localdate, db_index=True) + recipient = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="recipient_devices", limit_choices_to={"kind": ChoiceValue.Kind.RECIPIENT}) + location = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="location_devices", limit_choices_to={"kind": ChoiceValue.Kind.LOCATION}) + ticket = models.CharField(max_length=120, blank=True, db_index=True) + device_type = models.ForeignKey(ChoiceValue, on_delete=models.PROTECT, null=True, blank=True, related_name="type_devices", limit_choices_to={"kind": ChoiceValue.Kind.DEVICE_TYPE}) + price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + battery = HardwareValue("battery") + wifi = HardwareValue("wifi") + lan = HardwareValue("lan") + display_inches = HardwareValue("display_inches") + ram_gb = HardwareValue("ram_gb") + ssd_gb = HardwareValue("ssd_gb") + hdd_gb = HardwareValue("hdd_gb") + cpu = HardwareValue("cpu") + hwid = HardwareValue("hwid") + ports = HardwareValue("ports") + resolution = HardwareValue("resolution") + print_technology = HardwareValue("print_technology") + duplex = HardwareValue("duplex") + color_printing = HardwareValue("color_printing") + model = models.CharField(max_length=200, blank=True, db_index=True) + employee = models.ForeignKey(Employee, on_delete=models.PROTECT, null=True, blank=True, related_name="devices") + donor = models.CharField(max_length=200, blank=True) + comment = models.TextField(blank=True) + custom_values = models.JSONField(default=dict, blank=True) + archived_at = models.DateTimeField(null=True, blank=True, db_index=True) + archived_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="archived_devices") + + class Meta: + ordering = ["-number"] + constraints = [models.CheckConstraint(condition=models.Q(stage__in=["INTAKE", "INSPECTION", "REPAIR", "STORAGE", "ISSUED"]), name="valid_device_stage")] + indexes = [ + models.Index(fields=["archived_at", "-number"]), + models.Index(fields=["status", "archived_at"]), + models.Index(fields=["device_type", "archived_at"]), + models.Index(fields=["employee", "archived_at"]), + models.Index(fields=["recipient", "archived_at"]), + ] + + def __init__(self, *args, **kwargs): + hardware = {name: kwargs.pop(name) for name in HARDWARE_DEFAULTS if name in kwargs} + super().__init__(*args, **kwargs) + self._loaded_device_type_id = self.device_type_id + self._hardware_cache = {**HARDWARE_DEFAULTS, **hardware} if hardware else None + self._hardware_dirty = bool(hardware) + + def _load_hardware(self): + if self._hardware_cache is not None: + return + values = dict(HARDWARE_DEFAULTS) + if self.pk and not self._state.adding: + old_type = self.device_type if self._loaded_device_type_id == self.device_type_id else ( + ChoiceValue.objects.filter(pk=self._loaded_device_type_id).first() if self._loaded_device_type_id else None + ) + model = specification_model(old_type) + specification = getattr(self, model._meta.get_field("device").remote_field.related_name, None) + if specification: + for name in model.hardware_field_names: + values[name] = getattr(specification, name) + self._hardware_cache = values + + def _save_hardware(self): + model = specification_model(self.device_type) + for other in SPECIFICATION_MODELS: + if other is not model: + other.objects.filter(device_id=self.pk).delete() + defaults = {name: self._hardware_cache[name] for name in model.hardware_field_names} + specification, _ = model.objects.update_or_create(device=self, defaults=defaults) + specification.full_clean() + self._hardware_dirty = False + self._loaded_device_type_id = self.device_type_id + + @transaction.atomic + def save(self, *args, **kwargs) -> None: + creating = self._state.adding + type_changed = not creating and self.device_type_id != self._loaded_device_type_id + if creating or type_changed or self._hardware_dirty: + self._load_hardware() + if creating or type_changed: + from .device_types import clear_inapplicable + cleared = clear_inapplicable(self) + if cleared and kwargs.get("update_fields") is not None: + kwargs["update_fields"] = set(kwargs["update_fields"]) | {"custom_values"} + if kwargs.get("update_fields") is not None: + kwargs["update_fields"] = set(kwargs["update_fields"]) - HARDWARE_DEFAULTS.keys() + if self._hardware_dirty and not kwargs["update_fields"]: + kwargs["update_fields"].add("updated_at") + if not creating: + previous = Device.objects.select_for_update().get(pk=self.pk) + self.revision = previous.revision + 1 + # Keep legacy API timestamp tokens unique, including on coarse clocks. + self.updated_at = max(timezone.now(), previous.updated_at + timedelta(microseconds=1)) + if kwargs.get("update_fields") is not None: + if not kwargs["update_fields"]: + return + kwargs["update_fields"] = set(kwargs["update_fields"]) | {"revision", "updated_at"} + if not self.number: + self.number = NumberSequence.next_device_number() + super().save(*args, **kwargs) + if creating: + NumberSequence.ensure_at_least(self.number) + if creating or type_changed or self._hardware_dirty: + self._save_hardware() + + def refresh_from_db(self, *args, **kwargs): + super().refresh_from_db(*args, **kwargs) + self._loaded_device_type_id = self.device_type_id + self._hardware_cache = None + self._hardware_dirty = False + + def __str__(self) -> str: + return f"Gerät #{self.number}" + + @property + def hardware_fields(self): + from .device_types import applicable_fields + return applicable_fields(self.device_type) + + def get_battery_display(self): + return self.TriState(self.battery).label + + def get_wifi_display(self): + return self.TriState(self.wifi).label + + def get_lan_display(self): + return self.TriState(self.lan).label + + +class ComputerSpecification(models.Model): + hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="computer_specification", primary_key=True) + cpu = models.CharField(max_length=200, blank=True, db_index=True) + hwid = models.CharField(max_length=255, blank=True, db_index=True) + ram_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + ssd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + hdd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + battery = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + ports = models.CharField(max_length=255, blank=True) + + +class ComputerCore(models.Model): + cpu = models.CharField(max_length=200, blank=True, db_index=True) + hwid = models.CharField(max_length=255, blank=True, db_index=True) + ram_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + ssd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + hdd_gb = models.PositiveIntegerField(null=True, blank=True, db_index=True) + wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + ports = models.CharField(max_length=255, blank=True) + + class Meta: + abstract = True + + +class LaptopSpecification(ComputerCore): + hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="laptop_specification", primary_key=True) + display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + battery = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + + +class DesktopSpecification(ComputerCore): + hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "wifi", "lan", "ports") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="desktop_specification", primary_key=True) + + +class AllInOneSpecification(ComputerCore): + hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="aio_specification", primary_key=True) + display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + + +class MonitorSpecification(models.Model): + hardware_field_names = ("display_inches", "resolution", "ports") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="monitor_specification", primary_key=True) + display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True) + resolution = models.CharField(max_length=80, blank=True) + ports = models.CharField(max_length=255, blank=True) + + +class PrinterSpecification(models.Model): + hardware_field_names = ("wifi", "lan", "ports", "print_technology", "duplex", "color_printing") + device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="printer_specification", primary_key=True) + wifi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + lan = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + ports = models.CharField(max_length=255, blank=True) + print_technology = models.CharField(max_length=80, blank=True) + duplex = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + color_printing = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN) + + +SPECIFICATION_MODELS = (LaptopSpecification, DesktopSpecification, AllInOneSpecification, + MonitorSpecification, PrinterSpecification, ComputerSpecification) + + +def specification_model(device_type): + label = device_type.label.strip().casefold() if device_type else "" + if label in {"drucker", "printer"}: + return PrinterSpecification + if label in {"bildschirm", "monitor"}: + return MonitorSpecification + if label == "laptop": + return LaptopSpecification + if label == "desktop": + return DesktopSpecification + if label in {"aio", "all-in-one", "all in one"}: + return AllInOneSpecification + return ComputerSpecification + + +class DeviceTask(TimeStampedModel): + class State(models.TextChoices): + OPEN = "OPEN", "Offen" + DONE = "DONE", "Erledigt" + + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="tasks") + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + state = models.CharField(max_length=8, choices=State.choices, default=State.OPEN, db_index=True) + assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_device_tasks") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="created_device_tasks") + completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="completed_device_tasks") + completed_at = models.DateTimeField(null=True, blank=True) + due_date = models.DateField(null=True, blank=True) + + class Meta: + ordering = ["state", "-created_at"] + constraints = [ + models.CheckConstraint(condition=models.Q(state__in=["OPEN", "DONE"]), name="valid_device_task_state"), + models.CheckConstraint( + condition=models.Q(state="OPEN", completed_at__isnull=True) | models.Q(state="DONE", completed_at__isnull=False), + name="device_task_completion_matches_state", + ), + ] + indexes = [models.Index(fields=["state", "due_date"]), models.Index(fields=["device", "state"])] + + +class WorkflowEvent(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="workflow_events") + from_stage = models.CharField(max_length=12, choices=Device.Stage.choices, blank=True) + to_stage = models.CharField(max_length=12, choices=Device.Stage.choices) + note = models.CharField(max_length=500, blank=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [models.Index(fields=["device", "-created_at"])] + + +class InspectionRecord(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="inspections") + defects = models.TextField() + recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [models.Index(fields=["device", "-created_at"])] + + +class RepairRecord(models.Model): + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="repairs") + work_done = models.TextField() + recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [models.Index(fields=["device", "-created_at"])] + + +class AuditLog(models.Model): + class Action(models.TextChoices): + CREATE = "CREATE", "Erstellt" + UPDATE = "UPDATE", "Geändert" + ARCHIVE = "ARCHIVE", "Archiviert" + RESTORE = "RESTORE", "Wiederhergestellt" + IMPORT = "IMPORT", "Importiert" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="audit_entries") + api_client = models.CharField(max_length=50, blank=True) + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="audit_entries") + action = models.CharField(max_length=10, choices=Action.choices) + field = models.CharField(max_length=100, blank=True) + field_key = models.CharField(max_length=120, blank=True) + old_value = models.TextField(blank=True) + new_value = models.TextField(blank=True) + old_data = models.JSONField(null=True, blank=True) + new_data = models.JSONField(null=True, blank=True) + reverted_at = models.DateTimeField(null=True, blank=True) + reverted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="reverted_audit_entries", + ) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at", "-id"] + indexes = [models.Index(fields=["device", "-created_at"])] + + +class InventoryAPIKeyManager(BaseAPIKeyManager): + def get_usable_keys(self): + return super().get_usable_keys().filter( + service_user__is_active=True, + expiry_date__gt=timezone.now(), + ) + + +class InventoryAPIKey(AbstractAPIKey): + """Widerrufbarer Schlüssel für genau einen internen API-Client.""" + + objects = InventoryAPIKeyManager() + service_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="inventory_api_keys", + help_text="Technisches oder persönliches Konto, dem API-Änderungen zugerechnet werden.", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_inventory_api_keys", + ) + can_read = models.BooleanField(default=True) + can_write = models.BooleanField(default=False) + last_used_at = models.DateTimeField(null=True, blank=True, editable=False) + last_used_ip = models.GenericIPAddressField(null=True, blank=True, editable=False) + + class Meta(AbstractAPIKey.Meta): + verbose_name = "API-Schlüssel" + verbose_name_plural = "API-Schlüssel" + + +class UserPreference(TimeStampedModel): + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="inventory_preferences") + visible_columns = models.JSONField(default=list, blank=True) + column_order = models.JSONField(default=list, blank=True) + column_widths = models.JSONField(default=dict, blank=True) + page_size = models.PositiveSmallIntegerField(default=50) + + +class SavedView(TimeStampedModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_views") + name = models.CharField(max_length=120) + filters = models.JSONField(default=dict) + + class Meta: + constraints = [models.UniqueConstraint(fields=["user", "name"], name="unique_saved_view_name")] + + +class ImportJob(TimeStampedModel): + class Status(models.TextChoices): + UPLOADED = "UPLOADED", "Hochgeladen" + COMPLETED = "COMPLETED", "Abgeschlossen" + FAILED = "FAILED", "Fehlgeschlagen" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="import_jobs") + file = models.FileField(upload_to="imports/%Y/%m/") + original_name = models.CharField(max_length=255) + status = models.CharField(max_length=12, choices=Status.choices, default=Status.UPLOADED) + headers = models.JSONField(default=list) + preview_rows = models.JSONField(default=list) + result = models.JSONField(default=dict) diff --git a/inventory/permissions.py b/inventory/permissions.py new file mode 100644 index 0000000..4c2defd --- /dev/null +++ b/inventory/permissions.py @@ -0,0 +1,37 @@ +from functools import wraps +from django.contrib.auth.views import redirect_to_login +from django.core.exceptions import PermissionDenied + +from .models import UserProfile + + +def user_role(user) -> str | None: + if not getattr(user, "is_authenticated", False): + return None + if user.is_superuser: + return UserProfile.Role.ADMIN + profile, _ = UserProfile.objects.get_or_create(user=user) + return profile.role + + +def role_required(*roles: str): + def decorator(view): + @wraps(view) + def wrapped(request, *args, **kwargs): + if not request.user.is_authenticated: + return redirect_to_login(request.get_full_path()) + if user_role(request.user) not in roles: + raise PermissionDenied + return view(request, *args, **kwargs) + + return wrapped + + return decorator + + +def can_edit(user) -> bool: + return user_role(user) in {UserProfile.Role.ADMIN, UserProfile.Role.EDITOR} + + +def is_admin(user) -> bool: + return user_role(user) == UserProfile.Role.ADMIN diff --git a/inventory/query.py b/inventory/query.py new file mode 100644 index 0000000..102a72e --- /dev/null +++ b/inventory/query.py @@ -0,0 +1,167 @@ +from datetime import datetime +from decimal import Decimal, InvalidOperation +import re + +from django.db import connection +from django.db.models import CharField, Q, QuerySet +from django.db.models.functions import Coalesce + +from .models import Device, FieldDefinition + + +SORT_FIELDS = { + "number": "number", "date": "date", "shelf": "shelf", "status": "status__label", "stage": "stage", + "device_type": "device_type__label", "price": "price", "ram_gb": "_ram_gb", "ssd_gb": "_ssd_gb", + "hdd_gb": "_hdd_gb", "display_inches": "_display_inches", "cpu": "_cpu", "model": "model", + "employee": "employee__code", "recipient": "recipient__label", "ticket": "ticket", +} +COMPUTER_RELATIONS = ("laptop_specification", "desktop_specification", "aio_specification", "computer_specification") +SPECIFICATION_RELATIONS = (*COMPUTER_RELATIONS, "monitor_specification", "printer_specification") + + +def computer_field_query(field, lookup, value): + query = Q() + for relation in COMPUTER_RELATIONS: + query |= Q(**{f"{relation}__{field}__{lookup}": value}) + return query + + +def cpu_patterns(*patterns): + query = Q() + for lookup, value in patterns: + query |= computer_field_query("cpu", lookup, value) + return query + +CPU_VENDOR_QUERIES = { + "intel": cpu_patterns(("icontains", "intel"), ("istartswith", "core "), ("istartswith", "i3"), ("istartswith", "i5"), ("istartswith", "i7"), ("istartswith", "i9")), + "amd": cpu_patterns(("icontains", "amd"), ("icontains", "ryzen"), ("icontains", "threadripper"), ("istartswith", "fx-")), +} +CPU_FAMILY_QUERIES = { + "intel_i3": cpu_patterns(("icontains", "core i3"), ("istartswith", "i3")), + "intel_i5": cpu_patterns(("icontains", "core i5"), ("istartswith", "i5")), + "intel_i7": cpu_patterns(("icontains", "core i7"), ("istartswith", "i7")), + "intel_i9": cpu_patterns(("icontains", "core i9"), ("istartswith", "i9")), + "intel_ultra_5": cpu_patterns(("icontains", "core ultra 5")), + "intel_ultra_7": cpu_patterns(("icontains", "core ultra 7")), + "intel_ultra_9": cpu_patterns(("icontains", "core ultra 9")), + "amd_ryzen_3": cpu_patterns(("icontains", "ryzen 3 ")), + "amd_ryzen_5": cpu_patterns(("icontains", "ryzen 5 ")), + "amd_ryzen_7": cpu_patterns(("icontains", "ryzen 7 ")), + "amd_ryzen_9": cpu_patterns(("icontains", "ryzen 9 ")), + "amd_ryzen_ai": cpu_patterns(("icontains", "ryzen ai")), + "amd_threadripper": cpu_patterns(("icontains", "threadripper")), + "amd_fx": cpu_patterns(("icontains", "amd fx-"), ("istartswith", "fx-")), +} + + +def parse_filter_date(value): + for date_format in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(str(value), date_format).date() + except ValueError: + continue + return None + + +def device_queryset(params, archived: bool = False) -> QuerySet[Device]: + qs = Device.objects.select_related("status", "recipient", "location", "device_type", "employee", *SPECIFICATION_RELATIONS).annotate( + _display_inches=Coalesce("laptop_specification__display_inches", "aio_specification__display_inches", "monitor_specification__display_inches", "computer_specification__display_inches"), + _ram_gb=Coalesce(*(f"{name}__ram_gb" for name in COMPUTER_RELATIONS)), + _ssd_gb=Coalesce(*(f"{name}__ssd_gb" for name in COMPUTER_RELATIONS)), + _hdd_gb=Coalesce(*(f"{name}__hdd_gb" for name in COMPUTER_RELATIONS)), + _cpu=Coalesce(*(f"{name}__cpu" for name in COMPUTER_RELATIONS), output_field=CharField()), + ) + qs = qs.filter(archived_at__isnull=not archived) + search = params.get("q", "").strip() + if search: + if search.isdigit() and not search.startswith("0") and len(search) <= 10: + prefix = int(search) + if params.get("exact") == "1": + qs = qs.filter(number=prefix) + else: + number_query = Q(number=prefix) + for total_digits in range(len(search) + 1, 11): + factor = 10 ** (total_digits - len(search)) + number_query |= Q(number__gte=prefix * factor, number__lt=(prefix + 1) * factor) + qs = qs.filter(number_query) + else: + qs = qs.none() + for param, field in [("status", "status_id"), ("type", "device_type_id"), ("employee", "employee_id"), ("recipient", "recipient_id")]: + values = [v for v in params.getlist(param) if v] + if values: + if any(not v.isascii() or not v.isdigit() or len(v) > 19 or int(v) > 9223372036854775807 for v in values): + return qs.none() + qs = qs.filter(**{f"{field}__in": values}) + if stage := params.get("stage"): + if stage not in Device.Stage.values: + return qs.none() + qs = qs.filter(stage=stage) + for param, field in [("ram", "_ram_gb"), ("ssd", "_ssd_gb"), ("hdd", "_hdd_gb")]: + for suffix, lookup in [("min", "gte"), ("max", "lte"), ("exact", "exact")]: + value = params.get(f"{param}_{suffix}") + if value not in (None, ""): + if not value.isascii() or not value.isdigit() or len(value) > 10 or int(value) > 2147483647: + return qs.none() + qs = qs.filter(**{f"{field}__{lookup}": int(value)}) + for param, field in [("price", "price"), ("display", "_display_inches")]: + for suffix, lookup in [("min", "gte"), ("max", "lte")]: + value = params.get(f"{param}_{suffix}") + if value not in (None, ""): + try: + numeric = Decimal(value.replace(",", ".")) + if not numeric.is_finite() or abs(numeric) > Decimal("99999999.99"): + return qs.none() + except InvalidOperation: + return qs.none() + qs = qs.filter(**{f"{field}__{lookup}": numeric}) + for name in ["battery", "wifi", "lan"]: + value = params.get(name) + if value: + if name == "battery": + qs = qs.filter(Q(laptop_specification__battery=value) | Q(computer_specification__battery=value)) + else: + qs = qs.filter(computer_field_query(name, "exact", value) | Q(**{f"printer_specification__{name}": value})) + vendor_query = Q() + for value in params.getlist("cpu_vendor"): + if value in CPU_VENDOR_QUERIES: + vendor_query |= CPU_VENDOR_QUERIES[value] + if vendor_query: + qs = qs.filter(vendor_query) + family_query = Q() + for value in params.getlist("cpu_family"): + if value in CPU_FAMILY_QUERIES: + family_query |= CPU_FAMILY_QUERIES[value] + if family_query: + qs = qs.filter(family_query) + date_from = parse_filter_date(params.get("date_from")) if params.get("date_from") else None + date_to = parse_filter_date(params.get("date_to")) if params.get("date_to") else None + if date_from: + qs = qs.filter(date__gte=date_from) + if date_to: + qs = qs.filter(date__lte=date_to) + for name in ["model", "ticket"]: + value = params.get(f"{name}_contains", "").strip() + if value: + qs = qs.filter(**{f"{name}__icontains": value}) + cpu_value = params.get("cpu_contains", "").strip() + for token in re.findall(r"[a-z]+\d*|\d+[a-z]*", cpu_value.casefold()): + qs = qs.filter(computer_field_query("cpu", "icontains", token)) + for definition in FieldDefinition.objects.filter(active=True, filterable=True): + value = params.get(f"custom_{definition.key}", "").strip() + if not value: + continue + lookup = "exact" if definition.field_type in {"integer", "decimal", "boolean", "date", "select"} else "icontains" + if definition.field_type == "integer": + try: + value = int(value) + except ValueError: + continue + elif definition.field_type == "boolean": + value = value.lower() in {"1", "true", "yes", "ja"} + if lookup == "exact" and connection.vendor == "postgresql": + qs = qs.filter(custom_values__contains={definition.key: value}) + else: + qs = qs.filter(**{f"custom_values__{definition.key}__{lookup}": value}) + sort = params.get("sort", "number") + direction = "-" if params.get("dir", "desc") == "desc" else "" + return qs.order_by(f"{direction}{SORT_FIELDS.get(sort, 'number')}", "-number") diff --git a/inventory/services.py b/inventory/services.py new file mode 100644 index 0000000..e544589 --- /dev/null +++ b/inventory/services.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import ast +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Max +from django.utils import timezone + +from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, NumberSequence +from .device_types import HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable + + +FIELD_LABELS = { + "shelf": "Regal", "status": "Status", "date": "Datum", "recipient": "Empfänger", + "location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis", + "battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll", + "ram_gb": "RAM", "ssd_gb": "SSD", "hdd_gb": "HDD", "cpu": "CPU", + "hwid": "HWID", "model": "Modell", "employee": "Bearbeiter", "donor": "Spender", "comment": "Kommentar", + "ports": "Anschlüsse", "resolution": "Auflösung", "print_technology": "Druckverfahren", + "duplex": "Duplex", "color_printing": "Farbdruck", +} +INLINE_FIELDS = set(FIELD_LABELS) +RELATION_FIELDS = { + "status": ChoiceValue.Kind.STATUS, "recipient": ChoiceValue.Kind.RECIPIENT, + "location": ChoiceValue.Kind.LOCATION, "device_type": ChoiceValue.Kind.DEVICE_TYPE, +} +INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"} +DECIMAL_FIELDS = {"price", "display_inches"} +TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"} +_UNSET = object() + + +def display_value(value: Any) -> str: + if value is None: + return "" + if hasattr(value, "label"): + return value.label + if hasattr(value, "code"): + return value.code + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def display_device_value(field: str, value: Any) -> str: + if field in TRISTATE_FIELDS: + return dict(Device.TriState.choices).get(value, value or "") + if field == "date" and isinstance(value, date): + return value.strftime("%d.%m.%Y") + return display_value(value) + + +def serialize_audit_value(field: str, value: Any) -> Any: + if field.startswith("custom:"): + return value + if field in RELATION_FIELDS or field == "employee": + return value.pk if value is not None else None + if isinstance(value, (date, Decimal)): + return str(value) + return value + + +def add_audit( + user, device: Device, action: str, field: str = "", old: Any = "", new: Any = "", *, + field_key: str = "", old_data: Any = _UNSET, new_data: Any = _UNSET, api_client: str = "", +) -> AuditLog: + values = { + "user": user if getattr(user, "is_authenticated", False) else None, + "api_client": api_client, + "device": device, "action": action, "field": field, "field_key": field_key, + "old_value": display_value(old), "new_value": display_value(new), + } + if old_data is not _UNSET: + values["old_data"] = old_data + if new_data is not _UNSET: + values["new_data"] = new_data + return AuditLog.objects.create(**values) + + +@transaction.atomic +def create_device_batch(*, first_number: int, quantity: int, shelf: str, status, device_date, device_type, employee, donor: str, user, + model: str = "", hardware_values: dict | None = None) -> list[Device]: + sequence, _ = NumberSequence.objects.select_for_update().get_or_create(name="device") + last_number = first_number + quantity - 1 + conflict = Device.objects.filter(number__range=(first_number, last_number)).order_by("number").values_list("number", flat=True).first() + if conflict is not None: + raise ValidationError(f"Die Gerätenummer {conflict} ist bereits vergeben.") + + created: list[Device] = [] + for batch_start in range(first_number, last_number + 1, 1000): + batch_end = min(batch_start + 999, last_number) + batch = [ + Device(number=number, shelf=shelf, status=status, date=device_date, device_type=device_type, + employee=employee, donor=donor, model=model, **(hardware_values or {})) + for number in range(batch_start, batch_end + 1) + ] + saved = Device.objects.bulk_create(batch, batch_size=1000) + bulk_create_specifications(saved) + AuditLog.objects.bulk_create( + [AuditLog(user=user, device=device, action=AuditLog.Action.CREATE, new_value=f"Gerät #{device.number}") for device in saved], + batch_size=1000, + ) + created.extend(saved) + + current_maximum = Device.objects.aggregate(value=Max("number"))["value"] or 0 + sequence.last_value = max(sequence.last_value, current_maximum, last_number) + sequence.save(update_fields=["last_value"]) + return created + + +def coerce_inline_value(field: str, raw: Any) -> Any: + if isinstance(raw, (dict, list)): + raise ValidationError("Bitte einen einzelnen Feldwert eingeben.") + if raw in (None, ""): + if field == "status": + raise ValidationError("Dieses Feld darf nicht leer sein.") + return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else "" + if field in INTEGER_FIELDS: + try: + numeric = Decimal(str(raw)) + if not numeric.is_finite() or numeric != numeric.to_integral_value(): + raise ValueError + value = int(numeric) + except (TypeError, ValueError, InvalidOperation, OverflowError): + raise ValidationError("Bitte eine gültige ganze Zahl eingeben.") + if value < 0: + raise ValidationError("Der Wert darf nicht negativ sein.") + return value + if field in DECIMAL_FIELDS: + try: + value = Decimal(str(raw).replace(",", ".")) + except InvalidOperation: + raise ValidationError("Bitte eine gültige Dezimalzahl eingeben.") + if not value.is_finite() or value < 0: + raise ValidationError("Der Wert darf nicht negativ sein.") + return value + if field == "date": + for date_format in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(str(raw), date_format).date() + except ValueError: + continue + raise ValidationError("Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.") + if field in TRISTATE_FIELDS: + if raw not in dict(Device.TriState.choices): + raise ValidationError("Ungültiger Auswahlwert.") + return raw + if field in RELATION_FIELDS: + try: + return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field], active=True) + except (ChoiceValue.DoesNotExist, TypeError, ValueError): + raise ValidationError("Ungültiger Auswahlwert.") + if field == "employee": + try: + return Employee.objects.get(pk=int(raw), active=True) + except (Employee.DoesNotExist, TypeError, ValueError): + raise ValidationError("Ungültiger Mitarbeiter.") + return str(raw).strip() + + +def check_device_version(device, expected_updated_at=None, expected_revision=None): + if expected_revision is not None: + if isinstance(expected_revision, bool) or str(expected_revision) != str(device.revision): + raise RuntimeError("conflict") + if expected_updated_at: + try: + expected = expected_updated_at if isinstance(expected_updated_at, datetime) else datetime.fromisoformat(str(expected_updated_at).replace("Z", "+00:00")) + if timezone.is_naive(expected): + expected = timezone.make_aware(expected, timezone.get_current_timezone()) + except (TypeError, ValueError): + raise RuntimeError("conflict") + if device.updated_at != expected: + raise RuntimeError("conflict") + + +@transaction.atomic +def update_device_field( + device: Device, field: str, raw_value: Any, user, expected_updated_at: str | None = None, *, + api_client: str = "", expected_revision=None, +) -> Device: + if not isinstance(field, str) or field not in INLINE_FIELDS: + raise ValidationError("Dieses Feld kann nicht direkt bearbeitet werden.") + locked = Device.objects.select_for_update().get(pk=device.pk) + check_device_version(locked, expected_updated_at, expected_revision) + old = getattr(locked, field) + new = coerce_inline_value(field, raw_value) + if field in HARDWARE_FIELDS and field not in applicable_fields(locked.device_type): + raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.") + if serialize_audit_value(field, old) != serialize_audit_value(field, new): + setattr(locked, field, new) + changed_fields = [field, "updated_at"] + cleared_hardware = {} + if field == "device_type": + cleared_hardware = clear_inapplicable(locked) + changed_fields.extend(HARDWARE_FIELDS - applicable_fields(new)) + if cleared_hardware: + changed_fields.append("custom_values") + locked.full_clean(exclude=["custom_values"]) + locked.save(update_fields=changed_fields) + add_audit( + user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[field], + display_device_value(field, old), display_device_value(field, new), field_key=field, + old_data=serialize_audit_value(field, old), new_data=serialize_audit_value(field, new), + api_client=api_client, + ) + for hardware_field, previous_value in cleared_hardware.items(): + add_audit(user, locked, AuditLog.Action.UPDATE, FIELD_LABELS[hardware_field], + previous_value, "", field_key=hardware_field, + old_data=serialize_audit_value(hardware_field, previous_value), + new_data=serialize_audit_value(hardware_field, getattr(locked, hardware_field)), + api_client=api_client) + return locked + + +def _deserialize_device_value(field: str, raw: Any) -> Any: + if raw in (None, ""): + if field == "status": + raise ValidationError("Ein Status kann nicht leer sein.") + return None if field in INTEGER_FIELDS | DECIMAL_FIELDS | set(RELATION_FIELDS) | {"employee"} else "" + if field in RELATION_FIELDS: + try: + if str(raw).isdigit(): + return ChoiceValue.objects.get(pk=int(raw), kind=RELATION_FIELDS[field]) + return ChoiceValue.objects.get(label__iexact=str(raw), kind=RELATION_FIELDS[field]) + except (ChoiceValue.DoesNotExist, ChoiceValue.MultipleObjectsReturned, TypeError, ValueError) as exc: + raise ValidationError("Der frühere Auswahlwert ist nicht mehr vorhanden.") from exc + if field == "employee": + try: + if str(raw).isdigit(): + return Employee.objects.get(pk=int(raw)) + return Employee.objects.get(code__iexact=str(raw)) + except (Employee.DoesNotExist, Employee.MultipleObjectsReturned, TypeError, ValueError) as exc: + raise ValidationError("Der frühere Bearbeiter ist nicht mehr vorhanden.") from exc + if field == "date": + for date_format in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(str(raw), date_format).date() + except ValueError: + continue + raise ValidationError("Das frühere Datum ist ungültig.") + if field in INTEGER_FIELDS: + return int(raw) + if field in DECIMAL_FIELDS: + return Decimal(str(raw).replace(",", ".")) + if field in TRISTATE_FIELDS: + reverse_labels = {label: key for key, label in Device.TriState.choices} + value = reverse_labels.get(str(raw), raw) + if value not in Device.TriState.values: + raise ValidationError("Der frühere Auswahlwert ist ungültig.") + return value + return str(raw) + + +def _legacy_field_key(log: AuditLog) -> str: + key = next((name for name, label in FIELD_LABELS.items() if label == log.field), "") + if key: + return key + definition = FieldDefinition.objects.filter(name=log.field).first() + return f"custom:{definition.key}" if definition else "" + + +def _legacy_custom_value(definition: FieldDefinition, value: str) -> Any: + if value == "": + return None + if definition.field_type == FieldDefinition.FieldType.INTEGER: + return int(value) + if definition.field_type == FieldDefinition.FieldType.BOOLEAN: + return value.lower() in {"1", "true", "yes", "ja"} + if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT: + try: + parsed = ast.literal_eval(value) + except (SyntaxError, ValueError): + parsed = [part.strip() for part in value.split(",") if part.strip()] + return parsed if isinstance(parsed, list) else [str(parsed)] + return value + + +@transaction.atomic +def revert_audit_change(log: AuditLog, user) -> AuditLog: + locked_log = AuditLog.objects.select_for_update().select_related("device").get(pk=log.pk) + if locked_log.action != AuditLog.Action.UPDATE or locked_log.reverted_at: + raise ValidationError("Diese Änderung kann nicht zurückgesetzt werden.") + field_key = locked_log.field_key or _legacy_field_key(locked_log) + if not field_key: + raise ValidationError("Das geänderte Feld ist nicht mehr eindeutig zuzuordnen.") + + device = Device.objects.select_for_update().get(pk=locked_log.device_id) + if field_key.startswith("custom:"): + custom_key = field_key.split(":", 1)[1] + definition = FieldDefinition.objects.filter(key=custom_key).first() + if not definition: + raise ValidationError("Das Zusatzfeld ist nicht mehr vorhanden.") + current = device.custom_values.get(custom_key) + if locked_log.field_key: + if current != locked_log.new_data: + raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.") + restored = locked_log.old_data + else: + if display_value(current) != locked_log.new_value: + raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.") + restored = _legacy_custom_value(definition, locked_log.old_value) + custom_values = dict(device.custom_values) + custom_values[custom_key] = restored + device.custom_values = custom_values + device.save(update_fields=["custom_values", "updated_at"]) + reversal = add_audit( + user, device, AuditLog.Action.UPDATE, locked_log.field, + display_value(current), display_value(restored), field_key=field_key, + old_data=current, new_data=restored, + ) + else: + if field_key not in FIELD_LABELS: + raise ValidationError("Dieses Feld kann nicht zurückgesetzt werden.") + current = getattr(device, field_key) + current_data = serialize_audit_value(field_key, current) + if locked_log.field_key: + if current_data != locked_log.new_data: + raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.") + raw_old = locked_log.old_data + else: + current_displays = {display_device_value(field_key, current), display_value(current)} + if locked_log.new_value not in current_displays: + raise ValidationError("Dieses Feld wurde später erneut geändert und kann daher nicht sicher zurückgesetzt werden.") + raw_old = locked_log.old_value + restored = _deserialize_device_value(field_key, raw_old) + if field_key in HARDWARE_FIELDS and field_key not in applicable_fields(device.device_type): + raise ValidationError("Dieses Hardwarefeld gehört nicht zu diesem Gerätetyp.") + setattr(device, field_key, restored) + update_fields = [field_key, "updated_at"] + if field_key == "device_type": + cleared = clear_inapplicable(device) + update_fields.extend(HARDWARE_FIELDS - applicable_fields(restored)) + if cleared: + update_fields.append("custom_values") + device.full_clean(exclude=["custom_values"]) + device.save(update_fields=update_fields) + reversal = add_audit( + user, device, AuditLog.Action.UPDATE, FIELD_LABELS[field_key], + display_device_value(field_key, current), display_device_value(field_key, restored), field_key=field_key, + old_data=current_data, new_data=serialize_audit_value(field_key, restored), + ) + + locked_log.reverted_at = timezone.now() + locked_log.reverted_by = user + locked_log.save(update_fields=["reverted_at", "reverted_by"]) + return reversal + + +def validate_custom_value(definition: FieldDefinition, raw: Any) -> Any: + if raw in (None, "", []): + if definition.required: + raise ValidationError(f"{definition.name} ist erforderlich.") + return None + if definition.field_type == FieldDefinition.FieldType.INTEGER: + try: + value = Decimal(str(raw)) + if not value.is_finite() or value != value.to_integral_value(): + raise ValueError + return int(value) + except (TypeError, ValueError, InvalidOperation, OverflowError) as exc: + raise ValidationError(f"{definition.name}: Bitte eine ganze Zahl eingeben.") from exc + if definition.field_type == FieldDefinition.FieldType.DECIMAL: + try: + value = Decimal(str(raw).replace(",", ".")) + if not value.is_finite(): + raise InvalidOperation + return str(value) + except InvalidOperation as exc: + raise ValidationError(f"{definition.name}: Bitte eine Dezimalzahl eingeben.") from exc + if definition.field_type == FieldDefinition.FieldType.BOOLEAN: + return str(raw).lower() in {"1", "true", "yes", "ja", "on"} + if definition.field_type == FieldDefinition.FieldType.DATE: + for date_format in ("%d.%m.%Y", "%Y-%m-%d"): + try: + return datetime.strptime(str(raw), date_format).date().isoformat() + except ValueError: + continue + raise ValidationError(f"{definition.name}: Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.") + if definition.field_type in {FieldDefinition.FieldType.SELECT, FieldDefinition.FieldType.MULTI_SELECT}: + values = raw if isinstance(raw, list) else [str(raw)] + allowed = set(definition.options.filter(active=True).values_list("label", flat=True)) + if not definition.allow_custom_values and not set(values).issubset(allowed): + raise ValidationError(f"Ungültige Auswahl für {definition.name}.") + return values if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else values[0] + return str(raw) diff --git a/inventory/signals.py b/inventory/signals.py new file mode 100644 index 0000000..4656edc --- /dev/null +++ b/inventory/signals.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import UserProfile + + +@receiver(post_save, sender=User) +def ensure_user_profile(sender, instance: User, created: bool, raw=False, **kwargs) -> None: + if created and not raw: + role = UserProfile.Role.ADMIN if instance.is_superuser else UserProfile.Role.VIEWER + UserProfile.objects.create(user=instance, role=role) diff --git a/inventory/static/inventory/css/app.css b/inventory/static/inventory/css/app.css new file mode 100644 index 0000000..b1be769 --- /dev/null +++ b/inventory/static/inventory/css/app.css @@ -0,0 +1,179 @@ +:root{--blue:#0066ff;--blue-dark:#0052cc;--bg:#f6f8fc;--text:#111827;--muted:#667085;--border:#e5e7eb;--white:#fff;--danger:#c93434;--green:#14804a;--sidebar:228px;--radius:8px;--shadow:0 8px 24px rgba(16,24,40,.08)} +*{box-sizing:border-box}html{font-size:16px}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:inherit;text-decoration:none}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}.sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar);background:#fff;border-right:1px solid var(--border);display:flex;flex-direction:column;padding:14px 12px;z-index:40}.brand{height:55px;display:flex;align-items:center;gap:10px;padding:0 8px;margin-bottom:16px}.brand img{width:41px;height:43px;object-fit:contain}.brand span{display:flex;flex-direction:column}.brand strong{font-size:17px;letter-spacing:.02em}.brand small,.sidebar-user small{font-size:12px;color:var(--muted)}nav{display:flex;flex-direction:column;gap:3px}nav a{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:7px;font-weight:550;color:#475467}nav a span{width:19px;text-align:center;font-size:17px;color:#667085}nav a:hover{background:#f2f5fa;color:#101828}nav a.active{background:#edf4ff;color:var(--blue)}nav a.active span{color:var(--blue)}.nav-label{text-transform:uppercase;font-size:11px;font-weight:700;letter-spacing:.08em;color:#98a2b3;padding:18px 10px 6px}.sidebar-user{margin-top:auto;border-top:1px solid var(--border);padding:13px 5px 2px;display:flex;align-items:center;gap:9px}.sidebar-user>span:nth-child(2){display:flex;min-width:0;flex:1;flex-direction:column}.sidebar-user strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px}.avatar{display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:#e8f1ff;color:var(--blue);font-weight:700}.sidebar-user form button{border:0;background:none;color:#667085;font-size:18px;padding:5px}.main{margin-left:var(--sidebar);min-height:100vh;padding:28px 30px 50px}.mobile-header{display:none}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:20px}.page-head h1{margin:0;font-size:26px;letter-spacing:-.025em;line-height:1.15}.page-head p{margin:5px 0 0;color:var(--muted)}.actions,.form-actions{display:flex;align-items:center;gap:8px}.btn{display:inline-flex;min-height:37px;align-items:center;justify-content:center;gap:6px;padding:7px 12px;border:1px solid #d0d5dd;border-radius:7px;background:#fff;font-weight:600;color:#344054;line-height:1.2}.btn:hover{border-color:#98a2b3;background:#f9fafb}.btn.primary{color:#fff;border-color:var(--blue);background:var(--blue)}.btn.primary:hover{background:var(--blue-dark)}.btn.danger{color:var(--danger)}.btn.small{min-height:31px;padding:5px 9px;font-size:13px}.btn.icon{width:38px;padding:0;font-size:18px}.btn.full{width:100%}.toolbar{position:relative;display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--border);padding:10px;border-radius:9px 9px 0 0}.toolbar.compact{border-radius:9px;margin-bottom:16px}.search{height:38px;min-width:280px;max-width:520px;flex:1;display:flex;align-items:center;border:1px solid #d0d5dd;border-radius:7px;background:#fff;padding:0 10px;gap:8px}.search:focus-within{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,102,255,.1)}.search span{font-size:19px;color:#667085}.search input{border:0;outline:0;width:100%;background:transparent}.page-size{margin-left:auto;display:flex;align-items:center;gap:7px;color:var(--muted);white-space:nowrap}.page-size select{height:36px;border:1px solid #d0d5dd;border-radius:6px;background:white}.filter-panel{position:absolute;z-index:20;top:58px;left:0;right:0;background:#fff;border:1px solid var(--border);box-shadow:var(--shadow);padding:16px;display:grid;grid-template-columns:repeat(4,minmax(140px,1fr));gap:12px;border-radius:8px}.filter-panel[hidden]{display:none}.filter-panel label,.app-form label,.detail-form label,.mapping-grid label{display:flex;flex-direction:column;gap:5px;font-weight:600;font-size:13px}.filter-panel input,.filter-panel select,.app-form input,.app-form select,.app-form textarea,.detail-form input,.detail-form select,.detail-form textarea,.mapping-grid select{width:100%;border:1px solid #d0d5dd;border-radius:6px;background:#fff;padding:8px 10px;min-height:38px;outline:none}.filter-panel input:focus,.filter-panel select:focus,.app-form input:focus,.app-form select:focus,.app-form textarea:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,102,255,.1)}.filter-panel select[multiple]{height:86px}.filter-actions{display:flex;align-items:flex-end;justify-content:flex-end;gap:8px;grid-column:-3/-1}.column-picker{position:absolute;right:8px;top:58px;width:220px;z-index:21;background:#fff;border:1px solid var(--border);box-shadow:var(--shadow);padding:9px;border-radius:8px}.column-picker label{display:flex;gap:8px;padding:6px;font-weight:500}.table-shell{max-width:100%;overflow:auto;background:#fff;border:1px solid var(--border);border-top:0;border-radius:0 0 9px 9px}.device-table{width:100%;min-width:1320px;border-collapse:separate;border-spacing:0;font-size:13px}.device-table th{position:sticky;top:0;z-index:5;background:#f8fafc;border-bottom:1px solid var(--border);color:#475467;text-align:left;font-size:12px;font-weight:700;white-space:nowrap;padding:9px 10px}.device-table td{border-bottom:1px solid #eef0f3;padding:9px 10px;white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;height:43px}.device-table tbody tr{cursor:pointer}.device-table tbody tr:hover td{background:#f5f8fd}.device-table tbody tr:last-child td{border-bottom:0}.device-table td[data-field]{outline:0}.device-table td.editing{padding:3px;background:#eef5ff!important;overflow:visible}.device-table td.editing input,.device-table td.editing select{height:34px;min-width:90px;width:100%;border:1px solid var(--blue);border-radius:5px;padding:5px;outline:0;background:#fff}.mono{font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.strong{font-weight:700}.status{display:inline-flex;padding:3px 7px;border-radius:999px;background:#eef2f6;color:#344054;font-weight:650;font-size:12px}.status-eingang{background:#e9f2ff;color:#175cd3}.status-fertig{background:#e9f8f0;color:#087443}.status-defekt,.status-entsorgt{background:#fef0f0;color:#b42318}.status-reserviert{background:#fff5df;color:#a15c00}.pagination{display:flex;justify-content:space-between;align-items:center;color:var(--muted);padding:14px 2px}.pagination div{display:flex;gap:7px}.empty{text-align:center!important;padding:48px!important;color:var(--muted)}.empty strong,.empty span{display:block}.menu{position:relative}.menu summary{list-style:none}.menu summary::-webkit-details-marker{display:none}.menu>div{position:absolute;right:0;top:44px;width:180px;z-index:30;background:#fff;border:1px solid var(--border);border-radius:8px;padding:5px;box-shadow:var(--shadow)}.menu>div a{display:block;padding:8px;border-radius:5px}.menu>div a:hover{background:#f2f4f7}.narrow-page{max-width:720px;margin:0 auto}.wide-form{max-width:980px}.form-card,.detail-form section,.panel{background:#fff;border:1px solid var(--border);border-radius:9px;padding:22px;margin-bottom:16px}.form-card h2{margin-top:0}.app-form{display:grid;gap:14px}.form-actions{justify-content:flex-end;margin-top:8px}.number-preview{display:flex;justify-content:space-between;background:#f3f7fd;border-radius:7px;padding:11px 12px;margin-bottom:18px;color:var(--muted)}.number-preview strong{color:var(--text)}.errorlist,.form-error{color:#b42318;background:#fff0f0;border-radius:5px;margin:3px 0 0;padding:7px 10px;list-style:none;font-weight:500}.back{display:inline-block;color:var(--blue);font-weight:600;margin-bottom:8px}.detail-form{padding-bottom:66px}.detail-form section h2{font-size:14px;text-transform:uppercase;letter-spacing:.06em;color:#667085;margin:0 0 16px}.form-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.detail-form textarea{min-height:100px;resize:vertical}.detail-form label small,.app-form label small{font-weight:400;color:var(--muted)}.sticky-actions{position:sticky;bottom:12px;z-index:10;display:flex;justify-content:flex-end;gap:8px;background:rgba(255,255,255,.94);border:1px solid var(--border);border-radius:9px;padding:10px;box-shadow:var(--shadow);backdrop-filter:blur(8px)}.drawer-backdrop{position:fixed;inset:0;background:rgba(16,24,40,.22);z-index:70}.drawer{position:fixed;inset:0 0 0 auto;width:min(520px,95vw);z-index:71;background:#fff;box-shadow:-12px 0 30px rgba(16,24,40,.14);display:flex;flex-direction:column}.drawer>header{display:flex;justify-content:space-between;align-items:center;padding:17px 20px;border-bottom:1px solid var(--border)}.drawer>header span{color:var(--muted);font-size:12px}.drawer h2{margin:0;font-size:23px}.icon-close{border:0;background:#f2f4f7;border-radius:6px;width:34px;height:34px;font-size:22px}.drawer-body{padding:6px 20px 25px;overflow:auto}.drawer-body section{padding:16px 0;border-bottom:1px solid var(--border)}.drawer-body section h3{margin:0 0 10px;font-size:12px;color:#667085;text-transform:uppercase;letter-spacing:.08em}.drawer dl{display:grid;grid-template-columns:1fr 1fr;gap:13px 18px;margin:0}.drawer dl div{min-width:0}.drawer dl .wide{grid-column:1/-1}.drawer dt{color:#667085;font-size:12px}.drawer dd{margin:2px 0 0;font-weight:600;overflow-wrap:anywhere}.drawer .note{margin:0;background:#f8fafc;padding:10px;border-radius:6px}.drawer>footer{margin-top:auto;border-top:1px solid var(--border);display:flex;gap:8px;padding:14px 20px}.drawer>footer form{margin-left:auto}.mini-history>div{display:grid;grid-template-columns:100px 1fr;gap:2px 8px;margin-bottom:9px}.mini-history span{color:var(--muted);font-size:12px}.mini-history small{grid-column:2}.history-list{display:grid;gap:8px}.history-list article{display:grid;grid-template-columns:36px 1fr;gap:12px;background:#fff;border:1px solid var(--border);border-radius:8px;padding:13px}.history-icon{width:34px;height:34px;display:grid;place-items:center;background:#edf4ff;color:var(--blue);border-radius:50%;font-weight:700}.history-list header{display:flex;justify-content:space-between}.history-list header span,.history-list small{color:var(--muted)}.history-list p{margin:4px 0}.history-list del{color:#b42318}.history-list ins{color:#087443;text-decoration:none}.config-grid{display:grid;grid-template-columns:1.15fr 1fr;gap:16px}.panel{padding:0;overflow:hidden}.panel>header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;padding:18px;border-bottom:1px solid var(--border)}.panel h2{margin:0;font-size:17px}.panel header p{margin:4px 0 0;color:var(--muted);font-size:13px}.config-list{padding:10px 18px 18px;max-height:68vh;overflow:auto}.config-list h3{margin:16px 0 5px;color:#667085;font-size:12px;text-transform:uppercase;letter-spacing:.06em}.config-list a{display:flex;justify-content:space-between;align-items:center;gap:10px;border-bottom:1px solid #eef0f3;padding:9px 2px}.config-list a:hover span{color:var(--blue)}.config-list a>span{display:flex;flex-direction:column}.config-list small{color:var(--muted)}.config-list em{font-style:normal;font-size:11px;background:#f2f4f7;color:#667085;border-radius:99px;padding:3px 7px}.config-list em.active{background:#e9f8f0;color:#087443}.check-label{flex-direction:row!important;align-items:center}.check-label input{width:auto!important;min-height:auto!important}.steps{display:flex;list-style:none;padding:0;margin:0 0 22px}.steps li{flex:1;border-top:2px solid var(--border);padding-top:7px;color:var(--muted);font-size:12px}.steps li.active{border-color:var(--blue);color:var(--blue);font-weight:700}.file-drop{border:1px dashed #98a2b3;border-radius:8px;min-height:150px;align-items:center!important;justify-content:center;cursor:pointer;background:#fafbfc}.file-drop input{position:absolute;opacity:0;width:1px!important}.file-drop strong{font-size:15px;color:var(--blue)}.export-card{margin-top:16px}.export-card p{color:var(--muted)}.mapping-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;background:#fff;border:1px solid var(--border);border-radius:9px;padding:18px}.mapping-grid label span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.section-title{font-size:16px;margin:22px 0 10px}.login-page{min-height:100vh;display:grid;place-items:center;background:#f2f5fa}.login-card{width:min(390px,calc(100% - 30px));background:#fff;border:1px solid var(--border);border-radius:12px;padding:30px;box-shadow:var(--shadow)}.login-card>img{width:58px;height:61px;object-fit:contain}.login-card h1{margin:14px 0 3px;font-size:24px}.login-card>p{color:var(--muted);margin:0 0 22px}.login-card form{display:grid;gap:14px}.login-card label{display:grid;gap:5px;font-weight:600}.login-card input{height:42px;border:1px solid #d0d5dd;border-radius:6px;padding:8px}.toast-stack{position:fixed;right:20px;top:18px;display:grid;gap:8px;z-index:100}.toast{background:#fff;border:1px solid var(--border);border-left:4px solid var(--green);border-radius:7px;padding:11px 14px;box-shadow:var(--shadow);min-width:240px;animation:toast-in .2s ease}.toast.error{border-left-color:var(--danger)}.confirm-box{position:fixed;z-index:80;left:50%;top:50%;transform:translate(-50%,-50%);background:#fff;border-radius:10px;padding:22px;width:min(400px,calc(100% - 30px));box-shadow:var(--shadow)}.confirm-box h2{margin:0 0 8px}.confirm-box>div{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}@keyframes toast-in{from{opacity:0;transform:translateY(-5px)}} +@media(max-width:1050px){.main{padding:22px 18px}.filter-panel{grid-template-columns:repeat(3,1fr)}.form-grid{grid-template-columns:repeat(2,1fr)}.config-grid{grid-template-columns:1fr}} +@media(max-width:760px){.sidebar{transform:translateX(-100%);transition:transform .2s}.sidebar.open{transform:none;box-shadow:var(--shadow)}.main{margin-left:0;padding:64px 12px 35px}.mobile-header{display:flex;position:fixed;z-index:35;inset:0 0 auto 0;height:50px;background:#fff;border-bottom:1px solid var(--border);align-items:center;gap:12px;padding:0 12px}.mobile-header button{border:0;background:none;font-size:21px}.page-head{align-items:flex-start}.toolbar{flex-wrap:wrap}.search{min-width:100%;order:-1}.page-size{margin-left:0}.filter-panel{top:105px;grid-template-columns:1fr 1fr}.form-grid,.mapping-grid{grid-template-columns:1fr}.drawer{width:100%}.config-grid{grid-template-columns:1fr}} +.filter-panel{max-height:72vh;overflow:auto} +.device-table th{resize:horizontal;overflow:hidden;min-width:64px} +#device-table-container[aria-busy="true"]{opacity:.55;transition:opacity .12s} + +/* Übersicht */ +.overview-head{align-items:center}.period-tabs{display:flex;flex-direction:row;gap:3px;padding:4px;background:#e9edf4;border-radius:9px}.period-tabs a{padding:7px 12px;border-radius:6px;color:#58667a;font-size:13px;font-weight:650}.period-tabs a:hover{background:rgba(255,255,255,.55)}.period-tabs a.active{background:#fff;color:var(--blue);box-shadow:0 1px 4px rgba(16,24,40,.09)} +.metric-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:16px}.metric-card{position:relative;min-height:132px;background:#fff;border:1px solid var(--border);border-radius:10px;padding:17px;display:flex;flex-direction:column}.metric-card.accent{border-top:3px solid var(--blue);padding-top:15px}.metric-card>span{color:#667085;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.045em}.metric-card>strong{font-size:30px;line-height:1.2;margin:9px 0 2px;letter-spacing:-.035em}.metric-card>small{color:var(--muted);font-size:12px}.metric-card>em{display:block;margin-top:auto;padding-top:7px;color:#667085;font-size:11px;font-style:normal}.metric-card>em.up{color:#087443}.metric-card>em.down{color:#b42318} +.dashboard-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.chart-panel{background:#fff;border:1px solid var(--border);border-radius:10px;padding:19px 20px;min-width:0}.chart-panel.monthly-panel{grid-column:1/-1}.chart-panel>header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:18px}.chart-panel h2{font-size:16px;margin:0}.chart-panel header p{font-size:12px;color:var(--muted);margin:3px 0 0}.chart-total{font-size:21px;text-align:right}.chart-total small{display:block;color:var(--muted);font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:.05em} +.line-chart-wrap{overflow:hidden}.line-chart{display:block;width:100%;height:230px;overflow:visible}.line-chart text{fill:#98a2b3;font-size:10px}.chart-grid-line{stroke:#e8edf3;stroke-width:1;stroke-dasharray:4 5}.chart-area{fill:rgba(0,102,255,.09)}.chart-line{fill:none;stroke:var(--blue);stroke-width:3;stroke-linecap:round;stroke-linejoin:round}.chart-point{fill:#fff;stroke:var(--blue);stroke-width:3}.month-labels{display:grid;grid-template-columns:repeat(12,1fr);padding:0 1.3% 0 4.3%;color:#7b8798;font-size:10px;text-align:center}.month-labels span{white-space:nowrap} +.bar-chart{display:grid;gap:13px}.bar-row>div{display:flex;justify-content:space-between;gap:15px;margin-bottom:5px;font-size:13px}.bar-row strong{font-variant-numeric:tabular-nums}.bar-track,.completion-track{display:block;height:8px;border-radius:99px;background:#edf1f6;overflow:hidden}.bar-track i,.completion-track i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#0066ff,#4e95ff)}.recipient-chart .bar-track i{background:linear-gradient(90deg,#17a673,#4fc59a)} +.donut-layout{display:grid;grid-template-columns:180px minmax(0,1fr);gap:25px;align-items:center}.donut{width:170px;aspect-ratio:1;border-radius:50%;display:grid;place-items:center}.donut>div{width:105px;aspect-ratio:1;border-radius:50%;background:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;box-shadow:inset 0 0 0 1px #f3f4f6}.donut strong{font-size:26px;line-height:1}.donut span{font-size:11px;color:var(--muted);margin-top:4px}.legend{display:grid;gap:9px}.legend>div{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:8px;font-size:12px}.legend i{width:9px;height:9px;border-radius:3px}.legend strong{font-variant-numeric:tabular-nums}.legend small{font-weight:400;color:var(--muted);margin-left:3px} +.completion-chart{display:grid;gap:14px}.completion-chart>div{display:grid;grid-template-columns:110px 1fr 42px;gap:10px;align-items:center;font-size:12px}.completion-chart strong{text-align:right;font-variant-numeric:tabular-nums}.completion-track{height:9px}.completion-track i{background:linear-gradient(90deg,#8b5cf6,#b39af8)}.chart-empty{color:var(--muted);padding:28px 0;text-align:center} +@media(max-width:1150px){.metric-grid{grid-template-columns:repeat(3,1fr)}} +@media(max-width:850px){.dashboard-grid{grid-template-columns:1fr}.chart-panel.monthly-panel{grid-column:auto}.donut-layout{grid-template-columns:150px 1fr}.donut{width:145px}.donut>div{width:90px}.line-chart-wrap{overflow-x:auto}.line-chart,.month-labels{min-width:720px}} +@media(max-width:760px){.overview-head{display:grid}.period-tabs{width:100%;margin-top:12px}.period-tabs a{flex:1;text-align:center}.metric-grid{grid-template-columns:1fr 1fr}.metric-card{min-height:120px}.donut-layout{grid-template-columns:1fr}.donut{margin:0 auto}} +@media(max-width:460px){.metric-grid{grid-template-columns:1fr}.metric-card{min-height:110px}} + +/* Neu gegliederte Filteroberfläche */ +.filter-toggle.active{border-color:#9bc0ff;background:#edf4ff;color:var(--blue)}.filter-count{display:inline-grid;place-items:center;min-width:20px;height:20px;padding:0 6px;border-radius:99px;background:var(--blue);color:#fff;font-size:11px}.filter-count[hidden]{display:none}.filter-panel{display:block;top:60px;padding:0;max-height:min(78vh,760px);overflow:hidden;border-radius:11px}.filter-panel[hidden]{display:none}.filter-panel-head{display:flex;align-items:flex-start;justify-content:space-between;padding:17px 19px;border-bottom:1px solid var(--border)}.filter-panel-head h2{font-size:18px;margin:0}.filter-panel-head p{color:var(--muted);font-size:12px;margin:3px 0 0}.filter-panel-body{max-height:calc(min(78vh,760px) - 132px);overflow:auto;padding:4px 19px}.filter-section{display:grid;grid-template-columns:175px minmax(0,1fr);gap:20px;padding:17px 0;border-bottom:1px solid #edf0f4}.filter-section:last-child{border-bottom:0}.filter-section>header h3{font-size:13px;margin:1px 0 3px}.filter-section>header p{font-size:12px;color:var(--muted);margin:0}.filter-grid{display:grid;gap:10px}.filter-grid-four{grid-template-columns:repeat(4,minmax(0,1fr))}.filter-grid-six{grid-template-columns:repeat(3,minmax(0,1fr))}.filter-grid-hardware{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-panel label{font-size:12px}.filter-panel input,.filter-panel select{min-height:36px}.filter-panel fieldset{min-width:0;margin:0;border:1px solid #dce2ea;border-radius:8px;padding:9px 10px 10px}.filter-panel fieldset legend{padding:0 5px;color:#475467;font-size:12px;font-weight:700}.filter-panel fieldset>div{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.filter-panel fieldset:last-child>div{grid-template-columns:repeat(2,1fr)}.multi-filter{position:relative;min-width:0}.multi-filter summary{list-style:none;min-height:58px;border:1px solid #d0d5dd;border-radius:7px;padding:8px 31px 8px 10px;display:flex;flex-direction:column;justify-content:center;cursor:pointer;position:relative}.multi-filter summary::-webkit-details-marker{display:none}.multi-filter summary:after{content:'⌄';position:absolute;right:11px;top:19px;color:#667085}.multi-filter[open] summary{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,102,255,.1)}.multi-filter summary span{font-size:11px;color:#667085}.multi-filter summary b{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.multi-filter>div{position:absolute;z-index:25;left:0;right:0;top:62px;max-height:220px;overflow:auto;padding:6px;background:#fff;border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow)}.multi-filter>div label{display:flex;flex-direction:row;align-items:center;gap:8px;padding:7px;border-radius:5px;font-weight:500;cursor:pointer}.multi-filter>div label:hover{background:#f2f5fa}.multi-filter>div input{width:16px;min-height:16px;margin:0}.filter-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 19px;border-top:1px solid var(--border);background:#fafbfc}.filter-actions>span{margin-right:auto;color:var(--muted);font-size:12px}.export-menu>div{width:230px;padding:8px}.export-menu>div>strong,.export-menu>div>small{display:block;padding:2px 8px}.export-menu>div>small{color:var(--muted);font-size:11px;padding-bottom:7px}.export-menu hr{border:0;border-top:1px solid var(--border);margin:5px} +.history-content{min-width:0}.history-content>footer{display:flex;align-items:center;gap:10px;margin-top:8px}.history-content>footer form{margin-left:auto}.reverted-badge{margin-left:auto;padding:4px 8px;border-radius:99px;background:#f2f4f7;color:#667085;font-size:11px;font-weight:650} +@media(max-width:1100px){.filter-section{grid-template-columns:1fr}.filter-grid-four{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-grid-six{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:760px){.filter-panel{position:fixed;inset:51px 0 0;max-height:none;border-radius:0;z-index:60}.filter-panel-body{max-height:calc(100vh - 184px)}.filter-grid-four,.filter-grid-six,.filter-grid-hardware{grid-template-columns:1fr}.filter-actions{flex-wrap:wrap}.filter-actions>span{width:100%;margin:0}.filter-actions .btn{flex:1}.multi-filter>div{position:static;margin-top:5px}.history-content>footer{align-items:flex-start;flex-wrap:wrap}.history-content>footer form,.reverted-badge{margin-left:0}} + +/* Direct workflow controls: one level from the main navigation. */ +.workflow-board{display:grid;grid-template-columns:repeat(5,minmax(235px,1fr));gap:12px;align-items:start;overflow-x:auto;padding-bottom:18px}.workflow-column{min-width:235px;background:#edf1f7;border:1px solid #e2e7ef;border-radius:10px;min-height:270px}.workflow-column>header{display:flex;align-items:center;justify-content:space-between;padding:13px 14px}.workflow-column h2{font-size:14px;margin:0}.workflow-column>header span{display:grid;place-items:center;min-width:24px;height:24px;padding:0 6px;border-radius:99px;background:#dce8fb;color:#0052cc;font-weight:700}.workflow-cards{display:grid;gap:9px;padding:0 9px 10px}.workflow-card{background:#fff;border:1px solid #dce2ea;border-radius:9px;padding:12px;box-shadow:0 2px 8px rgba(16,24,40,.04)}.workflow-card-head{display:flex;flex-direction:column;gap:2px}.workflow-card-head a{font-weight:700;color:#0052cc}.workflow-card-head a:hover{text-decoration:underline}.workflow-card-head small,.workflow-card>p{color:var(--muted);font-size:12px}.workflow-card>p{margin:8px 0}.workflow-task-line{display:flex;flex-wrap:wrap;gap:5px;align-items:center;font-size:11px;margin-bottom:9px}.workflow-task-line strong,.workflow-task-line span{padding:3px 6px;border-radius:5px;background:#f2f5fa}.workflow-task-line strong{color:#344054}.workflow-quick-form{display:flex;gap:5px;margin-top:6px}.workflow-quick-form input,.workflow-quick-form select{min-width:0;flex:1;height:33px;border:1px solid #d0d5dd;border-radius:6px;padding:5px 7px;background:#fff}.workflow-quick-form .btn{min-height:33px}.workflow-empty{color:var(--muted);font-size:12px;margin:10px}.workflow-empty a{color:var(--blue);text-decoration:underline}.device-workbench{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}.device-workbench>section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:18px;min-width:0}.device-workbench h2{margin:0 0 8px;font-size:17px}.device-workbench .mini-history{margin-top:12px}.device-workbench .form-card{margin:8px 0;padding:10px}.device-workbench .app-form{margin-top:10px}.device-workbench .app-form label{margin-bottom:8px}.device-table .device-link{color:#0052cc;font-weight:700}.device-table .device-link:hover{text-decoration:underline} +@media(max-width:1100px){.workflow-board{grid-template-columns:repeat(5,245px)}} +@media(max-width:760px){.workflow-board{display:block;overflow:visible}.workflow-column{min-height:0;margin-bottom:12px}.device-workbench{grid-template-columns:1fr}} +.workflow-board{grid-template-columns:repeat(5,minmax(315px,1fr))} +.workflow-column{min-width:315px} +.workflow-stage-form{display:grid;gap:8px;padding:10px 0;margin:9px 0;border-top:1px solid #e5eaf0;border-bottom:1px solid #e5eaf0} +.workflow-stage-form h3{font-size:12px;margin:0;color:#344054} +.workflow-stage-form label{display:grid;gap:3px;color:#475467;font-size:11px;font-weight:650} +.workflow-stage-form input,.workflow-stage-form select,.workflow-stage-form textarea{width:100%;min-width:0;min-height:32px;border:1px solid #d0d5dd;border-radius:6px;padding:5px 7px;background:#fff;font-size:12px} +.workflow-stage-form textarea{min-height:58px;resize:vertical} +.workflow-stage-form .btn{justify-self:start} +.workflow-spec-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px} +.workflow-spec-grid label:first-child,.workflow-spec-grid label:last-child:nth-child(odd){grid-column:1/-1} +.workflow-record{padding:8px;border-radius:6px;background:#f6f8fb;color:#344054!important;overflow-wrap:anywhere} +.workflow-record small{display:block;margin-top:3px;color:var(--muted)} +@media(max-width:760px){.workflow-board{display:block}.workflow-column{min-width:0}} + +/* Bearbeiterverwaltung und deutsches Datumsformat */ +.config-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.config-row{display:flex;align-items:center;gap:7px;border-bottom:1px solid #eef0f3}.config-row:last-child{border-bottom:0}.config-list .config-row>a{border:0;flex:1;min-width:0}.config-row form{margin:0}.config-row .btn{white-space:nowrap}.employee-list{max-height:68vh}.app-form input[data-german-date],.detail-form input[data-german-date],.filter-panel input[data-german-date]{font-variant-numeric:tabular-nums;letter-spacing:.02em} +@media(max-width:1250px){.config-grid-three{grid-template-columns:1fr 1fr}.config-grid-three .panel:first-child{grid-column:1/-1}} +@media(max-width:760px){.config-grid-three{grid-template-columns:1fr}.config-grid-three .panel:first-child{grid-column:auto}.config-row{align-items:flex-start}.config-row form{padding-top:6px}} + +/* Fixierter Listenarbeitsbereich */ +.device-list-page{height:100vh;overflow:hidden}.device-list-page .main{height:100vh;min-height:0;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 1 auto;min-height:0;flex-direction:column}.device-list-page #device-table-container .table-shell{flex:1 1 auto;min-height:0;overflow:auto}.device-list-page #device-table-container .pagination{flex:0 0 auto;margin:0;padding:10px 2px 0}.device-list-page .device-table th{top:0;z-index:6}.device-list-page .table-shell{scrollbar-gutter:stable} + +/* Bearbeitbare Statusfarben */ +.status-colored{color:var(--status-color);background:color-mix(in srgb,var(--status-color) 12%,white);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--status-color) 24%,white)}.status-color-dot{display:inline-block;width:11px;height:11px;flex:0 0 auto;border-radius:50%;background:var(--status-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--status-color) 13%,white)}.config-value-label{flex-direction:row!important;align-items:center;gap:9px}.color-field{border:0;margin:0;padding:0}.color-field legend{font-size:13px;font-weight:600;margin-bottom:7px}.color-field>small{display:block;color:var(--muted);margin-top:7px}.color-options{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.color-option{position:relative;min-height:48px;display:flex!important;flex-direction:row!important;align-items:center;gap:8px;padding:8px 9px;border:1px solid #d0d5dd;border-radius:7px;cursor:pointer;font-weight:600}.color-option:hover{background:#f8fafc}.color-option:has(input:checked){border-color:var(--choice-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--choice-color) 14%,white)}.color-option input{position:absolute;opacity:0;width:1px!important;min-height:1px!important}.color-option i{width:18px;height:18px;flex:0 0 auto;border-radius:50%;background:var(--choice-color);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}.color-option span{font-size:12px} +@media(max-width:760px){.device-list-page .main{height:100vh;padding-bottom:8px}.color-options{grid-template-columns:repeat(2,minmax(0,1fr))}} + +/* CPU-Katalog und Live-Vorschläge */ +.cpu-filter-grid{grid-template-columns:minmax(180px,.7fr) minmax(220px,1fr) minmax(280px,1.3fr)}.cpu-search-field{position:relative}.cpu-autocomplete-host{position:relative}.cpu-suggestions{position:absolute;z-index:80;top:calc(100% + 5px);left:0;right:0;max-height:286px;overflow:auto;background:#fff;border:1px solid #cfd6e1;border-radius:8px;padding:5px;box-shadow:0 14px 34px rgba(16,24,40,.16)}.cpu-suggestions[hidden]{display:none}.cpu-suggestions button{width:100%;min-height:39px;display:flex;align-items:center;justify-content:space-between;gap:12px;border:0;border-radius:5px;background:#fff;padding:8px 9px;text-align:left;color:#253044}.cpu-suggestions button:hover,.cpu-suggestions button.active{background:#edf4ff;color:#0052cc}.cpu-suggestions strong{font-size:13px;font-weight:650}.cpu-suggestions small{color:#667085;font-size:10px;font-weight:600;white-space:nowrap}.device-table td.editing.cpu-autocomplete-host{position:relative;z-index:30}.device-table td.editing .cpu-suggestions{min-width:340px;top:39px}.app-form .cpu-suggestions,.detail-form .cpu-suggestions{top:calc(100% + 3px)} +@media(max-width:1100px){.cpu-filter-grid{grid-template-columns:1fr 1fr}.cpu-search-field{grid-column:1/-1}} +@media(max-width:760px){.cpu-filter-grid{grid-template-columns:1fr}.cpu-search-field{grid-column:auto}.cpu-suggestions small{display:none}} + +/* Spaltenanordnung im ursprünglichen, hellen Tabellendesign */ +.device-list-page .table-shell{position:relative} +.device-list-page .device-table th[data-column]{cursor:grab;touch-action:pan-y;user-select:none} +.device-list-page .device-table th[data-column]:active{cursor:grabbing} +.device-list-page .device-table th[data-column]:focus-visible{z-index:8;outline:3px solid #8bc0ff;outline-offset:-3px} +.device-list-page .device-table.column-reordering th{cursor:grabbing;transition:transform 155ms cubic-bezier(.2,.8,.2,1)} +.device-list-page .device-table th.column-drag-source{opacity:0} +.column-drag-ghost{position:fixed;z-index:200;display:flex;height:34px;max-width:240px;align-items:center;gap:8px;padding:7px 11px;border:1px solid #a9c7ef;border-radius:8px;background:rgba(255,255,255,.96);color:#344054;font-size:12px;font-weight:700;white-space:nowrap;box-shadow:0 10px 24px rgba(16,24,40,.16);pointer-events:none;will-change:transform} +.column-drag-ghost::before{content:'⋮⋮';color:#0878ff;font-size:13px;letter-spacing:-3px} +.column-drop-placeholder{position:absolute;z-index:12;width:62px;height:28px;border:2px dashed #1877ed;border-radius:9px;background:rgba(232,242,255,.34);box-shadow:0 0 0 4px rgba(24,119,237,.08);pointer-events:none;transform:translateX(-50%);transition:left 130ms cubic-bezier(.2,.8,.2,1),top 80ms ease;will-change:left,transform} +@keyframes column-slot-pop{0%{opacity:.3;transform:translateX(-50%) scale(.72)}70%{opacity:1;transform:translateX(-50%) scale(1.06)}100%{opacity:1;transform:translateX(-50%) scale(1)}} +.column-drop-placeholder.pop{animation:column-slot-pop 160ms cubic-bezier(.2,.8,.2,1)} +body.column-dragging,body.column-dragging *{cursor:grabbing!important} +.column-picker-hint{margin:2px 6px 7px;padding-bottom:8px;border-bottom:1px solid var(--border);color:#667085;font-size:11px;line-height:1.35} +.column-picker label[data-column]{align-items:center;border-radius:5px;cursor:pointer} +.column-picker label[data-column]:hover{background:#f2f5fa} + +/* Mehrfachauswahl und Navigation zwischen Geräten */ +.device-list-page .bulk-bar{flex:0 0 auto} +.bulk-bar{display:flex;align-items:center;gap:12px;min-height:58px;padding:9px 12px;border:1px solid #bad3fa;border-top:0;background:#edf5ff;color:#344054} +.bulk-bar[hidden]{display:none} +.bulk-bar>strong{min-width:92px;color:#175cd3;white-space:nowrap} +.bulk-bar label{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:650;white-space:nowrap} +.bulk-bar select{min-width:155px;height:36px;border:1px solid #b8c5d8;border-radius:6px;background:#fff;padding:0 30px 0 9px;outline:0} +.bulk-bar select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,102,255,.1)} +.bulk-bar [data-bulk-clear]{margin-left:auto} +.device-table .selection-column{width:42px;min-width:42px;max-width:42px;padding:0;text-align:center;resize:none} +.device-table .selection-column input{width:16px;height:16px;margin:0;accent-color:var(--blue);cursor:pointer;vertical-align:middle} +.device-table tbody tr.selected td{background:#edf5ff} +.device-table tbody tr.selected:hover td{background:#e4f0ff} +.record-navigation{display:flex;align-items:center;gap:7px} +.record-navigation>span{min-width:58px;color:var(--muted);font-size:12px;text-align:center;white-space:nowrap} +.record-navigation .btn.icon{font-size:18px} +.record-navigation .disabled{opacity:.38;pointer-events:none} +.record-navigation.compact{gap:5px} +.record-navigation.compact .btn.icon{width:34px;min-height:34px} +.device-detail-head{align-items:center} +.drawer-head-actions{display:flex;align-items:center;gap:8px} +@media(max-width:760px){.bulk-bar{flex-wrap:wrap}.bulk-bar label{flex:1;min-width:150px}.bulk-bar select{width:100%;min-width:0}.bulk-bar [data-bulk-clear]{margin-left:0}.bulk-bar [data-bulk-submit]{margin-left:auto}.device-detail-head{align-items:flex-start}.record-navigation>span{min-width:46px}} +[data-hardware-field][hidden],[data-hardware-section][hidden]{display:none!important} +.quick-hardware-section{border-top:1px solid var(--border);padding-top:17px;margin-top:3px} +.quick-hardware-section h2{font-size:16px;margin:0 0 13px} +.quick-hardware-section h2 small,.detail-form h2 small{font-size:12px;font-weight:500;text-transform:none;letter-spacing:0;color:var(--muted)} +.quick-hardware-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.quick-hardware-grid label{display:flex;flex-direction:column;gap:5px;font-weight:600;font-size:13px} +.quick-hardware-grid input,.quick-hardware-grid select{width:100%;min-width:0;border:1px solid #d0d5dd;border-radius:6px;background:#fff;padding:8px 10px;min-height:38px} +@media(max-width:600px){.quick-hardware-grid{grid-template-columns:1fr}} + +/* Persisted light/dark appearance. */ +.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.theme-toggle{display:flex;align-items:center;justify-content:center;gap:9px;border:1px solid var(--border);border-radius:7px;background:transparent;color:var(--text);font-weight:600;min-height:37px;cursor:pointer} +.theme-toggle:hover{background:color-mix(in srgb,var(--text) 7%,transparent)} +.theme-toggle:focus-visible{outline:2px solid var(--blue);outline-offset:2px} +.theme-toggle [data-theme-icon]{font-size:20px;line-height:1} +.sidebar-theme{width:100%;margin-top:auto;margin-bottom:10px} +.sidebar-user{margin-top:0} +.mobile-theme{display:none;margin-left:auto;width:38px;padding:0} +.login-theme{width:100%;margin-top:17px} +@media(max-width:760px){.mobile-theme{display:flex}} + +html[data-theme="dark"]{color-scheme:dark;--blue:#2563eb;--blue-dark:#1d4ed8;--bg:#101827;--text:#edf2fa;--muted:#a7b6ca;--border:#34445a;--white:#1b283a;--danger:#ff8b8b;--green:#56cc98;--surface:#1b283a;--raised:#243349;--input:#142033;--link:#8cbbff;--shadow:0 8px 24px rgba(0,0,0,.25)} +html[data-theme="light"]{color-scheme:light} +html[data-theme="dark"] .sidebar,html[data-theme="dark"] .mobile-header,html[data-theme="dark"] .toolbar,html[data-theme="dark"] .search,html[data-theme="dark"] .filter-panel,html[data-theme="dark"] .column-picker,html[data-theme="dark"] .table-shell,html[data-theme="dark"] .menu>div,html[data-theme="dark"] .form-card,html[data-theme="dark"] .detail-form section,html[data-theme="dark"] .panel,html[data-theme="dark"] .drawer,html[data-theme="dark"] .history-list article,html[data-theme="dark"] .mapping-grid,html[data-theme="dark"] .login-card,html[data-theme="dark"] .toast,html[data-theme="dark"] .confirm-box,html[data-theme="dark"] .chart-panel,html[data-theme="dark"] .metric-card,html[data-theme="dark"] .workflow-card,html[data-theme="dark"] .device-workbench>section,html[data-theme="dark"] .cpu-suggestions,html[data-theme="dark"] .cpu-suggestions button{background:var(--surface);color:var(--text)} +html[data-theme="dark"] .login-page{background:var(--bg)} +html[data-theme="dark"] input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),html[data-theme="dark"] select,html[data-theme="dark"] textarea{background:var(--input);color:var(--text);border-color:#52627a} +html[data-theme="dark"] input::placeholder,html[data-theme="dark"] textarea::placeholder{color:#9aabc2;opacity:1} +html[data-theme="dark"] .search input{background:transparent} +html[data-theme="dark"] .btn{background:var(--raised);border-color:#52627a;color:var(--text)} +html[data-theme="dark"] .btn:hover{background:#30435c;border-color:#74859c} +html[data-theme="dark"] .btn.primary{background:var(--blue);border-color:var(--blue);color:#fff} +html[data-theme="dark"] .btn.primary:hover{background:var(--blue-dark)} +html[data-theme="dark"] .btn.danger{color:var(--danger)} +html[data-theme="dark"] nav a{color:#c6d2e2} +html[data-theme="dark"] nav a span,html[data-theme="dark"] .nav-label,html[data-theme="dark"] .sidebar-user form button{color:var(--muted)} +html[data-theme="dark"] nav a:hover,html[data-theme="dark"] .menu>div a:hover,html[data-theme="dark"] .column-picker label[data-column]:hover,html[data-theme="dark"] .multi-filter>div label:hover{background:var(--raised);color:var(--text)} +html[data-theme="dark"] nav a.active,html[data-theme="dark"] .filter-toggle.active{background:#213b5d;color:var(--link);border-color:#426a9e} +html[data-theme="dark"] nav a.active span{color:var(--link)} +html[data-theme="dark"] .avatar,html[data-theme="dark"] .history-icon{background:#213b5d;color:var(--link)} +html[data-theme="dark"] .device-table th{background:#223147;color:#cfdaeb} +html[data-theme="dark"] .device-table td,html[data-theme="dark"] .config-list a,html[data-theme="dark"] .config-row,html[data-theme="dark"] .filter-section{border-color:#34445a} +html[data-theme="dark"] .device-table tbody tr:hover td,html[data-theme="dark"] .color-option:hover{background:#26384e} +html[data-theme="dark"] .device-table tbody tr.selected td{background:#254365} +html[data-theme="dark"] .device-table tbody tr.selected:hover td{background:#2c527a} +html[data-theme="dark"] .device-table td.editing{background:#2b476a!important} +html[data-theme="dark"] .status{background:#354155;color:#e0e8f4} +html[data-theme="dark"] .status-eingang{background:#1e395c;color:#abd0ff} +html[data-theme="dark"] .status-fertig{background:#183e32;color:#96e8ba} +html[data-theme="dark"] .status-defekt,html[data-theme="dark"] .status-entsorgt{background:#512b36;color:#ffb3bd} +html[data-theme="dark"] .status-reserviert{background:#51412a;color:#f9d088} +html[data-theme="dark"] .status-colored{color:color-mix(in srgb,var(--status-color) 50%,white);background:color-mix(in srgb,var(--status-color) 25%,var(--surface))} +html[data-theme="dark"] .number-preview,html[data-theme="dark"] .drawer .note,html[data-theme="dark"] .workflow-record,html[data-theme="dark"] .file-drop,html[data-theme="dark"] .filter-actions,html[data-theme="dark"] .reverted-badge{background:#18263a;color:#d8e2f0} +html[data-theme="dark"] .errorlist,html[data-theme="dark"] .form-error{background:#4a2733;color:#ffb6bf} +html[data-theme="dark"] .detail-form section h2,html[data-theme="dark"] .drawer-body section h3,html[data-theme="dark"] .drawer dt,html[data-theme="dark"] .config-list h3,html[data-theme="dark"] .filter-panel fieldset legend,html[data-theme="dark"] .workflow-stage-form label,html[data-theme="dark"] .workflow-stage-form h3{color:#c0cee0} +html[data-theme="dark"] .sticky-actions{background:rgba(27,40,58,.94)} +html[data-theme="dark"] .drawer-backdrop{background:rgba(0,0,0,.55)} +html[data-theme="dark"] .icon-close,html[data-theme="dark"] .config-list em,html[data-theme="dark"] .workflow-task-line strong,html[data-theme="dark"] .workflow-task-line span{background:var(--raised);color:#d8e2f0} +html[data-theme="dark"] .config-list em.active{background:#183e32;color:#96e8ba} +html[data-theme="dark"] .period-tabs{background:#19263a} +html[data-theme="dark"] .period-tabs a{color:#b7c5d8} +html[data-theme="dark"] .period-tabs a:hover{background:#2d4059} +html[data-theme="dark"] .period-tabs a.active{background:var(--raised);color:var(--link)} +html[data-theme="dark"] .metric-card>span,html[data-theme="dark"] .metric-card>em,html[data-theme="dark"] .month-labels,html[data-theme="dark"] .legend small{color:var(--muted)} +html[data-theme="dark"] .metric-card>em.up,html[data-theme="dark"] .history-list ins{color:#80dbac} +html[data-theme="dark"] .metric-card>em.down,html[data-theme="dark"] .history-list del{color:#ffa3ad} +html[data-theme="dark"] .bar-track,html[data-theme="dark"] .completion-track{background:#314258} +html[data-theme="dark"] .donut>div{background:var(--surface)} +html[data-theme="dark"] .multi-filter>div{background:var(--raised)} +html[data-theme="dark"] .workflow-column{background:#172335;border-color:#34445a} +html[data-theme="dark"] .workflow-column>header span{background:#254365;color:var(--link)} +html[data-theme="dark"] .workflow-card{border-color:#3b4c62} +html[data-theme="dark"] .workflow-card-head a,html[data-theme="dark"] .device-table .device-link{color:var(--link)} +html[data-theme="dark"] .workflow-stage-form{border-color:#34445a} +html[data-theme="dark"] .cpu-suggestions button:hover,html[data-theme="dark"] .cpu-suggestions button.active{background:#254365;color:var(--link)} +html[data-theme="dark"] .column-drag-ghost{background:rgba(36,51,73,.96);color:var(--text)} +html[data-theme="dark"] .column-drop-placeholder{background:rgba(37,99,235,.15)} +html[data-theme="dark"] .bulk-bar{background:#1c3452;color:#e1eaf6;border-color:#426086} +html[data-theme="dark"] .bulk-bar>strong{color:var(--link)} diff --git a/inventory/static/inventory/img/csr-logo.png b/inventory/static/inventory/img/csr-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b41e0a5b3891f130e3246be0d5c438d942e9ad86 GIT binary patch literal 7509 zcmXY0Wl$Rp6AloxXmOX~4#nNQxD9;Ds!7@;+>9cS7#GNG6m+7P@iB6zs)04p~N^TDAqhn(L}dt1kt5M}?9jUFR#o zu&6=;!o6R1G^k2o++G7Hf=?a0t$vTGV15ZbB!ikbg|*^m1K)%8f0awp7ep#EnTgDZ z$HPweMj+mQjL=%3*O-tfLrp3W!@tg-xHFAtt$;mu>@KRa7m~)zw;5~_A|UJy)Igsq zqd6BsFG$m{$%|Abrli4umRLdY69)T*&;(}86t0!5`l@sl7IAnK^oLB`kOEnB1->c& z`G0g`&u4?tmfgqfE2ykJ*C_kDm>z=~nv`tc z=i@9cK|ILx!yRmCIRf!`g{|3XSgiP;q&Mbs+~bQ#C^_>T(U0ejVoS@S75EcQ=wQjT z=Vl>qm1vIA7x$PNoMDEOOvS-QBBQQF>e;X;iF=CR`>sA@dy(hQMpnO))+Z#sVoaWh zq)D&`G?Q){BCdzYX=6ln=VJ81au8rkYz>HT+y_)?kxJ4cOccRyXMfmStPTl`6-isP zm<_GNE)gPY|1!!dh!P&696P7$?sP!nI&E3v{2?34y954R2N~m@tN;6d*==CUvS_q;3g8 zaFayeLOd#HWa8}m@ABv>R}?uc$5H(FWs;U@x5Oq>9h+hT-tUv3DAYphypyCd5Ar>? zM59A2MIXZNFXhM>`w`@d&T?At8ZLxP^R9%Zn0PgCVkVEEvyB0@iAWLK;2&l7;bGhn z2Oz&!Gyv;R0sTrL-zB?4tD@i{)0V)@l~Q7RZx|SE)tja7H{b;e@6PB<93NjxQ)7`l@)C?E=!5m)o$Lk@Y@*S9UX?TxqFXI zqsfQQWM>GV>5?y>=!bhn>j;F3wd*ZnNQGBBd7&e2uLZF%yzBIn0-((hL;i?#`M(Hw z8l(KiV>gR*1%-x(W0H^ zR6c+Zf&(oM5YPMixm+#{}tMn_DR@#-*_<@5bO z;8ZV#a~G@JpFL+JDaW+2n#rPf5oMa6J{Qu-Ru*?BdW`Gf8y%(FxxBn= z_I+Ttgyifrt@-!balzZ6N8G{jc_ji5IX|w4uzOvaIkcTL%EkLVUkpGE4iB5Gw7S3d zU9D8Fx)Gg#V0bj+bB2JIOL6w2-{Uno9UY=}8n53YN3QQB;#ubtb4yDL=bI{3-@rh< zaWC9y>k;#{_g!$7i>vFy#Q>>xhc{=1W+jENaMzs2eNx~EEIcxvh>Z=s>)v>!YGB+u z24s_|Yn`{G(tA-hH#h&axYAU2-2PZnp$aBTNJ!|esP2>-Nn=`REM||9`c$dgmGmX%o#(J7MDmprQs;8&telwb7e|f=6pqWy(sJPhqVpDR|;xk;xdn15!vZ^wZ zl6p3J!v*i{Z54fe1>PR#yY^>(VzPAJQ)t@Wh#k#{{KSOo4*rZutzh)}{D6ntw5!sv zV0XOMl}ja`R%1GVTBX+_W^7EJw@nMQv%!8TE%B4|<%BpI1|i|d4^G=*eV^z1i)&y? z+;r2-Jx&(;j~D8BJ-yK-O<{GoC3^K_(IS9xwDbXd9b)d~BEFeMrY_ z*cmfx({ECXj)f&5ApvI6ZKPylOUxG!pQ_U5cjGf(!S67D%=}av8XPoQZTEtjOsJbM z3mzpNG5;u(VtrLRn$2K-`l!Bp3Nc&sBcJw9RS4%~zx$PX(5$$-dc&UGnRaj)t1utS zzS5a|(5YQApqX(mJQTQGrt*pR^JlfHC>VIuLB4E7DeIgzU-y$mjd2L%8y}0Xuwf2| zb&?$`9=+iFD|?2*WtmFhHUPuY>x2Y!UQW*0gTUeS6&K=o=VddFDgpP zL5up9nY4z626}roJmkyoY^K{4TGf1h@$KybEgBV?*g3d3IMlg(?&ZU4`#`DUAPZa5 zBM*N4>UPA(%gg)AprciSS}% zSwR}f(cz(E2oFBW4zj5!G8o2I9xWR&g>iquI=fob)9*hyEk1}gJQ^OH1+&HT6H zo$|0rE3@GgIz{WHCP&NuC~T6B@K1c%*bYFxNzXlUPvst(fcD?v~|l-(4D zY(DoWfK)uMNe&JUwzs!a9FA_hSADO>0*OECH}2o4-xYH^8-@xw?0g6oi6;@vngV7{ zQZ&eN&#v9`g5v3ppCwDk^W13em$n*{WpkI)0P!~-d&3Uz*}|2m)o#Zv#1ns3X;oEK z0e@1U6u-3Vvvg8HA#f<0=NrAFxx#BdZ;l}VYe~ft?4;a|uyh%5Wopz73O+aT#Kpw) znX&U0XAAnwG2AV71qcH$GHGeG0Pg-SBT0;Nih_)_#ThC6kW@-jag= zU&xmy;CYLb*ZZ3G11_`KY`F#r;@2{iU@~-keAzaS6VHwRa6R|)!?O-U`M;fRFFAJI zT-&d~S@@iLANU`M)D6J#ZC07KQp+Uum1|V|YjPxI>3U{Y5_&Le@wd!Ylr}M;kWXhe z2iRvcM=%qZ%+wK46gU`Seh9U_xAG5;Ntbf<6iwW`5F&gUZ+J@tTs0Rbnl0?ZXsAtHd&!X4d?Ftbb z$aL%++Mg`M=XN0aw(4csQkKGHzgT0^j|6ByA8^6mOz%g4ismYHv%S6O3$&^Yj#qRq z+P963jRl^L>m8O@M%d}-=qRbEoOeg@n;@>4`>Vqv%zWA|p6SCy=_}01{-&hg1JbTV z*tALElW%JGP^w^YqW=Q`MnkH#z*oR6D=%*faO1nI7<72E8TWum{H|4K=3{B2fT+go z8F+%;4O*@?-*|lQS)*~7Hha;8)mL!tlwA}BCo&HqyfjeQJ6D*2Z)LUmRNFf{iRtNk zDVYsclfk3`&$W%|RXBBw^*(nFx5o>;Yj3aLEXK2jLJ={uOg@IAk&dU(seKm?=o~n6 zto_R4_t=nJt(0aip;BbO)Tl_+|8&*`NurV$a0qFdEc~UhAxm=JCkd$6(D=BA4duHF z%4v3sO)$@BXy7PO$mjveq+M$wc|aVS%9vB<^`P%Nu76Ol^*|B3l{5+?G@Gd^3C*#@tPhVL6P8G|yxE&;^EehUw`tt3` zwYcmc0b}Q8qyOz_E*5ZXu&U@`+w&C54c5tO-1-s{p<4=<y2f_GRNuoTt$?Ng?Gxc2ULFxe+FKrk~rkQTE*G*+~DY0`X)Wmy% zC-q&&GQ=k$8twV-Lu5pRazDu(A_zk+sc*T(H9PAMAd8H;jiaTf1X=(yPn3KiENuWx zo`zkmPNhON-z*)$ejNYlda9NIVyMYNSF~whuK!~sApZQtH;1#)?niTz1(G*&ad8n5 zwA`vnSdy*oN1Dvdzv-zJvt;Tm#^jpJwQpOV1{0HMlrH}gI`ZuN1>CBu%|9b=n z7BTI#3|?0Y-X`(2PQL{J=btS`^8i=E+%|YzkKf?9mc#413z#%jhfztu%-o;0o&Bq~ zM4K*Eq7f46?8(7b!HSHG-1t3jUwGq@c|>MbGEoSdmZWY?ERcfi~U2xK)k^DH*n zA7OpuGU-ED4S0K*Ix+4I136x{P6+}R7J~TRN2H2SQ4Wu_5O{zxwvpb{sbzPGGzCR*>Snv9&4i# z^YvBfxA8cwgS54^6*pft!b$&i_;8P9a+f-;bzUDYB=_}6{NQ#}w6d}~*yjg~IsolG zNUr7G>B@U%0J^*d)DHoN(0)10P<%7>{QPVK|A~m-Bj#djU@1LF@c>EaF=8b{fR>b$ z1QgrTul?`M(Y)7Q_BR`M6dyaYJoFk0#%RIAziP|vUdf}Q3i0~YtE$=XEZZq zPgWE&${0*EGv}Bb7&iAL3lAZ{hVy$e$~2Zh`E#k;e*@MOGi9(Oh{cmGVkkG05@j3s zHYs$EvQ7G8V$g(Kb_fypyGH~-TY+J_L00y6xq-nu{_w|YM!F#^GAwr;1w%&^-*-z9 z93A$iwzW1Ts1>KBGvJk{u;u)CZOx)*&^upLQZnb_RTnk0zq9!@i83D2P`%*;Sm_Tq zjOV*d?MF1vw@Z#0T=qIG4|$x>A6vQ;{l8>pq!k5 z_((`enQ#!i`2Uyx80B|aycY154bbD&^|aDE*YAZC_=HRfc~3f*%&{*G!!ih0?IU9m zjoh>nm}&3M8wOC((M1Av@9FL3#CT)_riqZ-!IML-gWk71Ydn04Nh+QCh(N=aLvxIjMRl2Z-5zB5BMV76lG;USQ?g_=M}3$bolV_ z@cX!^ngrO+bYwSJ5@&7d0giqL3E(V!305kn4*=A`S9iU(mPdmhpbV_P-{0xnp#s2Q z5qR7$)NAwjlKK>IGpqIg)v>JZITS+oM0#fu9~n^Xe%1|OKm#)``?KZv`ODo}g^l>l z`MnM3JAjX4Kb##7szCL@MRTgeQ9Y>o29wWi;>DJ2+M^hT8 zG(M{lD4F`pLVyd8F-UIm=b30B;Neu z#p=#LCwJ2SizaHz-O0X}&5#jl0ml>D6Az)jjS?_|4HS#P6Q?2cPmT zM67Ok_6t?OG%Yk@rCaov8#>LmV;F{ROWB~mpvs_db8QkEu1rVn%&~mhSUhR5K=j}q zkzZSaQK!-w&9xQY$*QdaddSfF91=~zWe(^oAA$>rL$_h&n2$ybACXzBw~OHwM6}l= zY}e&CA2arAL49=umEtM8Bz0GoMAIYN?58B+RC|2bnW#?^;*n^|w8^HkkI|YfN|ncd zk<8j;xTy3C*UpMLmp^Q@e69&{iAZQZw}-2|*ECY*VH zv7fpnn4xXCx>W*j$$>YW;e-Ev^q%gCOzrYt6WUx2)R+bBlwjP7UBQm}^%JXMiBQ0qMq z**%a#5kkv`g5VSFG4z&KW2slbJ&r)~I_PB-Zd*bck*sGlgDV;!1z2{NVxC4*ScCqg z^wCBd^1GtNTQHdcb4W#NyE=Hj6K>lq#YR(Pd{_e_C7)6W_BR9r|FWJfS0viS>HNyy@Dv#cO`z8>9P+L;Cn>vC^CCfG?OP0Px;>`&LORgVu+UkLDzR=-yDVNPPziM%8a7E zw^E4V|NGCbBl`O6wCF*Z_3;TT{>t1#usq-b>*608J+dKJ7kcw3wT?)ZAxAxol!BDf zCIMQCP&^);h@Hs$@j=d#G&H21&d(rt{AQVd_i^Tmy0FiqjQGAQ~6l+w{bzWcYWPPm75J5qGBk zhg1c%6m%EZk@D+N2jBfmD5wcWH-glcA4W@Dne#6nsUPXB0v)Kc|6xOe<6plo0^0Pre*}X?)vc z8hH)B>H`sJI|mZK)pdjkAV3X+vnl&BIj3jQBD C*`y8t literal 0 HcmV?d00001 diff --git a/inventory/static/inventory/js/app.js b/inventory/static/inventory/js/app.js new file mode 100644 index 0000000..979a9a5 --- /dev/null +++ b/inventory/static/inventory/js/app.js @@ -0,0 +1,404 @@ +(() => { + const csrftoken = document.body?.dataset.csrf; + const toast = (message, type = "success") => { const root=document.querySelector("#toast-root"),item=document.createElement("div");item.className=`toast ${type}`;item.textContent=message;root.append(item);setTimeout(()=>item.remove(),2800); }; + const updateKey="csr:data-updated"; + const publishUpdate=()=>{try{localStorage.setItem(updateKey,String(Date.now()));}catch{}}; + if(document.querySelector(".toast-stack .toast.success"))publishUpdate(); + if(document.querySelector("[data-live-refresh]")){ + const loadedAt=Date.now(); + let dirty=false,pending=false; + const markDirty=event=>{if(event.target.closest("[data-live-refresh] form[method=post]"))dirty=true;}; + document.addEventListener("input",markDirty); + document.addEventListener("change",markDirty); + const refreshIfSafe=()=>{if(document.visibilityState!=="visible"||dirty||document.querySelector(".editing, tr.selected"))return;if(pending||Date.now()-loadedAt>30000)window.location.reload();}; + window.addEventListener("storage",event=>{if(event.key===updateKey){pending=true;refreshIfSafe();}}); + document.addEventListener("visibilitychange",refreshIfSafe); + window.addEventListener("focus",refreshIfSafe); + } + const germanDate=value=>{const text=String(value||"").trim(),iso=text.match(/^(\d{4})-(\d{2})-(\d{2})$/);if(iso)return `${iso[3]}.${iso[2]}.${iso[1]}`;const digits=text.replace(/\D/g,"");return digits.length===8?`${digits.slice(0,2)}.${digits.slice(2,4)}.${digits.slice(4)}`:text;}; + const bindGermanDates=(root=document)=>root.querySelectorAll("[data-german-date]").forEach(input=>{if(input.dataset.dateBound)return;input.dataset.dateBound="1";input.value=germanDate(input.value);input.addEventListener("blur",()=>input.value=germanDate(input.value));});bindGermanDates(); + const typeFieldsScript=document.querySelector("#device-type-fields"); + if(typeFieldsScript){ + const typeFields=JSON.parse(typeFieldsScript.textContent); + document.querySelectorAll("[data-device-type-form]").forEach(form=>{ + const typeSelect=form.querySelector('[name="device_type"]'),section=form.querySelector("[data-hardware-section]"); + if(!typeSelect||!section)return; + const updateHardware=()=>{ + const noType=form.hasAttribute("data-hide-hardware-without-type")&&!typeSelect.value; + const visible=new Set(noType?[]:(typeFields[typeSelect.value]||typeFields[""]||[])); + section.hidden=noType; + const selectedLabel=section.querySelector("[data-selected-type-label]"); + if(selectedLabel)selectedLabel.textContent=typeSelect.value?`· ${typeSelect.selectedOptions[0]?.textContent.trim()||""}`:""; + section.querySelectorAll("[data-hardware-field]").forEach(wrapper=>{ + const active=visible.has(wrapper.dataset.hardwareField); + wrapper.hidden=!active; + wrapper.querySelectorAll("input,select,textarea").forEach(field=>field.disabled=!active||form.dataset.readonly==="true"); + }); + }; + typeSelect.addEventListener("change",updateHardware); + updateHardware(); + }); + } + document.querySelector("[data-sidebar-toggle]")?.addEventListener("click",()=>document.querySelector("#sidebar")?.classList.toggle("open")); + const filterPanel=document.querySelector("[data-filter-panel]"),filterToggle=document.querySelector("[data-filter-toggle]"); + const setFilterOpen=open=>{if(!filterPanel||!filterToggle)return;filterPanel.toggleAttribute("hidden",!open);filterToggle.setAttribute("aria-expanded",String(open));}; + filterToggle?.addEventListener("click",()=>setFilterOpen(filterPanel.hasAttribute("hidden"))); + document.querySelector("[data-filter-close]")?.addEventListener("click",()=>setFilterOpen(false)); + const updateMultiFilter=details=>{const selected=[...details.querySelectorAll('input[type="checkbox"]:checked')],value=details.querySelector("[data-multi-value]");if(!value)return;value.textContent=selected.length===0?"Alle":selected.length===1?selected[0].nextElementSibling.textContent:`${selected.length} ausgewählt`;}; + document.querySelectorAll("[data-multi-filter]").forEach(details=>{updateMultiFilter(details);details.addEventListener("change",()=>updateMultiFilter(details));}); + const filterForm=document.querySelector("#filter-form"),updateFilterCount=()=>{if(!filterForm)return;const ignored=new Set(["q","page_size","sort","dir","page","partial","exact"]),active=new Set();[...filterForm.elements].forEach(field=>{if(!field.name||ignored.has(field.name))return;const hasValue=(field.type==="checkbox"||field.type==="radio")?field.checked:Boolean(field.value);if(hasValue)active.add(field.name);});const badge=filterToggle?.querySelector(".filter-count");if(badge){badge.textContent=active.size;badge.toggleAttribute("hidden",active.size===0);}filterToggle?.classList.toggle("active",active.size>0);}; + filterForm?.addEventListener("input",updateFilterCount);filterForm?.addEventListener("change",updateFilterCount);updateFilterCount(); + const bindCpuAutocomplete=(root=document)=>root.querySelectorAll("[data-cpu-autocomplete]").forEach(input=>{if(input.dataset.cpuBound)return;input.dataset.cpuBound="1";input.setAttribute("role","combobox");input.setAttribute("aria-autocomplete","list");input.setAttribute("aria-expanded","false");const host=input.parentElement,menu=document.createElement("div");host.classList.add("cpu-autocomplete-host");menu.className="cpu-suggestions";menu.setAttribute("role","listbox");menu.hidden=true;input.insertAdjacentElement("afterend",menu);let timer,controller,items=[],active=-1;const close=()=>{menu.hidden=true;menu.replaceChildren();items=[];active=-1;input.setAttribute("aria-expanded","false");};const markActive=index=>{if(!items.length)return;active=(index+items.length)%items.length;items.forEach((item,itemIndex)=>item.classList.toggle("active",itemIndex===active));items[active].scrollIntoView({block:"nearest"});};const choose=value=>{input.value=value;input.dispatchEvent(new Event("change",{bubbles:true}));close();input.focus();};const load=async()=>{const term=input.value.trim();controller?.abort();if(!term){close();return;}controller=new AbortController();try{const response=await fetch(`/autocomplete/cpu/?${new URLSearchParams({q:term})}`,{signal:controller.signal}),data=await response.json();menu.replaceChildren();items=data.results.map((result,index)=>{const button=document.createElement("button"),label=document.createElement("strong");button.type="button";button.setAttribute("role","option");label.textContent=result.label;button.append(label);if(index===0){const hint=document.createElement("small");hint.textContent="Wahrscheinlichster Treffer";button.append(hint);}button.addEventListener("mousedown",event=>event.preventDefault());button.addEventListener("click",()=>choose(result.label));menu.append(button);return button;});active=items.length?0:-1;items.forEach((item,index)=>item.classList.toggle("active",index===0));menu.hidden=!items.length;input.setAttribute("aria-expanded",String(items.length>0));}catch(error){if(error.name!=="AbortError")close();}};input.addEventListener("input",()=>{clearTimeout(timer);timer=setTimeout(load,120);});input.addEventListener("focus",()=>{if(input.value.trim())load();});input.addEventListener("keydown",event=>{if(menu.hidden||!items.length)return;if(event.key==="ArrowDown"){event.preventDefault();markActive(active+1);}else if(event.key==="ArrowUp"){event.preventDefault();markActive(active-1);}else if(event.key==="Enter"){event.preventDefault();event.stopImmediatePropagation();choose(items[Math.max(active,0)].querySelector("strong").textContent);}else if(event.key==="Escape"){event.preventDefault();close();}});input.addEventListener("blur",()=>setTimeout(close,160));});bindCpuAutocomplete(); + document.querySelectorAll("[data-auto-submit]").forEach(el=>el.addEventListener("change",()=>{const size=Number(el.value);fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify({page_size:size})});el.form.submit();})); + document.addEventListener("submit", event => { + const form = event.target.closest?.("form[data-confirm]"); + if (!form || form.dataset.confirmed) return; + event.preventDefault(); + const overlay = document.createElement("div"), box = document.createElement("div"); + overlay.className = "drawer-backdrop"; + box.className = "confirm-box"; + const heading = document.createElement("h2"), message = document.createElement("p"); + heading.textContent = "Bitte bestätigen"; + message.textContent = form.dataset.confirm; + const actions = document.createElement("div"), cancel = document.createElement("button"), confirm = document.createElement("button"); + cancel.type = confirm.type = "button"; + cancel.className = "btn"; + confirm.className = "btn primary"; + cancel.textContent = "Abbrechen"; + confirm.textContent = form.dataset.confirmAction || "Bestätigen"; + actions.append(cancel, confirm); + box.append(heading, message, actions); + box.setAttribute("role", "dialog"); + box.setAttribute("aria-modal", "true"); + const close = () => { overlay.remove(); box.remove(); document.removeEventListener("keydown", onKey); }; + const onKey = event => { if (event.key === "Escape") close(); }; + overlay.onclick = cancel.onclick = close; + confirm.onclick = () => { form.dataset.confirmed = "1"; close(); form.requestSubmit(); delete form.dataset.confirmed; }; + document.addEventListener("keydown", onKey); + document.body.append(overlay, box); + cancel.focus(); + }); + let searchTimer,searchController;const refreshDeviceTable=async(form,exact=false)=>{const container=document.querySelector("#device-table-container"),params=new URLSearchParams(new FormData(form)),endpoint=form.getAttribute("action")||window.location.pathname;params.set("partial","1");exact&¶ms.get("q")?params.set("exact","1"):params.delete("exact");searchController?.abort();searchController=new AbortController();container?.setAttribute("aria-busy","true");try{const response=await fetch(`${endpoint}?${params.toString()}`,{signal:searchController.signal,headers:{"X-Requested-With":"XMLHttpRequest"}});if(!response.ok)throw new Error();container.innerHTML=await response.text();const total=container.querySelector("[data-table-total]")?.dataset.tableTotal;const totalLabel=document.querySelector("[data-total-count]");if(totalLabel&&total!==undefined)totalLabel.textContent=total;const browserParams=new URLSearchParams(params);browserParams.delete("partial");browserParams.delete("page");history.replaceState({},"",`${window.location.pathname}${browserParams.toString()?`?${browserParams.toString()}`:""}`);bindDeviceTable();}catch(error){if(error.name!=="AbortError")toast("Die Nummernsuche konnte nicht aktualisiert werden.","error");}finally{container?.removeAttribute("aria-busy");}};const numberSearch=document.querySelector("[data-search-input]");numberSearch?.addEventListener("input",event=>{event.target.value=event.target.value.replace(/\D/g,"");clearTimeout(searchTimer);searchTimer=setTimeout(()=>refreshDeviceTable(event.target.form),200);});numberSearch?.addEventListener("keydown",event=>{if(event.key==="Enter"){event.preventDefault();clearTimeout(searchTimer);refreshDeviceTable(event.target.form,true);}}); + let clickTimer; + const displayCell=(cell,display,color="")=>{const field=cell.dataset.field;if(["ram_gb","ssd_gb","hdd_gb"].includes(field)&&display)cell.textContent=`${display} GB`;else if(field==="price"&&display)cell.textContent=`${String(display).replace(".",",")} €`;else if(field==="display_inches"&&display)cell.textContent=`${String(display).replace(".",",")} Zoll`;else if(field==="status"){const badge=document.createElement("span");badge.className="status status-colored";badge.textContent=display||"—";badge.style.setProperty("--status-color",color||"#64748b");cell.replaceChildren(badge);}else cell.textContent=display||"—";}; + const editCell = async cell => { + if (cell.classList.contains("editing")) return; + cell.classList.add("editing"); + const original = cell.innerHTML, oldValue = cell.dataset.value || ""; + const row = cell.closest("tr"), expectedRevision = row.dataset.revision; + let input; + if (cell.dataset.input === "select") { + input = document.createElement("select"); + input.add(new Option("—", "")); + try { + const response = await fetch(`/autocomplete/${cell.dataset.field}/?q=`); + if (!response.ok) throw new Error(); + const data = await response.json(); + data.results.forEach(item => input.add(new Option(item.label, String(item.id)))); + // Retain inactive values and values outside any server-side result limit. + if (oldValue && ![...input.options].some(option => option.value === oldValue)) { + input.add(new Option(cell.textContent.trim(), oldValue)); + } + input.value = oldValue; + } catch { + cell.classList.remove("editing"); + toast("Auswahlwerte konnten nicht geladen werden.", "error"); + return; + } + } else if (cell.dataset.input === "tristate") { + input = document.createElement("select"); + [["YES", "JA"], ["NO", "NEIN"], ["UNKNOWN", "UNBEKANNT"]].forEach(([value, label]) => input.add(new Option(label, value, false, value === oldValue))); + } else { + input = document.createElement("input"); + input.type = cell.dataset.input === "number" ? "number" : "text"; + if (input.type === "number") input.step = cell.dataset.step || "1"; + input.value = cell.dataset.input === "date" ? germanDate(oldValue) : oldValue; + if (cell.dataset.input === "date") { + input.dataset.germanDate = ""; + input.placeholder = "TT.MM.JJJJ"; + input.inputMode = "numeric"; + } + if (cell.dataset.field === "cpu") { input.dataset.cpuAutocomplete = ""; input.autocomplete = "off"; } + } + cell.replaceChildren(input); + bindGermanDates(cell); + bindCpuAutocomplete(cell); + const initialValue = input.value; + input.focus(); + input.select?.(); + let finished = false; + const restore = () => { cell.classList.remove("editing"); cell.innerHTML = original; }; + const cancel = () => { if (!finished) { finished = true; restore(); } }; + const nextCell = () => { + const cells = [...row.querySelectorAll("td[data-field]")], next = cells[cells.indexOf(cell) + 1]; + if (next) editCell(next); + }; + const save = async (moveNext = false) => { + if (finished) return; + finished = true; + if (input.value === initialValue) { restore(); if (moveNext) nextCell(); return; } + try { + const response = await fetch(`/devices/${row.dataset.deviceId}/inline/`, { + method: "POST", headers: {"X-CSRFToken": csrftoken, "Content-Type": "application/json"}, + body: JSON.stringify({field: cell.dataset.field, value: input.value, expected_revision: expectedRevision}) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Speichern fehlgeschlagen"); + cell.dataset.value = data.value ?? ""; + row.dataset.updatedAt = data.updated_at; + row.dataset.revision = data.revision; + cell.classList.remove("editing"); + displayCell(cell, data.display, data.color); + toast("Gespeichert"); + publishUpdate(); + if (moveNext) nextCell(); + } catch (error) { restore(); toast(error.message, "error"); } + }; + input.addEventListener("keydown", event => { + if (event.key === "Enter") { event.preventDefault(); save(); } + else if (event.key === "Escape") { event.preventDefault(); cancel(); } + else if (event.key === "Tab") { event.preventDefault(); save(true); } + }); + input.addEventListener("blur", () => setTimeout(() => { if (!finished) save(); }, 220)); + }; + let tableBindingController; + const readJSON=(value,fallback)=>{try{return value?JSON.parse(value):fallback;}catch{return fallback;}}; + const savePreferences=payload=>fetch("/preferences/",{method:"POST",headers:{"X-CSRFToken":csrftoken,"Content-Type":"application/json"},body:JSON.stringify(payload)}).catch(()=>{}); + const reorderTableColumns=(table,order)=>table.querySelectorAll("tr").forEach(row=>{const cells=new Map([...row.children].filter(cell=>cell.dataset.column).map(cell=>[cell.dataset.column,cell]));order.forEach(key=>{const cell=cells.get(key);if(cell)row.append(cell);});}); + const bindDeviceTable=()=>{ + tableBindingController?.abort(); + tableBindingController=new AbortController(); + const {signal}=tableBindingController; + const container=document.querySelector("#device-table-container"); + container?.querySelectorAll("tr[data-device-id]").forEach(row=>{ + row.addEventListener("click",event=>{if(event.target.closest("td.editing,input,button,label,a"))return;clearTimeout(clickTimer);clickTimer=setTimeout(()=>{window.location.href=row.dataset.detailUrl;},220);},{signal}); + row.addEventListener("dblclick",()=>clearTimeout(clickTimer),{signal}); + }); + container?.querySelectorAll("td[data-field]").forEach(cell=>cell.addEventListener("dblclick",event=>{event.preventDefault();event.stopPropagation();clearTimeout(clickTimer);editCell(cell);},{signal})); + const table=container?.querySelector("[data-device-table]"),panel=document.querySelector("[data-columns-panel]"); + if(!table||!panel)return; + + const bulkForm=document.querySelector("[data-bulk-form]"); + if(bulkForm){ + const boxes=[...table.querySelectorAll("[data-device-select]")],selectAll=table.querySelector("[data-select-all]"),count=bulkForm.querySelector("[data-bulk-count]"),inputs=bulkForm.querySelector("[data-bulk-inputs]"),submit=bulkForm.querySelector("[data-bulk-submit]"),fields=[...bulkForm.querySelectorAll("[data-bulk-field]")]; + const returnQuery=bulkForm.querySelector('[name="return_query"]'); + if(returnQuery)returnQuery.value=window.location.search.replace(/^\?/,""); + const updateSelection=()=>{ + const selected=boxes.filter(box=>box.checked); + boxes.forEach(box=>box.closest("tr")?.classList.toggle("selected",box.checked)); + if(selectAll){selectAll.checked=Boolean(boxes.length)&&selected.length===boxes.length;selectAll.indeterminate=selected.length>0&&selected.length{const input=document.createElement("input");input.type="hidden";input.name="device_ids";input.value=box.value;return input;})); + bulkForm.hidden=selected.length===0; + submit.disabled=selected.length===0||!fields.some(field=>field.value); + }; + boxes.forEach(box=>box.addEventListener("change",updateSelection,{signal})); + selectAll?.addEventListener("change",()=>{boxes.forEach(box=>box.checked=selectAll.checked);updateSelection();},{signal}); + fields.forEach(field=>field.addEventListener("change",updateSelection,{signal})); + bulkForm.querySelector("[data-bulk-clear]")?.addEventListener("click",()=>{boxes.forEach(box=>box.checked=false);updateSelection();},{signal}); + boxes.forEach(box=>box.checked=false); + fields.forEach(field=>field.value=""); + updateSelection(); + } + + const userKey=document.body.dataset.userId||"guest"; + const orderStorageKey=`csr-column-order-${userKey}`; + const visibleStorageKey=`csr-visible-columns-${userKey}`; + const defaultColumns=[...table.querySelectorAll("thead th[data-column]")].map(th=>({key:th.dataset.column,label:th.textContent.trim()})); + const availableKeys=defaultColumns.map(column=>column.key); + const requiredPhotoColumns=["recipient","location","price","battery","lan","display_inches","donor","comment"]; + const serverOrder=readJSON(document.querySelector("#server-column-order")?.textContent,[]); + const localOrder=readJSON(localStorage.getItem(orderStorageKey),null); + const storedOrder=Array.isArray(localOrder)?localOrder:serverOrder; + const orderNeedsUpgrade=!storedOrder?.length||requiredPhotoColumns.some(key=>!storedOrder.includes(key)); + let order=orderNeedsUpgrade?[...availableKeys]:[...storedOrder.filter(key=>availableKeys.includes(key)),...availableKeys.filter(key=>!storedOrder.includes(key))]; + if(orderNeedsUpgrade){localStorage.setItem(orderStorageKey,JSON.stringify(order));savePreferences({column_order:order});} + reorderTableColumns(table,order); + + const columns=order.map(key=>defaultColumns.find(column=>column.key===key)).filter(Boolean); + const serverVisible=readJSON(document.querySelector("#server-visible-columns")?.textContent,[]); + const localVisible=readJSON(localStorage.getItem(visibleStorageKey),null); + const legacyVisible=readJSON(localStorage.getItem("csr-visible-columns"),null); + const storedVisible=Array.isArray(localVisible)?localVisible:(serverVisible.length?serverVisible:legacyVisible); + const visibilityUpgradeKey=`csr-complete-list-columns-${userKey}`; + const visible=new Set((storedVisible||availableKeys).filter(key=>availableKeys.includes(key))); + if(!localStorage.getItem(visibilityUpgradeKey)){ + requiredPhotoColumns.forEach(key=>visible.add(key)); + localStorage.setItem(visibilityUpgradeKey,"1"); + const migratedVisible=order.filter(key=>visible.has(key)); + localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible)); + savePreferences({visible_columns:migratedVisible}); + } + const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none")); + + panel.replaceChildren(); + const hint=document.createElement("p"); + hint.className="column-picker-hint"; + hint.textContent="Reihenfolge direkt im Tabellenkopf durch Ziehen ändern."; + panel.append(hint); + columns.forEach(column=>{ + const label=document.createElement("label"),check=document.createElement("input"); + label.dataset.column=column.key; + check.type="checkbox"; + check.checked=visible.has(column.key); + check.addEventListener("change",()=>{ + check.checked?visible.add(column.key):visible.delete(column.key); + const values=order.filter(key=>visible.has(key)); + localStorage.setItem(visibleStorageKey,JSON.stringify(values)); + savePreferences({visible_columns:values}); + applyVisibility(); + },{signal}); + label.append(check,column.label); + panel.append(label); + }); + applyVisibility(); + document.querySelector("[data-columns-toggle]").onclick=()=>panel.toggleAttribute("hidden"); + + const syncPickerOrder=()=>order.forEach(key=>{const label=panel.querySelector(`label[data-column="${key}"]`);if(label)panel.append(label);}); + const persistOrder=()=>{ + localStorage.setItem(orderStorageKey,JSON.stringify(order)); + savePreferences({column_order:order}); + syncPickerOrder(); + }; + const animateOrder=(nextOrder,visualBefore=null)=>{ + if(!visualBefore&&nextOrder.every((key,index)=>key===order[index]))return; + const headers=[...table.querySelectorAll("thead th[data-column]")]; + const before=visualBefore||new Map(headers.map(header=>[header.dataset.column,header.getBoundingClientRect().left])); + headers.forEach(header=>{header.style.transition="none";header.style.transform="";}); + order=nextOrder; + reorderTableColumns(table,order); + headers.forEach(header=>{ + const delta=(before.get(header.dataset.column)??header.getBoundingClientRect().left)-header.getBoundingClientRect().left; + if(!delta)return; + header.style.transition="none"; + header.style.transform=`translateX(${delta}px)`; + }); + table.getBoundingClientRect(); + requestAnimationFrame(()=>headers.forEach(header=>{ + header.style.transition="transform 180ms cubic-bezier(.2,.8,.2,1)"; + header.style.transform=""; + })); + setTimeout(()=>headers.forEach(header=>header.style.removeProperty("transition")),210); + }; + + let drag=null,suppressClick=false; + const beginDrag=(event,header)=>{ + const rect=header.getBoundingClientRect(); + drag={header,key:header.dataset.column,startX:event.clientX,startY:event.clientY,offsetX:event.clientX-rect.left,active:false,ghost:null,placeholder:null,pendingOrder:[...order],targetToken:""}; + }; + const moveDrag=event=>{ + if(!drag)return; + if(!drag.active&&Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<6)return; + if(!drag.active){ + drag.active=true; + suppressClick=true; + drag.ghost=document.createElement("div"); + drag.ghost.className="column-drag-ghost"; + drag.ghost.textContent=drag.header.textContent.trim(); + document.body.append(drag.ghost); + drag.placeholder=document.createElement("div"); + drag.placeholder.className="column-drop-placeholder"; + const sourceRect=drag.header.getBoundingClientRect(); + const visibleHeaders=order.map(key=>table.querySelector(`thead th[data-column="${key}"]`)).filter(header=>header&&getComputedStyle(header).display!=="none"); + drag.layout=new Map(visibleHeaders.map(header=>[header.dataset.column,{header,left:header.offsetLeft,width:header.getBoundingClientRect().width}])); + drag.baseStart=Math.min(...[...drag.layout.values()].map(item=>item.left)); + drag.sourceWidth=drag.layout.get(drag.key)?.width||sourceRect.width; + drag.placeholder.style.width=`${Math.max(44,drag.sourceWidth-8)}px`; + drag.placeholder.style.height=`${Math.max(24,sourceRect.height-8)}px`; + table.closest(".table-shell").append(drag.placeholder); + document.body.classList.add("column-dragging"); + table.classList.add("column-reordering"); + drag.header.classList.add("column-drag-source"); + drag.header.setAttribute("aria-grabbed","true"); + } + event.preventDefault(); + const ghostX=event.clientX-Math.min(drag.offsetX,Math.max(24,drag.header.getBoundingClientRect().width-24)); + drag.ghost.style.transform=`translate3d(${ghostX}px,${event.clientY-18}px,0)`; + const shell=table.closest(".table-shell"),shellRect=shell.getBoundingClientRect(),edge=52; + if(event.clientXshellRect.right-edge)shell.scrollLeft+=Math.ceil((event.clientX-(shellRect.right-edge))/7); + const visibleOrder=order.filter(key=>drag.layout.has(key)); + const otherKeys=visibleOrder.filter(key=>key!==drag.key); + const pointerX=event.clientX-shellRect.left+shell.scrollLeft-table.offsetLeft; + let cursor=drag.baseStart,beforeKey=null; + for(const key of otherKeys){ + const width=drag.layout.get(key).width; + if(pointerXkey!==drag.key); + let insertAt; + if(beforeKey)insertAt=nextOrder.indexOf(beforeKey); + else{ + const lastVisible=otherKeys.at(-1); + insertAt=lastVisible?nextOrder.indexOf(lastVisible)+1:nextOrder.length; + } + nextOrder.splice(Math.max(0,insertAt),0,drag.key); + drag.pendingOrder=nextOrder; + const pendingVisible=nextOrder.filter(key=>drag.layout.has(key)); + const targetLefts=new Map(); + cursor=drag.baseStart; + pendingVisible.forEach(key=>{targetLefts.set(key,cursor);cursor+=drag.layout.get(key).width;}); + drag.layout.forEach((item,key)=>{ + const delta=(targetLefts.get(key)??item.left)-item.left; + item.header.style.transform=delta?`translateX(${delta}px)`:""; + }); + const targetLeft=targetLefts.get(drag.key)??drag.layout.get(drag.key).left; + const headerRect=drag.header.getBoundingClientRect(); + drag.placeholder.style.left=`${table.offsetLeft+targetLeft+drag.sourceWidth/2}px`; + drag.placeholder.style.top=`${headerRect.top-shellRect.top+shell.scrollTop+4}px`; + const targetToken=beforeKey||"__end__"; + if(targetToken!==drag.targetToken){ + drag.targetToken=targetToken; + drag.placeholder.classList.remove("pop"); + void drag.placeholder.offsetWidth; + drag.placeholder.classList.add("pop"); + } + }; + const endDrag=event=>{ + if(!drag)return; + const {header,active,ghost,placeholder,pendingOrder}=drag; + drag=null; + if(active){ + const headers=[...table.querySelectorAll("thead th[data-column]")]; + const visualBefore=new Map(headers.map(item=>[item.dataset.column,item.getBoundingClientRect().left])); + ghost?.remove(); + placeholder?.remove(); + header.classList.remove("column-drag-source"); + header.setAttribute("aria-grabbed","false"); + table.classList.remove("column-reordering"); + document.body.classList.remove("column-dragging"); + if(event?.type!=="pointercancel"&&pendingOrder.some((key,index)=>key!==order[index])){ + animateOrder(pendingOrder,visualBefore); + persistOrder(); + }else animateOrder(order,visualBefore); + setTimeout(()=>suppressClick=false,0); + requestAnimationFrame(()=>header.focus({preventScroll:true})); + } + }; + table.addEventListener("click",event=>{if(suppressClick){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;}},{capture:true,signal}); + window.addEventListener("pointermove",moveDrag,{signal}); + window.addEventListener("pointerup",endDrag,{signal}); + window.addEventListener("pointercancel",endDrag,{signal}); + table.querySelectorAll("thead th[data-column]").forEach(header=>{ + header.tabIndex=0; + header.setAttribute("aria-grabbed","false"); + header.title=`${header.textContent.trim()} verschieben: ziehen oder Alt + Pfeiltaste verwenden`; + header.querySelectorAll("a").forEach(link=>link.draggable=false); + header.addEventListener("pointerdown",event=>{const rect=header.getBoundingClientRect();if(event.button!==0||rect.right-event.clientX<10)return;beginDrag(event,header);},{signal}); + header.addEventListener("keydown",event=>{ + if(!event.altKey||!["ArrowLeft","ArrowRight"].includes(event.key))return; + event.preventDefault(); + const visibleOrder=order.filter(key=>visible.has(key)),index=visibleOrder.indexOf(header.dataset.column),swapIndex=index+(event.key==="ArrowLeft"?-1:1); + if(index<0||swapIndex<0||swapIndex>=visibleOrder.length)return; + const nextOrder=[...order],otherKey=visibleOrder[swapIndex],first=nextOrder.indexOf(header.dataset.column),second=nextOrder.indexOf(otherKey); + [nextOrder[first],nextOrder[second]]=[nextOrder[second],nextOrder[first]]; + animateOrder(nextOrder); + persistOrder(); + requestAnimationFrame(()=>header.focus({preventScroll:true})); + },{signal}); + }); + }; + bindDeviceTable(); + document.querySelectorAll(".toast-stack .toast").forEach(item=>setTimeout(()=>item.remove(),3500));const initialDevice=document.querySelector("#device-table-container")?.dataset.initialDevice;if(initialDevice){const row=document.querySelector(`tr[data-device-id="${CSS.escape(initialDevice)}"]`);if(row)window.location.href=row.dataset.detailUrl;} +})(); diff --git a/inventory/static/inventory/js/theme.js b/inventory/static/inventory/js/theme.js new file mode 100644 index 0000000..669e3fe --- /dev/null +++ b/inventory/static/inventory/js/theme.js @@ -0,0 +1,39 @@ +(() => { + const key = "csr-theme"; + const systemTheme = () => window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + const savedTheme = () => { + try { + const value = localStorage.getItem(key); + return value === "dark" || value === "light" ? value : null; + } catch { + return null; + } + }; + const applyTheme = theme => { + document.documentElement.dataset.theme = theme; + document.querySelectorAll("[data-theme-toggle]").forEach(button => { + const dark = theme === "dark"; + button.setAttribute("aria-pressed", String(dark)); + button.setAttribute("aria-label", dark ? "Hellmodus einschalten" : "Dunkelmodus einschalten"); + const icon = button.querySelector("[data-theme-icon]"); + const label = button.querySelector("[data-theme-label]"); + if (icon) icon.textContent = dark ? "☀" : "☾"; + if (label) label.textContent = dark ? "Hellmodus" : "Dunkelmodus"; + }); + }; + applyTheme(savedTheme() || systemTheme()); + document.addEventListener("DOMContentLoaded", () => { + applyTheme(document.documentElement.dataset.theme); + document.querySelectorAll("[data-theme-toggle]").forEach(button => button.addEventListener("click", () => { + const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + try { localStorage.setItem(key, theme); } catch {} + applyTheme(theme); + })); + }); + window.addEventListener("storage", event => { + if (event.key === key) applyTheme(savedTheme() || systemTheme()); + }); + window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => { + if (!savedTheme()) applyTheme(systemTheme()); + }); +})(); diff --git a/inventory/templates/403.html b/inventory/templates/403.html new file mode 100644 index 0000000..c071a11 --- /dev/null +++ b/inventory/templates/403.html @@ -0,0 +1 @@ +{% extends 'inventory/base.html' %}{% block title %}Keine Berechtigung{% endblock %}{% block content %}

Keine Berechtigung

Ihr Konto darf diese Aktion nicht ausführen. Wenden Sie sich bei Bedarf an einen Administrator.

Zur Geräteliste
{% endblock %} diff --git a/inventory/templates/404.html b/inventory/templates/404.html new file mode 100644 index 0000000..57dc9cf --- /dev/null +++ b/inventory/templates/404.html @@ -0,0 +1 @@ +{% extends 'inventory/base.html' %}{% block title %}Nicht gefunden{% endblock %}{% block content %}

Seite nicht gefunden

Die angeforderte Seite oder das Gerät existiert nicht mehr.

Zur Geräteliste
{% endblock %} diff --git a/inventory/templates/500.html b/inventory/templates/500.html new file mode 100644 index 0000000..50795a2 --- /dev/null +++ b/inventory/templates/500.html @@ -0,0 +1,13 @@ +{% load static %} + + + + + + Technischer Fehler · CS-R + + + + +

Das hat nicht funktioniert

Es ist ein technischer Fehler aufgetreten. Bitte versuchen Sie es erneut. Falls der Fehler bleibt, informieren Sie einen Administrator.

Erneut versuchen
+ diff --git a/inventory/templates/inventory/_device_table.html b/inventory/templates/inventory/_device_table.html new file mode 100644 index 0000000..36e07ee --- /dev/null +++ b/inventory/templates/inventory/_device_table.html @@ -0,0 +1,61 @@ +{% load inventory_tags %} +
+ + + {% if can_edit_devices %}{% endif %} + + + + + + + + + + + {% if "battery" in visible_hardware_fields %} {% endif %} + {% if "wifi" in visible_hardware_fields %} {% endif %} + {% if "lan" in visible_hardware_fields %} {% endif %} + {% if "display_inches" in visible_hardware_fields %} {% endif %} + {% if "ram_gb" in visible_hardware_fields %} {% endif %} + {% if "ssd_gb" in visible_hardware_fields %} {% endif %} + {% if "hdd_gb" in visible_hardware_fields %} {% endif %} + {% if "cpu" in visible_hardware_fields %} {% endif %} + + + + + {% for field in custom_columns %}{% endfor %} + + + {% for device in page_obj %} + + {% if can_edit_devices %}{% endif %} + + + + + + + + + + + {% if "battery" in visible_hardware_fields %} {% if "battery" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "wifi" in visible_hardware_fields %} {% if "wifi" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "lan" in visible_hardware_fields %} {% if "lan" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "display_inches" in visible_hardware_fields %} {% if "display_inches" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "ram_gb" in visible_hardware_fields %} {% if "ram_gb" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "ssd_gb" in visible_hardware_fields %} {% if "ssd_gb" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "hdd_gb" in visible_hardware_fields %} {% if "hdd_gb" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + {% if "cpu" in visible_hardware_fields %} {% if "cpu" in device.hardware_fields %}{% else %}{% endif %}{% endif %} + + + + + {% for field in custom_columns %}{% endfor %} + + {% empty %}{% endfor %} + +
Nr.RegalArbeitsschrittStatusDatumEmpfängerOrtTicketTypPreisAkkuWLANLANZollRAMSSDHDDCPUModellBearbeiterSpenderKommentar{{ field.name }}
{{ device.number }}{{ device.shelf|default:'—' }}{{ device.get_stage_display }}{{ device.status.label }}{{ device.date|date:'d.m.Y' }}{{ device.recipient.label|default:'—' }}{{ device.location.label|default:'—' }}{{ device.ticket|default:'—' }}{{ device.device_type.label|default:'—' }}{% if device.price != None %}{{ device.price|floatformat:2 }} €{% else %}—{% endif %}{{ device.get_battery_display }}—{{ device.get_wifi_display }}—{{ device.get_lan_display }}—{% if device.display_inches != None %}{{ device.display_inches|floatformat:-1 }} Zoll{% else %}—{% endif %}—{% if device.ram_gb %}{{ device.ram_gb }} GB{% else %}—{% endif %}—{% if device.ssd_gb != None %}{{ device.ssd_gb }} GB{% else %}—{% endif %}—{% if device.hdd_gb != None %}{{ device.hdd_gb }} GB{% else %}—{% endif %}—{{ device.cpu|default:'—' }}—{{ device.model|default:'—' }}{{ device.employee.code|default:'—' }}{{ device.donor|default:'—' }}{{ device.comment|default:'—' }}{{ device.custom_values|get_item:field.key|display_custom|default:'—' }}
Keine Geräte gefundenFilter anpassen oder ein neues Gerät anlegen.
+ diff --git a/inventory/templates/inventory/archive_list.html b/inventory/templates/inventory/archive_list.html new file mode 100644 index 0000000..5ba60fa --- /dev/null +++ b/inventory/templates/inventory/archive_list.html @@ -0,0 +1 @@ +{% extends 'inventory/base.html' %}{% block title %}Archiv{% endblock %}{% block content %}

Archiv

{{ total_count }} archivierte Geräte

{% for device in page_obj %}{% empty %}{% endfor %}
Nr.StatusTypModellArchiviert
{{ device.number }}{{ device.status }}{{ device.device_type|default:'—' }}{{ device.model|default:'—' }}{{ device.archived_at|date:'d.m.Y H:i' }}
{% csrf_token %}
Das Archiv ist leer.
{% endblock %} diff --git a/inventory/templates/inventory/audit_list.html b/inventory/templates/inventory/audit_list.html new file mode 100644 index 0000000..0553698 --- /dev/null +++ b/inventory/templates/inventory/audit_list.html @@ -0,0 +1,5 @@ +{% extends 'inventory/base.html' %}{% block title %}Änderungshistorie{% endblock %}{% block content %} +

Änderungshistorie

Nachvollziehbare Änderungen an allen Geräten

+
+
{% for log in page_obj %}
{% if log.action == 'CREATE' %}+{% elif log.action == 'ARCHIVE' %}□{% elif log.action == 'RESTORE' %}↺{% else %}↻{% endif %}
Gerät #{{ log.device.number }}{{ log.created_at|date:'d.m.Y H:i' }}

{{ log.field|default:log.get_action_display }}{% if log.old_value or log.new_value %}: {{ log.old_value|default:'leer' }} → {{ log.new_value|default:'leer' }}{% endif %}

{{ log.user.get_full_name|default:log.user.username|default:'System' }}{% if log.api_client %} · API: {{ log.api_client }}{% endif %}{% if log.reverted_at %}Zurückgesetzt am {{ log.reverted_at|date:'d.m.Y H:i' }}{% elif can_revert_audits and log.action == 'UPDATE' %}
{% csrf_token %}
{% endif %}
{% empty %}
Noch keine Änderungen protokolliert.
{% endfor %}
+{% endblock %} diff --git a/inventory/templates/inventory/base.html b/inventory/templates/inventory/base.html new file mode 100644 index 0000000..fbffaed --- /dev/null +++ b/inventory/templates/inventory/base.html @@ -0,0 +1,49 @@ +{% load static %} + + + + + + + {% block title %}Geräteverwaltung{% endblock %} · CS-R + + + + + + + +
+
CS-R Geräte
+ {% if messages %}
{% for message in messages %}
{{ message }}
{% endfor %}
{% endif %} + {% block content %}{% endblock %} +
+
+ + diff --git a/inventory/templates/inventory/configuration.html b/inventory/templates/inventory/configuration.html new file mode 100644 index 0000000..d0d6ac7 --- /dev/null +++ b/inventory/templates/inventory/configuration.html @@ -0,0 +1,21 @@ +{% extends 'inventory/base.html' %} +{% block title %}Werte & Felder{% endblock %} +{% block content %} +

Werte & Felder

Bearbeiter, Auswahllisten und Zusatzfelder verwalten

+
+
+

Bearbeiter

Namen und Kürzel für die Geräteerfassung.

+ Bearbeiter
+
+ {% for employee in employees %}{% empty %}

Noch keine Bearbeiter vorhanden.

{% endfor %} +
+
+
+

Auswahlwerte

Deaktivieren statt löschen – bestehende Verknüpfungen bleiben erhalten.

+ Wert
+
{% regroup choices by get_kind_display as grouped %}{% for group in grouped %}

{{ group.grouper }}

{% for item in group.list %}{% if item.kind == 'status' %}{% endif %}{{ item.label }}{% if item.numeric_value != None %} · {{ item.numeric_value }}{% endif %}{% if item.active %}Aktiv{% else %}Inaktiv{% endif %}{% endfor %}{% endfor %}
+
+
+

Benutzerdefinierte Felder

Neue Felder ohne Datenbankmigration hinzufügen.

+ Feld
+ +
+
+{% endblock %} diff --git a/inventory/templates/inventory/device_detail.html b/inventory/templates/inventory/device_detail.html new file mode 100644 index 0000000..015e7c0 --- /dev/null +++ b/inventory/templates/inventory/device_detail.html @@ -0,0 +1,37 @@ +{% extends 'inventory/base.html' %}{% load inventory_tags %}{% block title %}Gerät #{{ device.number }}{% endblock %}{% block content %} +
← Zur Geräteliste

Gerät #{{ device.number }}

Zuletzt geändert {{ device.updated_at|date:'d.m.Y H:i' }}

+
+

Ablauf: {{ device.get_stage_display }}

+

Eingang → Inspektion → bei Bedarf Reparatur → Lagerung → Ausgabe

+ {% if can_edit_devices and not device.archived_at and next_stages %} +
+ {% csrf_token %} + + +
{% endif %} +
{% for event in workflow_events %}
{{ event.created_at|date:'d.m.Y H:i' }}{{ event.get_from_stage_display }} → {{ event.get_to_stage_display }}{{ event.note }}{% if event.user %} · {{ event.user.username }}{% endif %}
{% empty %}

Noch keine Schritte erfasst.

{% endfor %}
+ {% if inspections %}

Inspektionsbefunde

{% for record in inspections %}
{{ record.created_at|date:'d.m.Y H:i' }}{{ record.defects|linebreaksbr }}{% if record.recorded_by %}{{ record.recorded_by.username }}{% endif %}
{% endfor %}
{% endif %} + {% if repairs %}

Reparaturen

{% for record in repairs %}
{{ record.created_at|date:'d.m.Y H:i' }}{{ record.work_done|linebreaksbr }}{% if record.recorded_by %}{{ record.recorded_by.username }}{% endif %}
{% endfor %}
{% endif %} +
+

Aufgaben

+ {% for task in tasks %}
{{ task.title }} · {{ task.get_state_display }}{% if task.due_date %} · fällig {{ task.due_date|date:'d.m.Y' }}{% endif %}{% if task.assigned_to %} · {{ task.assigned_to.username }}{% endif %}{% if task.description %}

{{ task.description|linebreaksbr }}

{% endif %} + {% if can_edit_devices and task.state == 'OPEN' %}
{% csrf_token %}
{% endif %}
{% empty %}

Keine Aufgaben vorhanden.

{% endfor %} + {% if can_edit_devices and not device.archived_at and device.stage != 'ISSUED' %}
+ {% csrf_token %} + + + +
{% endif %} +
+
+
{% csrf_token %}{{ form.non_field_errors }} + {% for item in ram_suggestions %}{% for item in ssd_suggestions %}{% for item in hdd_suggestions %}{% for item in display_suggestions %} +

Allgemein

{% for field in form %}{% if field.name == 'shelf' or field.name == 'status' or field.name == 'date' or field.name == 'device_type' or field.name == 'model' %}{% endif %}{% endfor %}
+

Hardware

{% for field in form %}{% if field.name in all_hardware_fields %}{% endif %}{% endfor %}
+

Verwaltung

{% for field in form %}{% if field.name == 'ticket' or field.name == 'employee' or field.name == 'recipient' or field.name == 'location' or field.name == 'donor' or field.name == 'price' %}{% endif %}{% endfor %}
+

Notizen

{% for field in form %}{% if field.name == 'comment' %}{% endif %}{% endfor %}
+ {% if definitions %}

Zusatzfelder

{% for field in definitions %}{% endfor %}
{% endif %} +
Abbrechen{% if can_edit_devices %}{% endif %}
+
+{{ type_field_map|json_script:'device-type-fields' }} +
{% endblock %} diff --git a/inventory/templates/inventory/device_list.html b/inventory/templates/inventory/device_list.html new file mode 100644 index 0000000..fcd9491 --- /dev/null +++ b/inventory/templates/inventory/device_list.html @@ -0,0 +1,113 @@ +{% extends 'inventory/base.html' %} +{% load inventory_tags %} +{% block body_class %}device-list-page{% endblock %} +{% block title %}Geräte{% endblock %} +{% block content %} +
+

Geräte

{{ total_count }} Einträge

+
+ + {% if can_edit_devices %}+ Neues Gerät{% endif %} +
+
+ + +
+ + + + + + +
+

Geräte filtern

Mehrere Angaben lassen sich miteinander kombinieren.

+
+
+

Einordnung

Status, Geräteart und Zuständigkeit

+
+ +
StatusAlle
{% for option in statuses %}{% endfor %}
+
TypAlle
{% for option in types %}{% endfor %}
+
BearbeiterAlle
{% for option in employees %}{% endfor %}
+
EmpfängerAlle
{% for option in recipients %}{% endfor %}
+
+
+ +
+

Zeitraum und Preis

Eingangsdatum und erfasster Gerätepreis

+
+ + + + +
+
+ +
+

Technische Daten

Speicher und Bildschirmgröße eingrenzen

+
+
RAM in GB
+
SSD in GB
+
HDD in GB
+
Bildschirm in Zoll
+
+
+ +
+

Prozessor

Nach Hersteller, Leistungsklasse oder konkretem Modell filtern

+
+
HerstellerAlle
{% for value, label in cpu_vendors %}{% endfor %}
+
CPU-KlasseAlle
{% for value, label in cpu_families %}{% endfor %}
+ +
+
+ +
+

Ausstattung und Stichwörter

Verbindungen und Textangaben durchsuchen

+
+ + + + + +
+
+ + {% if custom_filters %}

Zusatzfelder

{% for field in custom_filters %}{% with param='custom_'|add:field.key %}{% endwith %}{% endfor %}
{% endif %} +
+ +
+
+{% if can_edit_devices %} + +{% endif %} +
{% include 'inventory/_device_table.html' %}
+{{ preference.visible_columns|json_script:"server-visible-columns" }} +{{ preference.column_order|json_script:"server-column-order" }} +{% endblock %} diff --git a/inventory/templates/inventory/import_map.html b/inventory/templates/inventory/import_map.html new file mode 100644 index 0000000..7aa7f18 --- /dev/null +++ b/inventory/templates/inventory/import_map.html @@ -0,0 +1,20 @@ +{% extends 'inventory/base.html' %} +{% load inventory_tags %} +{% block title %}Spalten zuordnen{% endblock %} +{% block content %} +
+
← Andere Datei

Spalten zuordnen

{{ job.original_name }}

+
+
{% csrf_token %} +
+ {% for header in job.headers %} + + {% endfor %} +
+

Vorschau

+
{% for header in job.headers %}{% endfor %}{% for row in job.preview_rows %}{% for cell in row %}{% endfor %}{% endfor %}
{{ header }}
{{ cell|default:'—' }}
+
Abbrechen
+
+{% endblock %} diff --git a/inventory/templates/inventory/import_upload.html b/inventory/templates/inventory/import_upload.html new file mode 100644 index 0000000..55ace49 --- /dev/null +++ b/inventory/templates/inventory/import_upload.html @@ -0,0 +1 @@ +{% extends 'inventory/base.html' %}{% block title %}Import / Export{% endblock %}{% block content %}

Import / Export

Excel-Dateien kontrolliert und transaktional übernehmen.

Excel importieren

  1. Datei hochladen
  2. Spalten zuordnen
  3. Import bestätigen
{% csrf_token %}{{ form.non_field_errors }}{% for field in form %}{% endfor %}

Direkt exportieren

Die vollständige Geräteliste als weiterverwendbare Datei laden.

{% endblock %} diff --git a/inventory/templates/inventory/overview.html b/inventory/templates/inventory/overview.html new file mode 100644 index 0000000..ee4ec03 --- /dev/null +++ b/inventory/templates/inventory/overview.html @@ -0,0 +1,59 @@ +{% extends 'inventory/base.html' %} +{% block title %}Übersicht{% endblock %} +{% block content %} +
+

Übersicht

Bestand, Eingänge und Datenqualität auf einen Blick

+ +
+ +
+
Aktive Geräte{{ active_count }}derzeit im Bestand
+
Neueingänge{{ period_count }}{{ period_start|date:'d.m.Y' }} bis {{ today|date:'d.m.Y' }}{% if difference > 0 %}+{% endif %}{{ difference }} zum vorherigen Zeitraum
+
Davon Laptops{{ period_laptops }}in den letzten {{ period_months }} Monaten
+
Davon fertig{{ period_finished }}aktuell mit Status „Fertig“
+
Im Archiv{{ archived_count }}historisch abgelegte Geräte
+
+ +
+
+

Geräteeingänge

Erfasste Geräte pro Monat · letzte 12 Monate

{{ maximum_month }}Höchstwert
+
+ + Geräteeingänge der letzten zwölf Monate + + {{ maximum_month }}{{ half_month }}0 + + {% for item in month_series %}{{ item.label }}: {{ item.count }} Geräte{% endfor %} + +
{% for item in month_series %}{{ item.label }}{% endfor %}
+
+
+ +
+

Gerätearten

Neueingänge im gewählten Zeitraum

+
{% for item in type_rows %}
{{ item.label }}{{ item.count }}
{% empty %}

In diesem Zeitraum gibt es keine Eingänge.

{% endfor %}
+
+ +
+

Statusverteilung

Alle aktiven Geräte

+
+
{{ status_total }}Geräte
+
{% for item in status_rows %}
{{ item.label }}{{ item.count }} {{ item.percentage }} %
{% empty %}

Keine aktiven Geräte vorhanden.

{% endfor %}
+
+
+ +
+

Häufigste Empfänger

Neueingänge im gewählten Zeitraum

+
{% for item in recipient_rows %}
{{ item.label }}{{ item.count }}
{% empty %}

Noch keine Empfänger im Zeitraum erfasst.

{% endfor %}
+
+ +
+

Datenvollständigkeit

Anteil ausgefüllter Angaben bei aktiven Geräten

+
{% for item in completeness %}
{{ item.label }}{{ item.percentage }} %
{% endfor %}
+
+
+{% endblock %} diff --git a/inventory/templates/inventory/quick_add.html b/inventory/templates/inventory/quick_add.html new file mode 100644 index 0000000..b825496 --- /dev/null +++ b/inventory/templates/inventory/quick_add.html @@ -0,0 +1,13 @@ +{% extends 'inventory/base.html' %}{% block title %}Neues Gerät{% endblock %}{% block content %} +

Neues Gerät

Typ auswählen und die passenden Daten direkt erfassen.

+
+ {% csrf_token %}{{ form.non_field_errors }} + {% for field in form %}{% if field.name not in all_hardware_fields %}{% endif %}{% endfor %} + +
Abbrechen
+
+{{ type_field_map|json_script:'device-type-fields' }} +{% endblock %} diff --git a/inventory/templates/inventory/simple_form.html b/inventory/templates/inventory/simple_form.html new file mode 100644 index 0000000..da30b55 --- /dev/null +++ b/inventory/templates/inventory/simple_form.html @@ -0,0 +1,17 @@ +{% extends 'inventory/base.html' %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +
+
← Zurück

{{ title }}

+
{% csrf_token %}{{ form.non_field_errors }} + {% for field in form %} + {% if field.name == 'color' %} +
{{ field.label }}
{% for option in field %}{% endfor %}
{% if field.help_text %}{{ field.help_text }}{% endif %}{{ field.errors }}
+ {% else %} + {{ field.label }}{{ field }}{% if field.help_text %}{{ field.help_text }}{% endif %}{{ field.errors }} + {% endif %} + {% endfor %} +
Abbrechen
+
+
+{% endblock %} diff --git a/inventory/templates/inventory/task_list.html b/inventory/templates/inventory/task_list.html new file mode 100644 index 0000000..304c8e5 --- /dev/null +++ b/inventory/templates/inventory/task_list.html @@ -0,0 +1,8 @@ +{% extends 'inventory/base.html' %}{% block title %}Aufgaben{% endblock %}{% block content %} +

Aufgaben

Offene Arbeiten an Geräten im Überblick.

+

Offen Erledigt

+{% for task in tasks %}

Gerät #{{ task.device.number }} · {{ task.title }}

+

{{ task.device.get_stage_display }}{% if task.assigned_to %} · {{ task.assigned_to.username }}{% endif %}{% if task.due_date %} · fällig {{ task.due_date|date:'d.m.Y' }}{% endif %}

+{% if task.description %}

{{ task.description|linebreaksbr }}

{% endif %} +{% if can_edit_devices and task.state == 'OPEN' %}
{% csrf_token %}
{% endif %} +
{% empty %}

Keine Aufgaben vorhanden.

{% endfor %}
{% endblock %} diff --git a/inventory/templates/inventory/user_list.html b/inventory/templates/inventory/user_list.html new file mode 100644 index 0000000..ecee367 --- /dev/null +++ b/inventory/templates/inventory/user_list.html @@ -0,0 +1,4 @@ +{% extends 'inventory/base.html' %}{% block title %}Benutzer{% endblock %}{% block content %} +

Benutzer

Konten und Rollen der Geräteverwaltung

+ Benutzer
+
{% for user in users %}{% empty %}{% endfor %}
BenutzernameNameE-MailRolleStatus
{{ user.username }}{{ user.get_full_name|default:'—' }}{{ user.email|default:'—' }}{{ user.profile.get_role_display }}{% if user.is_active %}Aktiv{% else %}Inaktiv{% endif %}Bearbeiten
Keine Benutzer vorhanden.
+{% endblock %} diff --git a/inventory/templates/inventory/workflow_board.html b/inventory/templates/inventory/workflow_board.html new file mode 100644 index 0000000..1197db8 --- /dev/null +++ b/inventory/templates/inventory/workflow_board.html @@ -0,0 +1,42 @@ +{% extends 'inventory/base.html' %}{% block title %}Ablauf{% endblock %}{% block content %} +

Ablauf

Geräte prüfen, reparieren, lagern und ausgeben.

Alle Aufgaben
+
+ {% for column in columns %}
+

{{ column.label }}

{{ column.total }}
+
+ {% for device in column.devices %}
+ +

{{ device.status.label }}{% if device.employee %} · {{ device.employee.code }}{% endif %}{% if device.shelf %} · Regal {{ device.shelf }}{% endif %}

+ {% if device.stage == 'INSPECTION' %} + {% with latest=device.inspection_history.0 %}{% if latest %}

Letzter Befund: {{ latest.defects|truncatechars:180 }}{{ latest.created_at|date:'d.m.Y H:i' }}{% if latest.recorded_by %} · {{ latest.recorded_by.username }}{% endif %}

{% endif %}{% endwith %} + {% if can_edit_devices %}
+ {% csrf_token %} +

Inspektion erfassen

+
{% for field in device.stage_form.specification_fields %}{% endfor %}
+ +
{% endif %} + {% elif device.stage == 'REPAIR' %} + {% with latest=device.repair_history.0 %}{% if latest %}

Letzte Reparatur: {{ latest.work_done|truncatechars:180 }}{{ latest.created_at|date:'d.m.Y H:i' }}{% if latest.recorded_by %} · {{ latest.recorded_by.username }}{% endif %}

{% endif %}{% endwith %} + {% if can_edit_devices %}
+ {% csrf_token %} +

Reparatur dokumentieren

+ +
{% endif %} + {% elif device.stage == 'STORAGE' %} + {% if can_edit_devices %}
+ {% csrf_token %} +

Ausgabe vorbereiten

+ +
{% elif device.recipient %}

Empfänger: {{ device.recipient.label }}

{% endif %} + {% endif %} +
{{ device.open_task_count }} offen{% for task in device.open_tasks|slice:':2' %}{{ task.title }}{% endfor %}
+ {% if can_edit_devices %} + {% if device.stage != 'ISSUED' %}
{% csrf_token %}
{% endif %} + {% if device.next_stages %}
{% csrf_token %}
{% endif %} + {% endif %} +
{% empty %}

Keine Geräte

{% endfor %} + {% if column.total > 30 %}

Die neuesten 30 von {{ column.total }} Geräten. Alle anzeigen

{% endif %} +
+
{% endfor %} +
+{% endblock %} diff --git a/inventory/templates/registration/login.html b/inventory/templates/registration/login.html new file mode 100644 index 0000000..6dabe65 --- /dev/null +++ b/inventory/templates/registration/login.html @@ -0,0 +1,5 @@ +{% load static %} +Anmelden · CS-R +
CS-R

Geräteverwaltung

Melden Sie sich mit Ihrem internen Konto an.

+{% if form.errors %}
Benutzername oder Passwort ist nicht korrekt.
{% endif %} +
{% csrf_token %}
diff --git a/inventory/templatetags/__init__.py b/inventory/templatetags/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/inventory/templatetags/__init__.py @@ -0,0 +1 @@ + diff --git a/inventory/templatetags/inventory_tags.py b/inventory/templatetags/inventory_tags.py new file mode 100644 index 0000000..eee574b --- /dev/null +++ b/inventory/templatetags/inventory_tags.py @@ -0,0 +1,66 @@ +from datetime import date, datetime + +from django import template + +register = template.Library() + + +@register.filter +def get_item(mapping, key): + return (mapping or {}).get(key, "") + + +@register.simple_tag(takes_context=True) +def query_replace(context, **kwargs): + query = context["request"].GET.copy() + for key, value in kwargs.items(): + query[key] = value + return query.urlencode() + + +@register.simple_tag +def option_selected(request, key, value): + return "selected" if str(value) in request.GET.getlist(key) else "" + + +@register.simple_tag +def option_checked(request, key, value): + return "checked" if str(value) in request.GET.getlist(key) else "" + + +@register.simple_tag(takes_context=True) +def sort_query(context, key): + query = context["request"].GET.copy() + current = query.get("sort") + direction = query.get("dir", "desc") + query["sort"] = key + query["dir"] = "desc" if current == key and direction == "asc" else "asc" + query.pop("page", None) + return query.urlencode() + + +@register.filter +def contains_value(values, value): + return value in (values or []) + + +@register.filter +def display_custom(value): + if isinstance(value, list): + return ", ".join(str(item) for item in value) + if value is True: + return "JA" + if value is False: + return "NEIN" + return value + + +@register.filter +def german_date(value): + if isinstance(value, (date, datetime)): + return value.strftime("%d.%m.%Y") + text = str(value or "") + try: + return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y") + except ValueError: + return text diff --git a/inventory/test_migration.py b/inventory/test_migration.py new file mode 100644 index 0000000..9bb4d68 --- /dev/null +++ b/inventory/test_migration.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path +import tempfile + +from django.contrib.auth.models import User +from django.core.management import CommandError +from django.test import TestCase + +from .management.commands.import_sqlite_export import Command +from .models import ChoiceValue, Device, NumberSequence, UserProfile + + +class MigrationImportTests(TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.fixture = Path(self.temporary.name) / "export.json" + + def write_fixture(self, objects): + self.fixture.write_text(json.dumps(objects), encoding="utf-8") + + def test_import_accepts_migration_defaults_and_preserves_ids_and_roles(self): + # Fresh databases have these rows from migration 0003. Fixture IDs differ. + ChoiceValue.objects.all().delete() + for order, label in enumerate(("Bildschirm", "Drucker"), 3): + ChoiceValue.objects.create(kind="device_type", label=label, sort_order=order) + stamp = "2026-09-23T10:00:00Z" + self.write_fixture([ + {"model": "auth.user", "pk": 50, "fields": {"username": "migrated", "password": "!", "is_active": True}}, + {"model": "inventory.userprofile", "pk": 60, "fields": {"user": ["migrated"], "role": "EDITOR", "created_at": stamp, "updated_at": stamp}}, + {"model": "inventory.choicevalue", "pk": 70, "fields": {"kind": "status", "label": "Imported", "created_at": stamp, "updated_at": stamp}}, + {"model": "inventory.choicevalue", "pk": 80, "fields": {"kind": "device_type", "label": "Bildschirm", "created_at": stamp, "updated_at": stamp}}, + {"model": "inventory.device", "pk": 90, "fields": {"number": 2000, "status": 70, "device_type": 80, "created_at": stamp, "updated_at": stamp, "revision": 1}}, + ]) + self.assertEqual(Command().load_export(self.fixture, verbosity=0), 1) + self.assertEqual(User.objects.get(username="migrated").pk, 50) + self.assertEqual(UserProfile.objects.get(user_id=50).role, "EDITOR") + self.assertEqual(Device.objects.get(pk=90).number, 2000) + self.assertEqual(NumberSequence.objects.get(name="device").last_value, 2000) + + def test_import_refuses_existing_business_data(self): + user = User.objects.create_user("existing") + self.write_fixture([]) + with self.assertRaises(CommandError): + Command().load_export(self.fixture, verbosity=0) + self.assertTrue(User.objects.filter(pk=user.pk).exists()) + + def test_bad_fixture_rolls_back_default_removal_and_import(self): + before = list(ChoiceValue.objects.values_list("pk", "label")) + self.write_fixture([{"model": "inventory.unknown", "pk": 1, "fields": {}}]) + with self.assertRaises(CommandError): + Command().load_export(self.fixture, verbosity=0) + self.assertEqual(list(ChoiceValue.objects.values_list("pk", "label")), before) diff --git a/inventory/test_security.py b/inventory/test_security.py new file mode 100644 index 0000000..3b6af4f --- /dev/null +++ b/inventory/test_security.py @@ -0,0 +1,161 @@ +import csv +import json +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO, StringIO +from threading import Barrier +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.core.management import call_command +from django.db import close_old_connections +from django.test import Client, TestCase, TransactionTestCase, skipUnlessDBFeature +from django.urls import reverse +from django.utils import timezone +from openpyxl import load_workbook + +from .models import AuditLog, ChoiceValue, Device, Employee, InventoryAPIKey, UserPreference +from .services import coerce_inline_value + + +class SecurityRegressionTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user("security-admin", is_superuser=True) + cls.status = ChoiceValue.objects.create(kind="status", label="Test") + + def setUp(self): + self.client.force_login(self.user) + self.device = Device.objects.create(status=self.status, ram_gb=4) + + def post_inline(self, payload): + return self.client.post(reverse("inventory:inline-update", args=[self.device.pk]), + json.dumps(payload), content_type="application/json") + + def test_revisions_and_legacy_timestamps_change_on_a_frozen_clock(self): + old_time = self.device.updated_at + with patch("inventory.models.timezone.now", return_value=old_time): + self.device.ram_gb = 8 + self.device.save(update_fields=["ram_gb"]) + self.assertEqual(self.device.revision, 2) + self.assertGreater(self.device.updated_at, old_time) + response = self.post_inline({"field": "ram_gb", "value": 16, "expected_revision": 1}) + self.assertEqual(response.status_code, 409) + response = self.post_inline({"field": "ram_gb", "value": 16, "expected_updated_at": old_time.isoformat()}) + self.assertEqual(response.status_code, 409) + self.device.refresh_from_db() + self.assertEqual(self.device.ram_gb, 8) + + def test_detail_rejects_stale_and_missing_versions(self): + self.device.ram_gb = 8 + self.device.save() + for token in ({"expected_revision": 1}, {}): + response = self.client.post(reverse("inventory:device-detail", args=[self.device.pk]), { + "status": self.status.pk, "date": "23.09.2026", "ram_gb": 16, + "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", **token, + }) + self.assertEqual(response.status_code, 302) + self.device.refresh_from_db() + self.assertEqual(self.device.ram_gb, 8) + self.assertFalse(AuditLog.objects.filter(device=self.device).exists()) + + def test_malformed_inline_payloads_return_400(self): + for payload in ([], None, {"field": "ram_gb", "value": 8}, + {"field": [], "value": 8, "expected_revision": 1}, + {"field": "price", "value": "NaN", "expected_revision": 1}, + {"field": "price", "value": "Infinity", "expected_revision": 1}): + with self.subTest(payload=payload): + self.assertEqual(self.post_inline(payload).status_code, 400) + + def test_integer_fields_do_not_silently_truncate(self): + for value in (1.5, "NaN", "Infinity", True): + with self.subTest(value=value), self.assertRaises(ValidationError): + coerce_inline_value("ram_gb", value) + + def test_invalid_filters_do_not_raise_server_errors(self): + for query in ("status=abc", "ram_min=abc", "price_min=NaN", "price_min=Infinity", "employee=9999999999999999999999999"): + with self.subTest(query=query): + response = self.client.get(reverse("inventory:device-list") + "?" + query) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["page_obj"].paginator.count, 0) + + def test_invalid_preferences_are_rejected_without_persisting(self): + for payload in ([], {"page_size": []}, {"column_order": "number"}, + {"visible_columns": ["