Compare commits

..

4 Commits

Author SHA1 Message Date
dee25b5cbb Merge remote-tracking branch 'origin/master' into feature/usb-aio-workflow
Some checks failed
Tests / postgres (push) Has been cancelled
Tests / postgres (pull_request) Has been cancelled
# Conflicts:
#	README.md
#	inventory/test_specification_migration.py
2026-10-14 20:04:37 +02:00
72138203be Align deployment access with inventar user and remove retired CA
Some checks failed
Tests / postgres (push) Has been cancelled
2026-10-13 10:58:19 +02:00
048cec333e Fix device issues and document current LAN deployment 2026-10-13 10:58:18 +02:00
35f88746ff Deleted local cert
Some checks failed
Tests / postgres (push) Has been cancelled
2026-10-13 08:53:41 +00:00
25 changed files with 723 additions and 52 deletions

View File

@@ -37,3 +37,6 @@ jobs:
node-version: "22" node-version: "22"
- run: npm install --prefix /tmp/csr-js-tests jsdom@26 - run: npm install --prefix /tmp/csr-js-tests jsdom@26
- run: node scripts/test_frontend.cjs /tmp/csr-js-tests/node_modules/jsdom - run: node scripts/test_frontend.cjs /tmp/csr-js-tests/node_modules/jsdom
- run: pip install playwright
- run: python -m playwright install --with-deps chromium --only-shell
- run: python scripts/test_browser.py

View File

@@ -23,6 +23,12 @@ Produktionsnahe interne Geräteverwaltung als kompakter Django-Monolith. Die Anw
- Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS - Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS
- Healthchecks, persistente Volumes und einfache Backup-/Restore-Skripte - Healthchecks, persistente Volumes und einfache Backup-/Restore-Skripte
## Anleitung für den LAN-Server
Für Kollegen: [Lokales Deployment und Serverbetrieb](docs/LOCAL_DEPLOY.md).
Die Anleitung enthält Zugang, Update mit Backup, Prüfung, Fehlerbehebung und
Datenbankzugriff für den LAN-Server.
## Lokale Entwicklung ## Lokale Entwicklung
Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose. Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose.
@@ -339,3 +345,22 @@ The USB station resolves hardware identity with `POST /api/v1/devices/resolve/`,
`POST /api/v1/devices/<number>/inspection/` accepts a UUID `request_id`, `hwid`, `expected_revision`, phase (`STARTED`, `IN_PROGRESS`, `COMPLETE`), technician, notes and a dictionary of test results. Each result preserves outcome, notes, evidence and attempt history. The API moves allowed devices to `INSPECTION`, sets status to `Inspektion`, adds a readable inspection record and retains the structured snapshot. A unique request ID prevents duplicate records after lost responses; reused IDs with different content are rejected. Inventory workflow checks, hardware matching and API-key permissions remain enforced. Completed inspections require a named technician and no unfinished results; unavailable tests remain distinct from hardware failures. `POST /api/v1/devices/<number>/inspection/` accepts a UUID `request_id`, `hwid`, `expected_revision`, phase (`STARTED`, `IN_PROGRESS`, `COMPLETE`), technician, notes and a dictionary of test results. Each result preserves outcome, notes, evidence and attempt history. The API moves allowed devices to `INSPECTION`, sets status to `Inspektion`, adds a readable inspection record and retains the structured snapshot. A unique request ID prevents duplicate records after lost responses; reused IDs with different content are rejected. Inventory workflow checks, hardware matching and API-key permissions remain enforced. Completed inspections require a named technician and no unfinished results; unavailable tests remain distinct from hardware failures.
Apply migrations through `0015_inspectionsubmission` before deploying the new USB application. Wi-Fi credentials and inventory API keys stay on USB persistent storage, not in this repository. Apply migrations through `0015_inspectionsubmission` before deploying the new USB application. Wi-Fi credentials and inventory API keys stay on USB persistent storage, not in this repository.
## Korrekturen für die Geräteansicht (Migration 0014)
Die Geräteliste bleibt auch bei kleinen oder niedrigen Fenstern scrollbar. Neue
Zusatzspalten werden bei vorhandenen Spalteneinstellungen automatisch eingeblendet;
anschließend können sie weiterhin über die Spaltenauswahl ausgeblendet werden.
Gespeicherte JA/NEIN-Werte und Zahlenwerte wie `0` bleiben beim Bearbeiten sichtbar.
Laptop und AiO bieten jetzt `camera` (Kamera) und `sound` (Sound). Bildschirme
bieten `hdmi`, `vga`, `dvi`, `displayport` (DP) sowie `aspect_ratio`
(Seitenverhältnis, beispielsweise `16:9`). Die Ausstattungsfelder verwenden
`YES`, `NO` oder `UNKNOWN` und sind in der Geräteliste filterbar. Sie stehen auch
in Erfassung, Inspektion, API und Tabellenimport/-export zur Verfügung.
Beim Update `python manage.py migrate` ausführen; Docker erledigt dies beim
Containerstart. Bestehende Anschlussnotizen bleiben erhalten. Neue Auswahlfelder
starten mit „UNBEKANNT“, da Freitext keine verlässliche JA/NEIN-Zuordnung erlaubt.
Die Gitea-Pipeline prüft zusätzlich die Browserabläufe einschließlich kleiner
Bildschirme und benutzerdefinierter Spalten.

240
docs/LOCAL_DEPLOY.md Normal file
View File

@@ -0,0 +1,240 @@
# Lokales Deployment der Inventarverwaltung
Diese Anleitung enthält die benötigten Schritte für Kollegen, die die bestehende
LAN-Installation aktualisieren oder administrativ auf die Datenbank zugreifen.
## Zugang
| Zweck | Adresse / Wert |
| --- | --- |
| Anwendung | **https://192.168.178.17/** |
| Anmeldung | https://192.168.178.17/accounts/login/ |
| SSH-Server | `192.168.178.17`, Port `22` |
| SSH-Benutzer | `inventar` |
| Projektverzeichnis | `/home/inventar/inventar` |
| Git-Branch | `master` |
| Dienste | `web` (Django), `db` (PostgreSQL), `traefik` (HTTPS) |
Voraussetzung ist Zugang zum Servernetz sowie ein freigegebener SSH-Zugang.
Anmeldedaten und gegebenenfalls die benötigten Administratorrechte bei der
zuständigen Administration anfordern. Anwendungskonto, SSH-Zugang und
Datenbankpasswort sind getrennt.
## Installation aktualisieren
Ein kurzes Wartungsfenster einplanen: Beim Neustart ist die Anwendung
vorübergehend nicht erreichbar. Bei einem Fehler zuerst dessen Ursache klären;
nicht mit dem nächsten Schritt fortfahren.
### 1. Auf dem Server anmelden
Im Terminal des eigenen Arbeitsplatzes:
```sh
ssh inventar@192.168.178.17
```
Alle folgenden Shell-Befehle werden **auf dem Server** ausgeführt:
```sh
cd /home/inventar/inventar
pwd
git status --short --branch
git branch --show-current
docker compose ps
docker compose config --quiet
```
Erwartet werden Branch `master` und die Dienste `db`, `web`, `traefik`.
`config --quiet` prüft die Compose-Konfiguration, ohne die aufgelösten
Passwörter auszugeben. Docker und Git müssen mit einem dafür berechtigten Konto
bedient werden. Falls dafür eine Root-Sitzung vorgesehen ist, `su -` ausführen
und danach erneut `cd /home/inventar/inventar` eingeben.
Lokale Änderungen vor dem Update klären. Nicht mit `git reset --hard` oder
`git clean` entfernen. Eine lokale `docker-compose.override.yml` kann zur
Serverkonfiguration gehören und muss erhalten bleiben.
### 2. Backup erstellen
```sh
sh scripts/backup.sh
git rev-parse HEAD
```
Das Skript erstellt und prüft eine komprimierte PostgreSQL-Sicherung unter
`/home/inventar/inventar/backups/`. Es meldet den genauen Dateinamen. Diesen
zusammen mit der ausgegebenen Git-Revision für eine mögliche Wiederherstellung
notieren. Nur nach erfolgreicher Sicherung fortfahren.
Das Skript sichert ausschließlich die Datenbank. `.env`, die lokale
Compose-Konfiguration, `tls/` und die Uploads im Docker-Volume `media_data`
gehören ebenfalls zur Installation und müssen separat geschützt gesichert
werden. Regelmäßige Sicherungen zusätzlich auf einem anderen System aufbewahren.
### 3. Aktualisieren und starten
Den gesamten Block im Projektverzeichnis ausführen. Bei einem Fehler beendet
`set -eu` den Block, bevor weitere Schritte ausgeführt werden.
```sh
(
set -eu
git pull --ff-only origin master
docker compose config --quiet
docker compose build web
docker compose up -d --wait --wait-timeout 180 web traefik
)
```
`--ff-only` verhindert unbeabsichtigte Merge-Commits auf dem Server. Meldet Git
abweichende lokale Änderungen oder eine abweichende Historie, diese mit der
Administration klären.
Beim Web-Containerstart werden Datenbankmigrationen automatisch angewendet,
Auswahlwerte ergänzt und statische Dateien gesammelt. Ein zusätzliches
`python manage.py migrate` auf dem Host ist nicht nötig. Migration **0014**
ergänzt Kamera/Sound für Laptop und AiO sowie Monitoranschlüsse und
Seitenverhältnis. Bestehende Anschlussnotizen bleiben erhalten; neue
JA/NEIN-Felder beginnen mit „UNBEKANNT“.
`.env`, Zertifikate und persistente Volumes bei Updates beibehalten.
**Kein `docker compose down -v` verwenden:** Dieser Befehl löscht die
Datenbank- und Upload-Volumes.
### 4. Update prüfen
```sh
docker compose ps
docker compose logs --tail 100 web traefik
docker compose exec -T web python manage.py check --deploy
docker compose exec -T web python manage.py migrate --check
docker compose exec -T web python manage.py showmigrations inventory
curl --fail --show-error --cacert tls/ca/root.crt https://192.168.178.17/health/
```
Erwartet werden gesunde Dienste, keine ausstehenden Migrationen und ein
erfolgreicher Healthcheck. Angewendete Migrationen tragen in der Liste `[X]`;
für diesen Stand muss auch `0014` markiert sein. Warnungen und Fehler vor dem
Abschluss des Updates prüfen.
Anschließend im Browser anmelden und prüfen:
- Geräteliste und ein bestehendes Gerät öffnen sich mit den gespeicherten Daten.
- Die Liste lässt sich auch im kleinen Fenster scrollen.
- Benutzerdefinierte Spalten und gespeicherte JA/NEIN-Werte erscheinen korrekt.
- Hardwarefelder passen zum Gerätetyp; Monitoranschlüsse lassen sich filtern.
- Ablauf, Aufgaben und Archiv öffnen sich.
Für fortlaufende Logs:
```sh
docker compose logs -f web
```
Mit `Strg+C` nur die Logansicht beenden; die Container laufen weiter.
Den eingesetzten Git-Stand und das Prüfergebnis im Betriebsprotokoll festhalten.
## HTTPS und Zertifikate
Der Browser muss der lokalen Zertifizierungsstelle vertrauen. Das aktuelle
öffentliche Stammzertifikat liegt bei der LAN-Konfiguration auf dem Server unter
`/home/inventar/inventar/tls/ca/root.crt`. Eine bestätigte Kopie von der
Administration beziehen. Nicht ungeprüft die CA einer früheren Installation
verwenden und niemals den privaten Schlüssel `root.key` verteilen.
Den SHA-256-Fingerabdruck auf dem Server anzeigen und mit der Administration
abgleichen:
```sh
openssl x509 -in tls/ca/root.crt -noout -subject -dates -fingerprint -sha256
```
Auf einem Windows-Arbeitsplatz die bestätigte Datei als `root.crt` speichern
und in PowerShell aus deren Verzeichnis importieren:
```powershell
Import-Certificate -FilePath .\root.crt -CertStoreLocation Cert:\CurrentUser\Root
```
Browser anschließend neu starten. Browser mit eigenem Zertifikatsspeicher
benötigen den Import dort ebenfalls. Bleibt eine Warnung bestehen, zusätzlich
prüfen, ob das Serverzertifikat für `192.168.178.17` ausgestellt wurde.
Die TLS-Routine lässt sich im Projektverzeichnis durch ein berechtigtes Konto
aufrufen:
```sh
python3 scripts/renew_local_tls.py --hostname 192.168.178.17
```
Bei eingerichteter automatischer Erneuerung die tatsächlich installierte Unit
kontrollieren:
```sh
systemctl cat csr-inventory-tls.service
systemctl list-timers csr-inventory-tls.timer
journalctl -u csr-inventory-tls.service --no-pager
```
**Die mitgelieferte Service-Vorlage enthält noch den früheren Pfad
`/home/inventar/csr-inventory`.** Bei ihrer Installation `WorkingDirectory`,
`EnvironmentFile` und `ExecStart` auf `/home/inventar/inventar` anpassen.
Die Server-`.env` muss `APP_HOST=192.168.178.17` enthalten; ebenso müssen
`ALLOWED_HOSTS` und `CSRF_TRUSTED_ORIGINS` die aktuelle Adresse berücksichtigen.
## Fehlerbehebung und Wiederherstellung
| Problem | Nächster Schritt |
| --- | --- |
| SSH nicht erreichbar | Servernetz/VPN, IP und Port 22 prüfen. |
| Docker-Zugriff verweigert | Berechtigtes Verwaltungskonto verwenden; nach einem Benutzerwechsel wieder ins Projektverzeichnis wechseln. |
| Git-Update scheitert | `git status` prüfen und lokale Änderungen mit der Administration klären. |
| Start dauert länger als 180 Sekunden | Logs prüfen; das Zeitlimit allein sagt nicht, ob Migrationen bereits angewendet wurden. |
| Migration oder Webstart scheitert | `docker compose logs --tail 200 web db` prüfen; Datenbank und Volumes erhalten. |
| HTTPS/Anmeldung schlägt fehl | Zertifikatsvertrauen und aktuelle IP in `.env` prüfen. |
| Oberfläche zeigt alte Dateien | Erfolgreichen Build prüfen und Browser vollständig neu laden. |
Ein einfaches Zurücksetzen des Codes macht bereits ausgeführte
Datenbankmigrationen nicht rückgängig. Für eine Wiederherstellung müssen
Git-Revision, Datenbanksicherung und Konfiguration zusammenpassen.
`scripts/restore.sh` erwartet eine **leere Zieldatenbank** und überschreibt
bestehende Tabellen nicht. Eine Sicherung zuerst in einer getrennten Testinstanz
mit eigenen Volumes und eigener Konfiguration prüfen. Dort lautet der Aufruf
mit dem tatsächlich ausgewählten Dateinamen:
```sh
sh scripts/restore.sh backups/backup_DATUM_UHRZEIT.sql.gz
```
Eine produktive Wiederherstellung mit der Administration abstimmen; Änderungen
seit dem Backup können verloren gehen. Keine produktive Datenbank für einen
Restore-Test löschen.
## Datenbankzugriff mit DBeaver
PostgreSQL wird auf dem Server ausschließlich an `127.0.0.1:15432` gebunden.
Für DBeaver eine PostgreSQL-Verbindung mit diesen Angaben anlegen:
| Einstellung | Wert |
| --- | --- |
| Datenbankhost | `127.0.0.1` |
| Datenbankport | `15432` |
| Datenbank | `csr_inventory` oder `POSTGRES_DB` aus `.env` |
| Datenbankbenutzer | `csr_inventory` oder `POSTGRES_USER` aus `.env` |
| Datenbankpasswort | `POSTGRES_PASSWORD` aus der geschützten Server-`.env` |
| SSH-Tunnel | aktivieren |
| SSH-Host / Port | `192.168.178.17` / `22` |
| SSH-Benutzer | `inventar` |
| SSH-Authentifizierung | freigegebener SSH-Schlüssel oder SSH-Passwort |
Die Verbindung über die Host-Felder konfigurieren, nicht über eine abweichende
fest eingetragene JDBC-URL. Der SSH-Tunnel erreicht den lokalen Datenbankport;
eine direkte LAN-Freigabe für PostgreSQL ist nicht erforderlich.
Das Datenbankpasswort steht in `/home/inventar/inventar/.env` und ist getrennt
vom SSH-Passwort. Nur berechtigte Administratoren sollen diese Datei einsehen.
Passwörter nicht in Screenshots, Tickets oder Git übernehmen. Normale Gerätepflege
erfolgt über die Anwendung, damit Rechteprüfung und Änderungshistorie erhalten
bleiben.

View File

@@ -1,11 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBojCCAUmgAwIBAgIQUS4ubv+VPmHfsOZnHA55kDAKBggqhkjOPQQDAjAwMS4w
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X
DTI2MDkyMzE3MjEyMVoXDTM2MDgwMTE3MjEyMVowMDEuMCwGA1UEAxMlQ2FkZHkg
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
SM49AwEHA0IABNfvDgBuLlx110lhzZllEhvq9OJ9y/BxFUJnR8RK5idzm1R9H/U/
PlszTxrP5QkxaJgBgjecOJS+19ziBEYdBXyjRTBDMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSUYvaXe7IkfVqEf49jgl5npdiw
BTAKBggqhkjOPQQDAgNHADBEAiBjTG79jTLkoYy5yhXPPbegqDlpt0+sWrp71ZZc
f/hkXgIgLG0JFVISRSCzm+bM5DM/iAwX4nyNrGKoVGuRaNtlD6w=
-----END CERTIFICATE-----

View File

@@ -1,4 +1,9 @@
# Serverbetrieb auf 192.168.188.45 # Historische Betriebsnotizen: 192.168.188.45
Die aktuelle Arbeitsanleitung für Kollegen steht vollständig in
[LOCAL_DEPLOY.md](LOCAL_DEPLOY.md). Die folgenden Abschnitte dokumentieren
frühere Installationen und Prüfungen; sie bestätigen nicht den heutigen
Serverzustand.
Stand: 26. September 2026. Die Anwendung wurde auf dem Debian-Server `homelab` Stand: 26. September 2026. Die Anwendung wurde auf dem Debian-Server `homelab`
installiert und gegen PostgreSQL geprüft. installiert und gegen PostgreSQL geprüft.
@@ -18,12 +23,15 @@ Gunicorn sind nicht direkt über veröffentlichte Docker-Ports erreichbar.
## HTTPS im lokalen Netzwerk ## HTTPS im lokalen Netzwerk
Traefik verwendet für die private IP-Adresse eine lokale Zertifizierungsstelle. Traefik verwendet für die private IP-Adresse eine lokale Zertifizierungsstelle.
Das öffentliche Stammzertifikat liegt in [homelab-root-ca.crt](homelab-root-ca.crt) Das damalige öffentliche Stammzertifikat wurde als `homelab-root-ca.crt`
und auf dem Server unter `/home/denis/csr-inventory-root-ca.crt`. verteilt und lag auf dem Server unter `/home/denis/csr-inventory-root-ca.crt`.
Die frühere Repository-Kopie wurde entfernt; für die aktuelle Installation
das in [LOCAL_DEPLOY.md](LOCAL_DEPLOY.md) beschriebene Zertifikat beziehen.
Damit Windows-Browser dem Zertifikat vertrauen, kann es für den aktuellen Benutzer Damit Windows-Browser dem Zertifikat vertrauen, kann es für den aktuellen Benutzer
importiert werden. Dieser Schritt wurde auf dem Arbeitsplatz **nicht** automatisch importiert werden. Dieser Schritt wurde auf dem Arbeitsplatz **nicht** automatisch
ausgeführt. Im lokalen Projektverzeichnis: ausgeführt. Das damalige Importbeispiel setzte eine separat bereitgestellte Zertifikatsdatei
im lokalen Verzeichnis `docs/` voraus:
```powershell ```powershell
Import-Certificate -FilePath .\docs\homelab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root Import-Certificate -FilePath .\docs\homelab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root

View File

@@ -38,6 +38,14 @@ class EmployeeSummarySerializer(serializers.ModelSerializer):
class DeviceSerializer(serializers.ModelSerializer): class DeviceSerializer(serializers.ModelSerializer):
camera = serializers.CharField(read_only=True)
sound = serializers.CharField(read_only=True)
hdmi = serializers.CharField(read_only=True)
vga = serializers.CharField(read_only=True)
dvi = serializers.CharField(read_only=True)
displayport = serializers.CharField(read_only=True)
aspect_ratio = serializers.CharField(read_only=True)
battery = serializers.CharField(read_only=True) battery = serializers.CharField(read_only=True)
wifi = serializers.CharField(read_only=True) wifi = serializers.CharField(read_only=True)
lan = serializers.CharField(read_only=True) lan = serializers.CharField(read_only=True)
@@ -64,7 +72,7 @@ class DeviceSerializer(serializers.ModelSerializer):
"number", "stage", "shelf", "status", "date", "recipient", "location", "ticket", "number", "stage", "shelf", "status", "date", "recipient", "location", "ticket",
"device_type", "price", "battery", "wifi", "lan", "display_inches", "device_type", "price", "battery", "wifi", "lan", "display_inches",
"ram_gb", "ssd_gb", "hdd_gb", "cpu", "hwid", "ports", "resolution", "print_technology", "ram_gb", "ssd_gb", "hdd_gb", "cpu", "hwid", "ports", "resolution", "print_technology",
"duplex", "color_printing", "model", "employee", "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport", "model", "employee",
"donor", "comment", "archived_at", "created_at", "updated_at", "revision", "donor", "comment", "archived_at", "created_at", "updated_at", "revision",
] ]
@@ -88,6 +96,13 @@ def active_choice_field(kind, *, allow_null=True):
class DeviceChangesSerializer(StrictSerializer): class DeviceChangesSerializer(StrictSerializer):
camera = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
sound = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
hdmi = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
vga = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
dvi = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
displayport = serializers.ChoiceField(choices=Device.TriState.choices, required=False)
aspect_ratio = serializers.CharField(max_length=20, required=False, allow_blank=True)
shelf = serializers.CharField(max_length=80, required=False, allow_blank=True) shelf = serializers.CharField(max_length=80, required=False, allow_blank=True)
status = active_choice_field(ChoiceValue.Kind.STATUS, allow_null=False) status = active_choice_field(ChoiceValue.Kind.STATUS, allow_null=False)
date = serializers.DateField(required=False) date = serializers.DateField(required=False)

View File

@@ -3,18 +3,19 @@
COMPUTER_FIELDS = frozenset({ COMPUTER_FIELDS = frozenset({
"cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports", "cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports",
}) })
HARDWARE_FIELDS = COMPUTER_FIELDS | {"resolution", "print_technology", "duplex", "color_printing"} TRISTATE_FIELDS = frozenset({"battery", "wifi", "lan", "duplex", "color_printing", "camera", "sound", "hdmi", "vga", "dvi", "displayport"})
HARDWARE_FIELDS = COMPUTER_FIELDS | {"resolution", "print_technology", "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport"}
TYPE_FIELDS = { TYPE_FIELDS = {
"laptop": COMPUTER_FIELDS, "laptop": COMPUTER_FIELDS | {"camera", "sound"},
"aio": COMPUTER_FIELDS - {"battery"}, "aio": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"},
"desktop": COMPUTER_FIELDS - {"battery", "display_inches"}, "desktop": COMPUTER_FIELDS - {"battery", "display_inches"},
"bildschirm": frozenset({"display_inches", "resolution", "ports"}), "bildschirm": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}),
"monitor": frozenset({"display_inches", "resolution", "ports"}), "monitor": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}),
"drucker": frozenset({"wifi", "lan", "ports", "print_technology", "duplex", "color_printing"}), "drucker": frozenset({"wifi", "lan", "ports", "print_technology", "duplex", "color_printing"}),
"printer": 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"}) | {"camera", "sound"},
"all in one": COMPUTER_FIELDS - {"battery"}, "all in one": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"},
} }
@@ -38,7 +39,7 @@ def clear_inapplicable(device):
if value not in (None, "", "UNKNOWN"): if value not in (None, "", "UNKNOWN"):
old_values[name] = value old_values[name] = value
legacy[name] = str(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) setattr(device, name, "UNKNOWN" if name in TRISTATE_FIELDS else "" if name in {"cpu", "hwid", "ports", "resolution", "print_technology", "aspect_ratio"} else None)
if old_values: if old_values:
device.custom_values = {**device.custom_values, "legacy_hardware": legacy} device.custom_values = {**device.custom_values, "legacy_hardware": legacy}
return old_values return old_values

View File

@@ -5,7 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from .models import ChoiceValue, Device, Employee, FieldDefinition, FieldOption, UserProfile from .models import ChoiceValue, Device, Employee, FieldDefinition, FieldOption, UserProfile
from .device_types import HARDWARE_FIELDS, applicable_fields from .device_types import HARDWARE_FIELDS, TRISTATE_FIELDS, applicable_fields
GERMAN_DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"] GERMAN_DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y-%m-%d"]
@@ -45,7 +45,7 @@ class QuickAddForm(forms.Form):
selected_type = self.fields["device_type"].queryset.filter(pk=self.data["device_type"]).first() selected_type = self.fields["device_type"].queryset.filter(pk=self.data["device_type"]).first()
for name in sorted(HARDWARE_FIELDS): for name in sorted(HARDWARE_FIELDS):
field = deepcopy(DeviceForm.base_fields[name]) field = deepcopy(DeviceForm.base_fields[name])
if name in {"battery", "wifi", "lan", "duplex", "color_printing"}: if name in TRISTATE_FIELDS:
field.required = False field.required = False
field.initial = Device.TriState.UNKNOWN field.initial = Device.TriState.UNKNOWN
if self.is_bound and name not in applicable_fields(selected_type): if self.is_bound and name not in applicable_fields(selected_type):
@@ -60,7 +60,7 @@ class QuickAddForm(forms.Form):
values = {} values = {}
for name in applicable_fields(self.cleaned_data["device_type"]): for name in applicable_fields(self.cleaned_data["device_type"]):
value = self.cleaned_data[name] value = self.cleaned_data[name]
if name in {"battery", "wifi", "lan", "duplex", "color_printing"} and not value: if name in TRISTATE_FIELDS and not value:
value = Device.TriState.UNKNOWN value = Device.TriState.UNKNOWN
values[name] = value values[name] = value
return values return values
@@ -88,6 +88,13 @@ class QuickAddForm(forms.Form):
class DeviceForm(forms.ModelForm): class DeviceForm(forms.ModelForm):
camera = forms.ChoiceField(label="Kamera", choices=Device.TriState.choices, required=False)
sound = forms.ChoiceField(label="Sound", choices=Device.TriState.choices, required=False)
hdmi = forms.ChoiceField(label="HDMI", choices=Device.TriState.choices, required=False)
vga = forms.ChoiceField(label="VGA", choices=Device.TriState.choices, required=False)
dvi = forms.ChoiceField(label="DVI", choices=Device.TriState.choices, required=False)
displayport = forms.ChoiceField(label="DP", choices=Device.TriState.choices, required=False)
aspect_ratio = forms.CharField(label="Seitenverhältnis", max_length=20, required=False, help_text="z. B. 16:9, 4:3 oder 21:9")
battery = forms.ChoiceField(label="Akku", choices=Device.TriState.choices, required=False) battery = forms.ChoiceField(label="Akku", choices=Device.TriState.choices, required=False)
wifi = forms.ChoiceField(label="WLAN", 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) lan = forms.ChoiceField(label="LAN", choices=Device.TriState.choices, required=False)
@@ -162,7 +169,7 @@ class DeviceForm(forms.ModelForm):
device = super().save(commit=False) device = super().save(commit=False)
for name in applicable_fields(device.device_type): for name in applicable_fields(device.device_type):
value = self.cleaned_data[name] value = self.cleaned_data[name]
if name in {"battery", "wifi", "lan", "duplex", "color_printing"} and not value: if name in TRISTATE_FIELDS and not value:
value = Device.TriState.UNKNOWN value = Device.TriState.UNKNOWN
setattr(device, name, value) setattr(device, name, value)
if commit: if commit:
@@ -179,11 +186,18 @@ class InspectionForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.device = device self.device = device
self.initial.setdefault("model", device.model) 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"): for name in ("cpu", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "hwid", "ports", "resolution", "print_technology", "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport"):
if name in applicable_fields(device.device_type): if name in applicable_fields(device.device_type):
self.fields[name] = deepcopy(DeviceForm.base_fields[name]) self.fields[name] = deepcopy(DeviceForm.base_fields[name])
self.initial.setdefault(name, getattr(device, name)) self.initial.setdefault(name, getattr(device, name))
def clean(self):
cleaned = super().clean()
for name in TRISTATE_FIELDS & self.fields.keys():
if not cleaned.get(name):
cleaned[name] = Device.TriState.UNKNOWN
return cleaned
@property @property
def specification_fields(self): def specification_fields(self):
return [self[name] for name in self.fields if name != "defects"] return [self[name] for name in self.fields if name != "defects"]

View File

@@ -14,11 +14,18 @@ from openpyxl import Workbook, load_workbook
from openpyxl.cell import WriteOnlyCell from openpyxl.cell import WriteOnlyCell
from .models import AuditLog, ChoiceValue, Device, Employee, NumberSequence from .models import AuditLog, ChoiceValue, Device, Employee, NumberSequence
from .device_types import clear_inapplicable from .device_types import TRISTATE_FIELDS, clear_inapplicable
from .services import add_audit from .services import add_audit
COLUMN_MAP = { COLUMN_MAP = {
"kamera": "camera",
"sound": "sound",
"hdmi": "hdmi",
"vga": "vga",
"dvi": "dvi",
"dp": "displayport",
"displayport": "displayport", "seitenverhältnis": "aspect_ratio", "seitenverhaeltnis": "aspect_ratio",
"nr.": "number", "nr": "number", "nummer": "number", "regal": "shelf", "status": "status", "nr.": "number", "nr": "number", "nummer": "number", "regal": "shelf", "status": "status",
"datum": "date", "empfänger": "recipient", "empfaenger": "recipient", "ort": "location", "datum": "date", "empfänger": "recipient", "empfaenger": "recipient", "ort": "location",
"ticket": "ticket", "typ": "device_type", "preis": "price", "akku": "battery", "wlan": "wifi", "ticket": "ticket", "typ": "device_type", "preis": "price", "akku": "battery", "wlan": "wifi",
@@ -37,6 +44,13 @@ EXPORT_COLUMNS = [
("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"), ("print_technology", "Druckverfahren"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"),
("comment", "Kommentar"), ("comment", "Kommentar"),
("camera", "Kamera"),
("sound", "Sound"),
("hdmi", "HDMI"),
("vga", "VGA"),
("dvi", "DVI"),
("displayport", "DP"),
("aspect_ratio", "Seitenverhältnis"),
] ]
@@ -155,6 +169,13 @@ def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
ports=str(data.get("ports") or "").strip(), resolution=str(data.get("resolution") 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(), print_technology=str(data.get("print_technology") or "").strip(),
duplex=tri_state(data.get("duplex")), color_printing=tri_state(data.get("color_printing")), duplex=tri_state(data.get("duplex")), color_printing=tri_state(data.get("color_printing")),
camera=tri_state(data.get("camera")),
sound=tri_state(data.get("sound")),
hdmi=tri_state(data.get("hdmi")),
vga=tri_state(data.get("vga")),
dvi=tri_state(data.get("dvi")),
displayport=tri_state(data.get("displayport")),
aspect_ratio=str(data.get("aspect_ratio") or "").strip(),
model=str(data.get("model") or "").strip(), model=str(data.get("model") or "").strip(),
employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(), employee=employee, donor=str(data.get("donor") or "").strip(), comment=str(data.get("comment") or "").strip(),
) )
@@ -174,7 +195,7 @@ def import_workbook(file_obj, mapping: dict[int, str], user) -> dict[str, Any]:
def export_value(device: Device, field: str) -> Any: def export_value(device: Device, field: str) -> Any:
value = getattr(device, field) value = getattr(device, field)
if field in {"battery", "wifi", "lan", "duplex", "color_printing"}: if field in TRISTATE_FIELDS:
return dict(Device.TriState.choices).get(value, value) return dict(Device.TriState.choices).get(value, value)
if hasattr(value, "label"): if hasattr(value, "label"):
return value.label return value.label

View File

@@ -0,0 +1,58 @@
# Generated by Django 5.2.18 on 2026-10-13 08:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('inventory', '0013_deviceregistration'),
]
operations = [
migrations.AddField(
model_name='allinonespecification',
name='camera',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='allinonespecification',
name='sound',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='laptopspecification',
name='camera',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='laptopspecification',
name='sound',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='aspect_ratio',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='monitorspecification',
name='displayport',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='dvi',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='hdmi',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
migrations.AddField(
model_name='monitorspecification',
name='vga',
field=models.CharField(choices=[('YES', 'JA'), ('NO', 'NEIN'), ('UNKNOWN', 'UNBEKANNT')], default='UNKNOWN', max_length=10),
),
]

View File

@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("inventory", "0014_allinonespecification_camera_and_more"),
("inventory", "0015_inspectionsubmission"),
]
operations = []

View File

@@ -153,6 +153,13 @@ class NumberSequence(models.Model):
HARDWARE_DEFAULTS = { HARDWARE_DEFAULTS = {
"camera": "UNKNOWN",
"sound": "UNKNOWN",
"hdmi": "UNKNOWN",
"vga": "UNKNOWN",
"dvi": "UNKNOWN",
"displayport": "UNKNOWN",
"aspect_ratio": "",
"battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN",
"display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None, "display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None,
"cpu": "", "hwid": "", "ports": "", "resolution": "", "cpu": "", "hwid": "", "ports": "", "resolution": "",
@@ -216,6 +223,13 @@ class Device(TimeStampedModel):
cpu = HardwareValue("cpu") cpu = HardwareValue("cpu")
hwid = HardwareValue("hwid") hwid = HardwareValue("hwid")
ports = HardwareValue("ports") ports = HardwareValue("ports")
camera = HardwareValue("camera")
sound = HardwareValue("sound")
hdmi = HardwareValue("hdmi")
vga = HardwareValue("vga")
dvi = HardwareValue("dvi")
displayport = HardwareValue("displayport")
aspect_ratio = HardwareValue("aspect_ratio")
resolution = HardwareValue("resolution") resolution = HardwareValue("resolution")
print_technology = HardwareValue("print_technology") print_technology = HardwareValue("print_technology")
duplex = HardwareValue("duplex") duplex = HardwareValue("duplex")
@@ -358,10 +372,12 @@ class ComputerCore(models.Model):
class LaptopSpecification(ComputerCore): class LaptopSpecification(ComputerCore):
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports") hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "battery", "wifi", "lan", "ports", "camera", "sound")
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="laptop_specification", primary_key=True) 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) 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) battery = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
camera = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
sound = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
class DesktopSpecification(ComputerCore): class DesktopSpecification(ComputerCore):
@@ -370,17 +386,24 @@ class DesktopSpecification(ComputerCore):
class AllInOneSpecification(ComputerCore): class AllInOneSpecification(ComputerCore):
hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports") hardware_field_names = ("cpu", "hwid", "ram_gb", "ssd_gb", "hdd_gb", "display_inches", "wifi", "lan", "ports", "camera", "sound")
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="aio_specification", primary_key=True) 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) display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True)
camera = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
sound = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
class MonitorSpecification(models.Model): class MonitorSpecification(models.Model):
hardware_field_names = ("display_inches", "resolution", "ports") hardware_field_names = ("display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio")
device = models.OneToOneField(Device, on_delete=models.CASCADE, related_name="monitor_specification", primary_key=True) 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) display_inches = models.DecimalField(max_digits=4, decimal_places=1, null=True, blank=True)
resolution = models.CharField(max_length=80, blank=True) resolution = models.CharField(max_length=80, blank=True)
ports = models.CharField(max_length=255, blank=True) ports = models.CharField(max_length=255, blank=True)
hdmi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
vga = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
dvi = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
displayport = models.CharField(max_length=10, choices=Device.TriState.choices, default=Device.TriState.UNKNOWN)
aspect_ratio = models.CharField(max_length=20, blank=True)
class PrinterSpecification(models.Model): class PrinterSpecification(models.Model):

View File

@@ -121,6 +121,18 @@ def device_queryset(params, archived: bool = False) -> QuerySet[Device]:
qs = qs.filter(Q(laptop_specification__battery=value) | Q(computer_specification__battery=value)) qs = qs.filter(Q(laptop_specification__battery=value) | Q(computer_specification__battery=value))
else: else:
qs = qs.filter(computer_field_query(name, "exact", value) | Q(**{f"printer_specification__{name}": value})) qs = qs.filter(computer_field_query(name, "exact", value) | Q(**{f"printer_specification__{name}": value}))
for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport"):
value = params.get(name)
if value:
if value not in Device.TriState.values:
return qs.none()
relations = ("laptop_specification", "aio_specification") if name in {"camera", "sound"} else ("monitor_specification",)
query = Q()
for relation in relations:
query |= Q(**{f"{relation}__{name}": value})
qs = qs.filter(query)
if ratio := params.get("aspect_ratio", "").strip():
qs = qs.filter(monitor_specification__aspect_ratio=ratio)
vendor_query = Q() vendor_query = Q()
for value in params.getlist("cpu_vendor"): for value in params.getlist("cpu_vendor"):
if value in CPU_VENDOR_QUERIES: if value in CPU_VENDOR_QUERIES:

View File

@@ -11,10 +11,17 @@ from django.db.models import Max
from django.utils import timezone from django.utils import timezone
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, NumberSequence from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition, NumberSequence
from .device_types import HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable from .device_types import TRISTATE_FIELDS, HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable
FIELD_LABELS = { FIELD_LABELS = {
"camera": "Kamera",
"sound": "Sound",
"hdmi": "HDMI",
"vga": "VGA",
"dvi": "DVI",
"displayport": "DP",
"aspect_ratio": "Seitenverhältnis",
"shelf": "Regal", "status": "Status", "date": "Datum", "recipient": "Empfänger", "shelf": "Regal", "status": "Status", "date": "Datum", "recipient": "Empfänger",
"location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis", "location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis",
"battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll", "battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll",
@@ -30,7 +37,6 @@ RELATION_FIELDS = {
} }
INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"} INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"}
DECIMAL_FIELDS = {"price", "display_inches"} DECIMAL_FIELDS = {"price", "display_inches"}
TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"}
_UNSET = object() _UNSET = object()

View File

@@ -49,7 +49,7 @@
@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}} @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 */ /* 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} .device-list-page{min-height:100vh}.device-list-page .main{height:100vh;min-height:600px;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar,.device-list-page .main>nav{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 0 240px;min-height:240px;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 */ /* 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} .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}

View File

@@ -221,7 +221,7 @@
const legacyVisible=readJSON(localStorage.getItem("csr-visible-columns"),null); const legacyVisible=readJSON(localStorage.getItem("csr-visible-columns"),null);
const storedVisible=Array.isArray(localVisible)?localVisible:(serverVisible.length?serverVisible:legacyVisible); const storedVisible=Array.isArray(localVisible)?localVisible:(serverVisible.length?serverVisible:legacyVisible);
const visibilityUpgradeKey=`csr-complete-list-columns-${userKey}`; const visibilityUpgradeKey=`csr-complete-list-columns-${userKey}`;
const visible=new Set((storedVisible||availableKeys).filter(key=>availableKeys.includes(key))); const visible=new Set(storedVisible||availableKeys);
if(!localStorage.getItem(visibilityUpgradeKey)){ if(!localStorage.getItem(visibilityUpgradeKey)){
requiredPhotoColumns.forEach(key=>visible.add(key)); requiredPhotoColumns.forEach(key=>visible.add(key));
localStorage.setItem(visibilityUpgradeKey,"1"); localStorage.setItem(visibilityUpgradeKey,"1");
@@ -229,6 +229,12 @@
localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible)); localStorage.setItem(visibleStorageKey,JSON.stringify(migratedVisible));
savePreferences({visible_columns:migratedVisible}); savePreferences({visible_columns:migratedVisible});
} }
const knownColumnsKey=`csr-known-columns-${userKey}`;
const knownColumns=readJSON(localStorage.getItem(knownColumnsKey),null);
const previouslyKnown=Array.isArray(knownColumns)?knownColumns:(storedOrder||[]).filter(key=>!key.startsWith("custom_"));
availableKeys.filter(key=>!previouslyKnown.includes(key)).forEach(key=>visible.add(key));
localStorage.setItem(knownColumnsKey,JSON.stringify([...new Set([...previouslyKnown,...availableKeys])]));
localStorage.setItem(visibleStorageKey,JSON.stringify([...visible]));
const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none")); const applyVisibility=()=>columns.forEach(column=>container.querySelectorAll(`[data-column="${column.key}"]`).forEach(element=>element.style.display=visible.has(column.key)?"":"none"));
panel.replaceChildren(); panel.replaceChildren();
@@ -243,7 +249,7 @@
check.checked=visible.has(column.key); check.checked=visible.has(column.key);
check.addEventListener("change",()=>{ check.addEventListener("change",()=>{
check.checked?visible.add(column.key):visible.delete(column.key); check.checked?visible.add(column.key):visible.delete(column.key);
const values=order.filter(key=>visible.has(key)); const values=[...visible];
localStorage.setItem(visibleStorageKey,JSON.stringify(values)); localStorage.setItem(visibleStorageKey,JSON.stringify(values));
savePreferences({visible_columns:values}); savePreferences({visible_columns:values});
applyVisibility(); applyVisibility();

View File

@@ -21,6 +21,7 @@
{% if "ssd_gb" in visible_hardware_fields %} <th data-column="ssd_gb"><a href="?{% sort_query 'ssd_gb' %}">SSD</a></th>{% endif %} {% if "ssd_gb" in visible_hardware_fields %} <th data-column="ssd_gb"><a href="?{% sort_query 'ssd_gb' %}">SSD</a></th>{% endif %}
{% if "hdd_gb" in visible_hardware_fields %} <th data-column="hdd_gb"><a href="?{% sort_query 'hdd_gb' %}">HDD</a></th>{% endif %} {% if "hdd_gb" in visible_hardware_fields %} <th data-column="hdd_gb"><a href="?{% sort_query 'hdd_gb' %}">HDD</a></th>{% endif %}
{% if "cpu" in visible_hardware_fields %} <th data-column="cpu"><a href="?{% sort_query 'cpu' %}">CPU</a></th>{% endif %} {% if "cpu" in visible_hardware_fields %} <th data-column="cpu"><a href="?{% sort_query 'cpu' %}">CPU</a></th>{% endif %}
{% for name, label in extra_hardware_columns %}<th data-column="{{ name }}">{{ label }}</th>{% endfor %}
<th data-column="model"><a href="?{% sort_query 'model' %}">Modell</a></th> <th data-column="model"><a href="?{% sort_query 'model' %}">Modell</a></th>
<th data-column="employee"><a href="?{% sort_query 'employee' %}">Bearbeiter</a></th> <th data-column="employee"><a href="?{% sort_query 'employee' %}">Bearbeiter</a></th>
<th data-column="donor">Spender</th> <th data-column="donor">Spender</th>
@@ -49,11 +50,12 @@
{% if "ssd_gb" in visible_hardware_fields %} {% if "ssd_gb" in device.hardware_fields %}<td data-column="ssd_gb" data-field="ssd_gb" data-input="number" data-value="{{ device.ssd_gb|default:'' }}">{% if device.ssd_gb != None %}{{ device.ssd_gb }} GB{% else %}—{% endif %}</td>{% else %}<td data-column="ssd_gb">—</td>{% endif %}{% endif %} {% if "ssd_gb" in visible_hardware_fields %} {% if "ssd_gb" in device.hardware_fields %}<td data-column="ssd_gb" data-field="ssd_gb" data-input="number" data-value="{{ device.ssd_gb|default:'' }}">{% if device.ssd_gb != None %}{{ device.ssd_gb }} GB{% else %}—{% endif %}</td>{% else %}<td data-column="ssd_gb">—</td>{% endif %}{% endif %}
{% if "hdd_gb" in visible_hardware_fields %} {% if "hdd_gb" in device.hardware_fields %}<td data-column="hdd_gb" data-field="hdd_gb" data-input="number" data-value="{{ device.hdd_gb|default:'' }}">{% if device.hdd_gb != None %}{{ device.hdd_gb }} GB{% else %}—{% endif %}</td>{% else %}<td data-column="hdd_gb">—</td>{% endif %}{% endif %} {% if "hdd_gb" in visible_hardware_fields %} {% if "hdd_gb" in device.hardware_fields %}<td data-column="hdd_gb" data-field="hdd_gb" data-input="number" data-value="{{ device.hdd_gb|default:'' }}">{% if device.hdd_gb != None %}{{ device.hdd_gb }} GB{% else %}—{% endif %}</td>{% else %}<td data-column="hdd_gb">—</td>{% endif %}{% endif %}
{% if "cpu" in visible_hardware_fields %} {% if "cpu" in device.hardware_fields %}<td data-column="cpu" data-field="cpu" data-value="{{ device.cpu }}">{{ device.cpu|default:'—' }}</td>{% else %}<td data-column="cpu">—</td>{% endif %}{% endif %} {% if "cpu" in visible_hardware_fields %} {% if "cpu" in device.hardware_fields %}<td data-column="cpu" data-field="cpu" data-value="{{ device.cpu }}">{{ device.cpu|default:'—' }}</td>{% else %}<td data-column="cpu">—</td>{% endif %}{% endif %}
{% for name, label in extra_hardware_columns %}<td data-column="{{ name }}">{% if name in device.hardware_fields %}{% hardware_display device name %}{% else %}—{% endif %}</td>{% endfor %}
<td data-column="model" data-field="model" data-value="{{ device.model }}">{{ device.model|default:'—' }}</td> <td data-column="model" data-field="model" data-value="{{ device.model }}">{{ device.model|default:'—' }}</td>
<td data-column="employee" data-field="employee" data-input="select" data-value="{{ device.employee_id|default:'' }}">{{ device.employee.code|default:'—' }}</td> <td data-column="employee" data-field="employee" data-input="select" data-value="{{ device.employee_id|default:'' }}">{{ device.employee.code|default:'—' }}</td>
<td data-column="donor" data-field="donor" data-value="{{ device.donor }}">{{ device.donor|default:'—' }}</td> <td data-column="donor" data-field="donor" data-value="{{ device.donor }}">{{ device.donor|default:'—' }}</td>
<td data-column="comment" data-field="comment" data-value="{{ device.comment }}">{{ device.comment|default:'—' }}</td> <td data-column="comment" data-field="comment" data-value="{{ device.comment }}">{{ device.comment|default:'—' }}</td>
{% for field in custom_columns %}<td data-column="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key|display_custom|default:'—' }}</td>{% endfor %} {% for field in custom_columns %}<td data-column="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key|display_custom|default_if_none:'—' }}</td>{% endfor %}
</tr> </tr>
{% empty %}<tr><td colspan="30" class="empty"><strong>Keine Geräte gefunden</strong><span>Filter anpassen oder ein neues Gerät anlegen.</span></td></tr>{% endfor %} {% empty %}<tr><td colspan="30" class="empty"><strong>Keine Geräte gefunden</strong><span>Filter anpassen oder ein neues Gerät anlegen.</span></td></tr>{% endfor %}
</tbody> </tbody>

View File

@@ -1,4 +1,4 @@
{% extends 'inventory/base.html' %}{% load inventory_tags %}{% block title %}Gerät #{{ device.number }}{% endblock %}{% block content %} {% extends 'inventory/base.html' %}{% load inventory_tags l10n %}{% block title %}Gerät #{{ device.number }}{% endblock %}{% block content %}
<section class="narrow-page wide-form"><div class="page-head device-detail-head"><div><a class="back" href="{{ list_url }}">← Zur Geräteliste</a><h1>Gerät #{{ device.number }}</h1><p>Zuletzt geändert {{ device.updated_at|date:'d.m.Y H:i' }}</p></div><nav class="record-navigation" aria-label="Zwischen Geräten wechseln"><a class="btn icon{% if not previous_device_id %} disabled{% endif %}" {% if previous_device_id %}href="{% url 'inventory:device-detail' previous_device_id %}{% if navigation_query %}?{{ navigation_query }}{% endif %}"{% else %}aria-disabled="true"{% endif %} title="Vorheriges Gerät" aria-label="Vorheriges Gerät">←</a><span>{% if navigation_position %}{{ navigation_position }} / {{ navigation_total }}{% endif %}</span><a class="btn icon{% if not next_device_id %} disabled{% endif %}" {% if next_device_id %}href="{% url 'inventory:device-detail' next_device_id %}{% if navigation_query %}?{{ navigation_query }}{% endif %}"{% else %}aria-disabled="true"{% endif %} title="Nächstes Gerät" aria-label="Nächstes Gerät">→</a></nav></div> <section class="narrow-page wide-form"><div class="page-head device-detail-head"><div><a class="back" href="{{ list_url }}">← Zur Geräteliste</a><h1>Gerät #{{ device.number }}</h1><p>Zuletzt geändert {{ device.updated_at|date:'d.m.Y H:i' }}</p></div><nav class="record-navigation" aria-label="Zwischen Geräten wechseln"><a class="btn icon{% if not previous_device_id %} disabled{% endif %}" {% if previous_device_id %}href="{% url 'inventory:device-detail' previous_device_id %}{% if navigation_query %}?{{ navigation_query }}{% endif %}"{% else %}aria-disabled="true"{% endif %} title="Vorheriges Gerät" aria-label="Vorheriges Gerät">←</a><span>{% if navigation_position %}{{ navigation_position }} / {{ navigation_total }}{% endif %}</span><a class="btn icon{% if not next_device_id %} disabled{% endif %}" {% if next_device_id %}href="{% url 'inventory:device-detail' next_device_id %}{% if navigation_query %}?{{ navigation_query }}{% endif %}"{% else %}aria-disabled="true"{% endif %} title="Nächstes Gerät" aria-label="Nächstes Gerät">→</a></nav></div>
<div class="device-workbench"> <div class="device-workbench">
<section><h2>Ablauf: {{ device.get_stage_display }}</h2> <section><h2>Ablauf: {{ device.get_stage_display }}</h2>
@@ -30,7 +30,7 @@
<section data-hardware-section><h2>Hardware <small data-selected-type-label></small></h2><div class="form-grid">{% for field in form %}{% if field.name in all_hardware_fields %}<label data-hardware-field="{{ field.name }}"{% if field.name not in hardware_fields %} hidden{% endif %}>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</div></section> <section data-hardware-section><h2>Hardware <small data-selected-type-label></small></h2><div class="form-grid">{% for field in form %}{% if field.name in all_hardware_fields %}<label data-hardware-field="{{ field.name }}"{% if field.name not in hardware_fields %} hidden{% endif %}>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</div></section>
<section><h2>Verwaltung</h2><div class="form-grid">{% 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' %}<label>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</div></section> <section><h2>Verwaltung</h2><div class="form-grid">{% 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' %}<label>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</div></section>
<section><h2>Notizen</h2>{% for field in form %}{% if field.name == 'comment' %}<label>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</section> <section><h2>Notizen</h2>{% for field in form %}{% if field.name == 'comment' %}<label>{{ field.label }}{{ field }}{{ field.errors }}</label>{% endif %}{% endfor %}</section>
{% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if device.custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ device.custom_values|get_item:field.key }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true">JA</option><option value="false">NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ device.custom_values|get_item:field.key }}" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %} {% if definitions %}<section><h2>Zusatzfelder</h2><div class="form-grid">{% for field in definitions %}<label>{{ field.name }}{% if field.field_type == 'select' %}<select name="custom_{{ field.key }}"><option value="">—</option>{% for option in field.options.all %}{% if option.active %}<option {% if custom_values|get_item:field.key == option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'multi_select' %}<select name="custom_{{ field.key }}" multiple>{% for option in field.options.all %}{% if option.active %}<option {% if custom_values|get_item:field.key|contains_value:option.label %}selected{% endif %}>{{ option.label }}</option>{% endif %}{% endfor %}</select>{% elif field.field_type == 'long_text' %}<textarea name="custom_{{ field.key }}">{{ custom_values|get_item:field.key|default_if_none:""|unlocalize }}</textarea>{% elif field.field_type == 'boolean' %}<select name="custom_{{ field.key }}"><option value="">—</option><option value="true" {% if custom_values|get_item:field.key == True or custom_values|get_item:field.key == "true" %}selected{% endif %}>JA</option><option value="false" {% if custom_values|get_item:field.key == False or custom_values|get_item:field.key == "false" %}selected{% endif %}>NEIN</option></select>{% elif field.field_type == 'date' %}<input name="custom_{{ field.key }}" value="{{ custom_values|get_item:field.key|german_date }}" type="text" placeholder="TT.MM.JJJJ" inputmode="numeric" data-german-date>{% else %}<input name="custom_{{ field.key }}" value="{{ custom_values|get_item:field.key|default_if_none:""|unlocalize }}" step="any" type="{% if field.field_type == 'integer' or field.field_type == 'decimal' %}number{% else %}text{% endif %}">{% endif %}{% if field.unit %}<small>{{ field.unit }}</small>{% endif %}</label>{% endfor %}</div></section>{% endif %}
<div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div> <div class="sticky-actions"><a class="btn" href="{{ list_url }}">Abbrechen</a>{% if can_edit_devices %}<button class="btn primary">Änderungen speichern</button>{% endif %}</div>
</form> </form>
{% if can_edit_devices and not device.archived_at %}<form method="post" action="{% url 'inventory:archive-device' device.pk %}" data-confirm="Gerät #{{ device.number }} wirklich archivieren?" data-confirm-action="Archivieren" class="archive-action">{% csrf_token %}<button class="btn danger" type="submit">Gerät archivieren</button></form>{% endif %} {% if can_edit_devices and not device.archived_at %}<form method="post" action="{% url 'inventory:archive-device' device.pk %}" data-confirm="Gerät #{{ device.number }} wirklich archivieren?" data-confirm-action="Archivieren" class="archive-action">{% csrf_token %}<button class="btn danger" type="submit">Gerät archivieren</button></form>{% endif %}

View File

@@ -74,6 +74,13 @@
<label>WLAN<select name="wifi"><option value="">Alle</option><option value="YES" {% option_selected request 'wifi' 'YES' %}>JA</option><option value="NO" {% option_selected request 'wifi' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'wifi' 'UNKNOWN' %}>UNBEKANNT</option></select></label> <label>WLAN<select name="wifi"><option value="">Alle</option><option value="YES" {% option_selected request 'wifi' 'YES' %}>JA</option><option value="NO" {% option_selected request 'wifi' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'wifi' 'UNKNOWN' %}>UNBEKANNT</option></select></label>
<label>Akku<select name="battery"><option value="">Alle</option><option value="YES" {% option_selected request 'battery' 'YES' %}>JA</option><option value="NO" {% option_selected request 'battery' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'battery' 'UNKNOWN' %}>UNBEKANNT</option></select></label> <label>Akku<select name="battery"><option value="">Alle</option><option value="YES" {% option_selected request 'battery' 'YES' %}>JA</option><option value="NO" {% option_selected request 'battery' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'battery' 'UNKNOWN' %}>UNBEKANNT</option></select></label>
<label>LAN<select name="lan"><option value="">Alle</option><option value="YES" {% option_selected request 'lan' 'YES' %}>JA</option><option value="NO" {% option_selected request 'lan' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'lan' 'UNKNOWN' %}>UNBEKANNT</option></select></label> <label>LAN<select name="lan"><option value="">Alle</option><option value="YES" {% option_selected request 'lan' 'YES' %}>JA</option><option value="NO" {% option_selected request 'lan' 'NO' %}>NEIN</option><option value="UNKNOWN" {% option_selected request 'lan' 'UNKNOWN' %}>UNBEKANNT</option></select></label>
<label>Kamera<select name="camera"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'camera' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>Sound<select name="sound"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'sound' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>HDMI<select name="hdmi"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'hdmi' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>VGA<select name="vga"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'vga' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>DVI<select name="dvi"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'dvi' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>DP<select name="displayport"><option value="">Alle</option>{% for value, label in tristate_choices %}<option value="{{ value }}" {% option_selected request 'displayport' value %}>{{ label }}</option>{% endfor %}</select></label>
<label>Seitenverhältnis<input name="aspect_ratio" value="{{ request.GET.aspect_ratio }}" placeholder="z. B. 16:9"></label>
<label>Modell enthält<input name="model_contains" value="{{ request.GET.model_contains }}" placeholder="z. B. ThinkPad"></label> <label>Modell enthält<input name="model_contains" value="{{ request.GET.model_contains }}" placeholder="z. B. ThinkPad"></label>
<label>Ticket enthält<input name="ticket_contains" value="{{ request.GET.ticket_contains }}"></label> <label>Ticket enthält<input name="ticket_contains" value="{{ request.GET.ticket_contains }}"></label>
</div> </div>

View File

@@ -46,6 +46,8 @@ def contains_value(values, value):
@register.filter @register.filter
def display_custom(value): def display_custom(value):
if value is None or value == "":
return "—"
if isinstance(value, list): if isinstance(value, list):
return ", ".join(str(item) for item in value) return ", ".join(str(item) for item in value)
if value is True: if value is True:
@@ -64,3 +66,9 @@ def german_date(value):
return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y") return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y")
except ValueError: except ValueError:
return text return text
@register.simple_tag
def hardware_display(device, name):
from inventory.services import display_device_value
return display_device_value(name, getattr(device, name)) or "—"

View File

@@ -0,0 +1,123 @@
"""Regression coverage for Gitea issues #1–#3."""
from io import BytesIO
from django.contrib.auth.models import User
from django.core.management import call_command
from django.http import QueryDict
from django.test import TestCase
from django.urls import reverse
from openpyxl import load_workbook
from .api import DeviceChangesSerializer, DeviceSerializer
from .forms import DeviceForm, InspectionForm, QuickAddForm
from .import_export import import_workbook, suggested_mapping, xlsx_response
from .models import AuditLog, ChoiceValue, Device, Employee, FieldDefinition
from .query import device_queryset
from .services import update_device_field
class IssueRegressionTests(TestCase):
@classmethod
def setUpTestData(cls):
call_command("seed_initial_data", verbosity=0)
cls.user = User.objects.create_superuser("issue-admin", password="test-password")
cls.status = ChoiceValue.objects.get(kind="status", label="Eingang")
cls.laptop = ChoiceValue.objects.get(kind="device_type", label="Laptop")
cls.aio = ChoiceValue.objects.get(kind="device_type", label="AiO")
cls.monitor = ChoiceValue.objects.get(kind="device_type", label="Bildschirm")
def setUp(self):
self.client.force_login(self.user)
def device(self, **values):
return Device.objects.create(status=self.status, device_type=self.laptop, **values)
def test_custom_values_render_and_survive_an_unrelated_edit(self):
for key, kind in (("enabled", "boolean"), ("disabled", "boolean"), ("count", "integer"), ("amount", "decimal"), ("empty", "text")):
FieldDefinition.objects.create(name=key, key=key, field_type=kind, show_in_list=True)
values = {"enabled": True, "disabled": False, "count": 0, "amount": "1.25", "empty": None}
device = self.device(custom_values=values)
url = reverse("inventory:device-detail", args=[device.pk])
response = self.client.get(url)
self.assertContains(response, 'value="true" selected')
self.assertContains(response, 'value="false" selected')
self.assertContains(response, 'name="custom_count" value="0"')
self.assertContains(response, 'name="custom_amount" value="1.25"')
self.assertContains(response, 'name="custom_empty" value=""')
listing = self.client.get(reverse("inventory:device-list"))
self.assertContains(listing, '<td data-column="custom_count">0</td>', html=True)
self.assertContains(listing, '<td data-column="custom_disabled">NEIN</td>', html=True)
payload = {"status": self.status.pk, "date": "2026-10-13", "device_type": self.laptop.pk,
"expected_revision": device.revision, "custom_enabled": "true", "custom_disabled": "false",
"custom_count": "0", "custom_amount": "1.25", "custom_empty": "", "model": "Edited"}
response = self.client.post(url, payload)
self.assertEqual(response.status_code, 302)
device.refresh_from_db()
self.assertEqual(device.custom_values, values)
payload.update(expected_revision=device.revision, custom_count="invalid", custom_enabled="false")
response = self.client.post(url, payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["custom_values"]["count"], "invalid")
self.assertEqual(response.context["custom_values"]["enabled"], "false")
device.refresh_from_db()
self.assertEqual(device.custom_values, values)
def test_hardware_persistence_api_and_type_changes(self):
for device_type in (self.laptop, self.aio):
device = Device.objects.create(status=self.status, device_type=device_type, camera="YES", sound="NO")
device.refresh_from_db()
self.assertEqual((device.camera, device.sound), ("YES", "NO"))
self.assertEqual(DeviceSerializer(device).data["camera"], "YES")
device = update_device_field(device, "sound", "YES", self.user, expected_revision=device.revision)
self.assertEqual(device.sound, "YES")
self.assertTrue(AuditLog.objects.filter(device=device, field_key="sound").exists())
device.device_type = self.monitor
device.save()
device.refresh_from_db()
self.assertEqual(device.camera, "UNKNOWN")
self.assertEqual(device.custom_values["legacy_hardware"]["camera"], "YES")
serializer = DeviceChangesSerializer(data={"hdmi": "sometimes"})
self.assertFalse(serializer.is_valid())
def test_monitor_filters_distinguish_no_and_unknown(self):
yes = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="YES", vga="NO", dvi="NO", displayport="YES", aspect_ratio="16:9", ports="USB-C")
no = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="NO")
unknown = Device.objects.create(status=self.status, device_type=self.monitor)
laptop = self.device(camera="YES", sound="NO")
for query, expected in (("hdmi=YES&displayport=YES&aspect_ratio=16:9", [yes]), ("hdmi=NO", [no]), ("hdmi=UNKNOWN", [unknown]), ("camera=YES&sound=NO", [laptop]), ("hdmi=invalid", [])):
self.assertEqual(list(device_queryset(QueryDict(query))), expected)
yes.refresh_from_db()
self.assertEqual(yes.ports, "USB-C")
listing = self.client.get(reverse("inventory:device-list"), {"type": self.monitor.pk, "hdmi": "YES"})
self.assertContains(listing, '<td data-column="hdmi">JA</td>', html=True)
self.assertNotContains(listing, '<th data-column="camera">')
def test_quick_add_and_inspection_include_new_fields(self):
form = QuickAddForm({"number": "1", "quantity": "1", "status": self.status.pk,
"date": "2026-10-13", "device_type": self.laptop.pk,
"employee": Employee.objects.first().pk, "camera": "YES", "sound": "NO"})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.selected_hardware_values()["camera"], "YES")
device = self.device(camera="YES")
self.assertEqual(DeviceForm(instance=device)["camera"].value(), "YES")
inspection = InspectionForm({"defects": "No camera test yet"}, device=device)
self.assertTrue(inspection.is_valid(), inspection.errors)
self.assertEqual(inspection.cleaned_data["camera"], "UNKNOWN")
monitor = Device.objects.create(status=self.status, device_type=self.monitor)
self.assertIn("displayport", InspectionForm(device=monitor).fields)
self.assertNotIn("camera", InspectionForm(device=monitor).fields)
def test_new_hardware_fields_round_trip_through_xlsx(self):
laptop = self.device(camera="YES", sound="NO")
monitor = Device.objects.create(status=self.status, device_type=self.monitor, hdmi="YES", vga="NO", dvi="UNKNOWN", displayport="YES", aspect_ratio="21:9", ports="USB-C")
payload = xlsx_response(Device.objects.all()).content
book = load_workbook(BytesIO(payload))
headers = [cell.value for cell in book.active[1]]
mapping = suggested_mapping(headers)
expected = {device.number: DeviceSerializer(device).data for device in (laptop, monitor)}
Device.objects.all().delete()
result = import_workbook(BytesIO(payload), mapping, self.user)
self.assertEqual(result["created"], 2)
for device in Device.objects.all():
for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport", "aspect_ratio", "ports"):
self.assertEqual(getattr(device, name), expected[device.number][name])

View File

@@ -7,7 +7,11 @@ from django.test import TransactionTestCase
OLD = [("inventory", "0008_normalize_hardware_by_type")] OLD = [("inventory", "0008_normalize_hardware_by_type")]
NEW = [("inventory", "0009_split_device_specifications")] NEW = [("inventory", "0009_split_device_specifications")]
LATEST = [("inventory", "0012_allinonespecification_desktopspecification_and_more")]
def restore_latest_schema():
executor = MigrationExecutor(connection)
executor.migrate(executor.loader.graph.leaf_nodes())
class SpecificationMigrationTests(TransactionTestCase): class SpecificationMigrationTests(TransactionTestCase):
@@ -40,8 +44,7 @@ class SpecificationMigrationTests(TransactionTestCase):
self.assertEqual(str(Monitor.objects.get(device_id=screen_id).display_inches), "24.0") self.assertEqual(str(Monitor.objects.get(device_id=screen_id).display_inches), "24.0")
self.assertFalse(Computer.objects.filter(device_id=printer_id).exists()) self.assertFalse(Computer.objects.filter(device_id=printer_id).exists())
finally: finally:
executor = MigrationExecutor(connection) restore_latest_schema()
executor.migrate(executor.loader.graph.leaf_nodes())
class DeviceTypeTableMigrationTests(TransactionTestCase): class DeviceTypeTableMigrationTests(TransactionTestCase):
@@ -61,8 +64,8 @@ class DeviceTypeTableMigrationTests(TransactionTestCase):
device = Device.objects.create(number=92000 + index, status=status, device_type=device_type) device = Device.objects.create(number=92000 + index, status=status, device_type=device_type)
Computer.objects.create(device_id=device.pk, cpu="Core i5", ram_gb=8, ssd_gb=256) Computer.objects.create(device_id=device.pk, cpu="Core i5", ram_gb=8, ssd_gb=256)
ids[label] = device.pk ids[label] = device.pk
MigrationExecutor(connection).migrate(LATEST) restore_latest_schema()
apps = MigrationExecutor(connection).loader.project_state(LATEST).apps apps = MigrationExecutor(connection).loader.project_state().apps
self.assertEqual(apps.get_model("inventory", "LaptopSpecification").objects.get(device_id=ids["Laptop"]).ram_gb, 8) self.assertEqual(apps.get_model("inventory", "LaptopSpecification").objects.get(device_id=ids["Laptop"]).ram_gb, 8)
self.assertEqual(apps.get_model("inventory", "DesktopSpecification").objects.get(device_id=ids["Desktop"]).ssd_gb, 256) self.assertEqual(apps.get_model("inventory", "DesktopSpecification").objects.get(device_id=ids["Desktop"]).ssd_gb, 256)
self.assertEqual(apps.get_model("inventory", "AllInOneSpecification").objects.get(device_id=ids["AiO"]).cpu, "Core i5") self.assertEqual(apps.get_model("inventory", "AllInOneSpecification").objects.get(device_id=ids["AiO"]).cpu, "Core i5")
@@ -70,5 +73,30 @@ class DeviceTypeTableMigrationTests(TransactionTestCase):
self.assertFalse(Computer.objects.filter(device_id__in=ids.values()).exclude(device_id=ids["Sondergerät"]).exists()) self.assertFalse(Computer.objects.filter(device_id__in=ids.values()).exclude(device_id=ids["Sondergerät"]).exists())
self.assertTrue(Computer.objects.filter(device_id=ids["Sondergerät"]).exists()) self.assertTrue(Computer.objects.filter(device_id=ids["Sondergerät"]).exists())
finally: finally:
executor = MigrationExecutor(connection) restore_latest_schema()
executor.migrate(executor.loader.graph.leaf_nodes())
class EquipmentFieldMigrationTests(TransactionTestCase):
def test_existing_connection_notes_and_hardware_are_preserved(self):
old = [("inventory", "0013_deviceregistration")]
executor = MigrationExecutor(connection)
executor.migrate(old)
try:
apps = executor.loader.project_state(old).apps
Choice = apps.get_model("inventory", "ChoiceValue")
Device = apps.get_model("inventory", "Device")
Monitor = apps.get_model("inventory", "MonitorSpecification")
status, _ = Choice.objects.get_or_create(kind="status", label="Eingang")
kind, _ = Choice.objects.get_or_create(kind="device_type", label="Bildschirm")
device = Device.objects.create(number=93001, status=status, device_type=kind)
Monitor.objects.create(device=device, ports="HDMI, VGA; DP ungetestet", resolution="1920x1080")
restore_latest_schema()
apps = MigrationExecutor(connection).loader.project_state().apps
specification = apps.get_model("inventory", "MonitorSpecification").objects.get(device_id=device.pk)
self.assertEqual(specification.ports, "HDMI, VGA; DP ungetestet")
self.assertEqual(specification.resolution, "1920x1080")
self.assertEqual(specification.hdmi, "UNKNOWN")
self.assertEqual(specification.displayport, "UNKNOWN")
self.assertEqual(specification.aspect_ratio, "")
finally:
restore_latest_schema()

View File

@@ -199,11 +199,13 @@ def _list_context(request: HttpRequest, archived: bool = False) -> dict:
"statuses": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True), "statuses": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True),
"stages": Device.Stage.choices, "stages": Device.Stage.choices,
"types": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True), "types": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.DEVICE_TYPE, active=True),
"visible_hardware_fields": applicable_fields(selected_type) if selected_type else COMPUTER_FIELDS, "visible_hardware_fields": applicable_fields(selected_type) if selected_type else HARDWARE_FIELDS,
"extra_hardware_columns": [(name, FIELD_LABELS[name]) for name in ("camera", "sound", "hdmi", "vga", "dvi", "displayport", "aspect_ratio") if not selected_type or name in applicable_fields(selected_type)],
"recipients": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.RECIPIENT, active=True), "recipients": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.RECIPIENT, active=True),
"employees": Employee.objects.filter(active=True), "employees": Employee.objects.filter(active=True),
"cpu_vendors": CPU_VENDOR_CHOICES, "cpu_vendors": CPU_VENDOR_CHOICES,
"cpu_families": CPU_FAMILY_CHOICES, "cpu_families": CPU_FAMILY_CHOICES,
"tristate_choices": Device.TriState.choices,
"custom_columns": FieldDefinition.objects.filter(active=True, show_in_list=True), "custom_columns": FieldDefinition.objects.filter(active=True, show_in_list=True),
"custom_filters": FieldDefinition.objects.filter(active=True, filterable=True).prefetch_related("options"), "custom_filters": FieldDefinition.objects.filter(active=True, filterable=True).prefetch_related("options"),
"total_count": page.paginator.count, "total_count": page.paginator.count,
@@ -410,6 +412,10 @@ def device_detail(request: HttpRequest, pk: int) -> HttpResponse:
type_field_map = _hardware_type_map() type_field_map = _hardware_type_map()
selected_type_id = str(form["device_type"].value() or "") selected_type_id = str(form["device_type"].value() or "")
context = { context = {
"custom_values": {
definition.key: (request.POST.getlist(f"custom_{definition.key}") if definition.field_type == FieldDefinition.FieldType.MULTI_SELECT else request.POST.get(f"custom_{definition.key}", ""))
for definition in definitions
} if request.method == "POST" else device.custom_values,
"device": device, "form": form, "definitions": definitions, "can_edit_devices": can_edit(request.user), "device": device, "form": form, "definitions": definitions, "can_edit_devices": can_edit(request.user),
"hardware_fields": type_field_map.get(selected_type_id, applicable_fields(device.device_type)), "hardware_fields": type_field_map.get(selected_type_id, applicable_fields(device.device_type)),
"all_hardware_fields": HARDWARE_FIELDS, "all_hardware_fields": HARDWARE_FIELDS,
@@ -794,6 +800,13 @@ def import_map(request: HttpRequest, pk: int) -> HttpResponse:
("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"), ("location", "Ort"), ("ticket", "Ticket"), ("device_type", "Typ"), ("price", "Preis"), ("battery", "Akku"),
("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"),
("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"), ("hdd_gb", "HDD"), ("cpu", "CPU"), ("hwid", "HWID"), ("ports", "Anschlüsse"),
("camera", "Kamera"),
("sound", "Sound"),
("hdmi", "HDMI"),
("vga", "VGA"),
("dvi", "DVI"),
("displayport", "DP"),
("aspect_ratio", "Seitenverhältnis"),
("resolution", "Auflösung"), ("print_technology", "Druckverfahren"), ("resolution", "Auflösung"), ("print_technology", "Druckverfahren"),
("duplex", "Duplex"), ("color_printing", "Farbdruck"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"),
("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]] ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]]

View File

@@ -76,7 +76,7 @@ def run():
django.setup() django.setup()
from django.contrib.auth.models import User from django.contrib.auth.models import User
from openpyxl import Workbook from openpyxl import Workbook
from inventory.models import ChoiceValue, Device, Employee, UserProfile from inventory.models import ChoiceValue, Device, Employee, FieldDefinition, UserProfile
call_command("migrate", interactive=False, verbosity=0) call_command("migrate", interactive=False, verbosity=0)
call_command("seed_initial_data", verbosity=0) call_command("seed_initial_data", verbosity=0)
@@ -93,6 +93,11 @@ def run():
for device_type, model in ((laptop, "Fixture Laptop"), (printer, "Fixture Printer"), (monitor, "Fixture Monitor")): for device_type, model in ((laptop, "Fixture Laptop"), (printer, "Fixture Printer"), (monitor, "Fixture Monitor")):
Device.objects.create(status=status, device_type=device_type, model=model, employee=employee) Device.objects.create(status=status, device_type=device_type, model=model, employee=employee)
FieldDefinition.objects.create(name="Geprüft", key="checked", field_type="boolean", show_in_list=True)
first = Device.objects.get(number=1)
first.custom_values = {"checked": False}
first.save()
port = available_port() port = available_port()
base_url = f"http://127.0.0.1:{port}" base_url = f"http://127.0.0.1:{port}"
with (Path(temporary) / "server.log").open("w", encoding="utf-8") as log_file: with (Path(temporary) / "server.log").open("w", encoding="utf-8") as log_file:
@@ -111,6 +116,24 @@ def run():
login(page, base_url, "browser_admin") login(page, base_url, "browser_admin")
print("PASS login and protected routes", flush=True) print("PASS login and protected routes", flush=True)
# Reproduce an existing user's preferences before adding a custom column.
page.evaluate("""() => {
const id = document.body.dataset.userId;
localStorage.setItem(`csr-visible-columns-${id}`, '["number","model"]');
localStorage.setItem(`csr-complete-list-columns-${id}`, '1');
localStorage.removeItem(`csr-known-columns-${id}`);
}""")
page.reload()
custom_cell = page.locator('tr[data-device-id="1"] td[data-column="custom_checked"]')
expect(custom_cell).to_be_visible()
expect(custom_cell).to_have_text("NEIN")
page.goto(f"{base_url}/devices/1/")
expect(page.locator('[name="custom_checked"]')).to_have_value("false")
page.locator('.detail-form [name="model"]').fill("Fixture Laptop Edited")
page.locator('.detail-form button[type="submit"], .detail-form button.primary').click()
expect(page.locator('[name="custom_checked"]')).to_have_value("false")
print("PASS custom column defaults and saved boolean editing", flush=True)
for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/devices/new/", for route in ("/", "/uebersicht/", "/ablauf/", "/aufgaben/", "/devices/new/",
"/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"): "/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"):
response = page.goto(f"{base_url}{route}") response = page.goto(f"{base_url}{route}")
@@ -257,6 +280,17 @@ def run():
expect(mobile_page.locator('.mobile-header')).to_be_visible() expect(mobile_page.locator('.mobile-header')).to_be_visible()
mobile_page.locator('[data-sidebar-toggle]').click() mobile_page.locator('[data-sidebar-toggle]').click()
expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open') expect(mobile_page.locator('#sidebar')).to_have_class('sidebar open')
for width, height in ((390, 400), (844, 390)):
mobile_page.set_viewport_size({"width": width, "height": height})
mobile_page.goto(f"{base_url}/?page_size=100")
shell = mobile_page.locator(".table-shell")
shell.scroll_into_view_if_needed()
assert shell.evaluate("el => el.clientHeight") >= 100
mobile_page.locator('.pagination').scroll_into_view_if_needed()
assert mobile_page.locator('.pagination').evaluate("el => el.getBoundingClientRect().bottom <= innerHeight + 1")
if shell.evaluate("el => el.scrollWidth > el.clientWidth"):
shell.evaluate("el => el.scrollLeft = el.scrollWidth")
assert shell.evaluate("el => el.scrollLeft") > 0
mobile.close() mobile.close()
print("PASS dark mode persistence and mobile navigation", flush=True) print("PASS dark mode persistence and mobile navigation", flush=True)

View File

@@ -55,4 +55,29 @@ async function run() {
console.log("Frontend regression checks passed: safe confirmation text, preserved selection, no-op blur, revision save."); console.log("Frontend regression checks passed: safe confirmation text, preserved selection, no-op blur, revision save.");
} finally { dom.window.close(); } } finally { dom.window.close(); }
} }
run().catch(error => { console.error(error); process.exitCode = 1; }); async function testCustomColumns() {
const dom = new JSDOM(`<!doctype html><body data-user-id="42">
<button data-columns-toggle></button><div data-columns-panel></div>
<div id="device-table-container"><table data-device-table>
<thead><tr><th data-column="number">Nr.</th><th data-column="custom_checked">Checked</th></tr></thead>
<tbody><tr><td data-column="number">1</td><td data-column="custom_checked">JA</td></tr></tbody>
</table></div></body>`, {url: "https://inventory.test/", runScripts: "outside-only"});
try {
const {window} = dom;
window.fetch = async () => ({ok: true, json: async () => ({})});
window.localStorage.setItem("csr-visible-columns-42", '["number"]');
window.localStorage.setItem("csr-column-order-42", '["number","custom_checked"]');
window.localStorage.setItem("csr-complete-list-columns-42", "1");
window.eval(source);
const cell = window.document.querySelector('td[data-column="custom_checked"]');
assert.equal(cell.style.display, "", "New custom columns must appear with old preferences");
const check = window.document.querySelector('[data-columns-panel] [data-column="custom_checked"] input');
check.checked = false;
check.dispatchEvent(new window.Event("change"));
assert.equal(cell.style.display, "none");
window.eval(source);
assert.equal(cell.style.display, "none", "Explicitly hidden custom columns must stay hidden on rebinding");
console.log("Frontend regression checks passed: custom-column defaults and saved visibility.");
} finally { dom.window.close(); }
}
run().then(testCustomColumns).catch(error => { console.error(error); process.exitCode = 1; });