From 048cec333e50b23f4149f825f2a5303e3be5cc41 Mon Sep 17 00:00:00 2001 From: "denis.stoecklmeier" Date: Tue, 13 Oct 2026 10:54:20 +0200 Subject: [PATCH] Fix device issues and document current LAN deployment --- .gitea/workflows/tests.yml | 3 + README.md | 25 ++ docs/LOCAL_DEPLOY.md | 240 ++++++++++++++++++ docs/serverbetrieb.md | 7 +- inventory/api.py | 17 +- inventory/device_types.py | 17 +- inventory/forms.py | 24 +- inventory/import_export.py | 25 +- ...4_allinonespecification_camera_and_more.py | 58 +++++ inventory/models.py | 29 ++- inventory/query.py | 12 + inventory/services.py | 10 +- inventory/static/inventory/css/app.css | 2 +- inventory/static/inventory/js/app.js | 10 +- .../templates/inventory/_device_table.html | 4 +- .../templates/inventory/device_detail.html | 4 +- .../templates/inventory/device_list.html | 7 + inventory/templatetags/inventory_tags.py | 8 + inventory/test_issue_regressions.py | 123 +++++++++ inventory/test_specification_migration.py | 40 ++- inventory/views.py | 15 +- scripts/test_browser.py | 36 ++- scripts/test_frontend.cjs | 27 +- 23 files changed, 707 insertions(+), 36 deletions(-) create mode 100644 docs/LOCAL_DEPLOY.md create mode 100644 inventory/migrations/0014_allinonespecification_camera_and_more.py create mode 100644 inventory/test_issue_regressions.py diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index 0a366ed..efb0144 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -37,3 +37,6 @@ jobs: 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 + - run: pip install playwright + - run: python -m playwright install --with-deps chromium --only-shell + - run: python scripts/test_browser.py diff --git a/README.md b/README.md index 24023fa..e89e417 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ Produktionsnahe interne Geräteverwaltung als kompakter Django-Monolith. Die Anw - Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS - 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 Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose. @@ -331,3 +337,22 @@ 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. + +## 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. diff --git a/docs/LOCAL_DEPLOY.md b/docs/LOCAL_DEPLOY.md new file mode 100644 index 0000000..ce99c0d --- /dev/null +++ b/docs/LOCAL_DEPLOY.md @@ -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 | `denis` | +| 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 denis@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/denis/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 | `denis` | +| 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. diff --git a/docs/serverbetrieb.md b/docs/serverbetrieb.md index c0dec25..f11fdda 100644 --- a/docs/serverbetrieb.md +++ b/docs/serverbetrieb.md @@ -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` installiert und gegen PostgreSQL geprüft. diff --git a/inventory/api.py b/inventory/api.py index 9bd12f8..c9e1dc4 100644 --- a/inventory/api.py +++ b/inventory/api.py @@ -38,6 +38,14 @@ class EmployeeSummarySerializer(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) wifi = 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", "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", + "duplex", "color_printing", "aspect_ratio", "camera", "sound", "hdmi", "vga", "dvi", "displayport", "model", "employee", "donor", "comment", "archived_at", "created_at", "updated_at", "revision", ] @@ -88,6 +96,13 @@ def active_choice_field(kind, *, allow_null=True): 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) status = active_choice_field(ChoiceValue.Kind.STATUS, allow_null=False) date = serializers.DateField(required=False) diff --git a/inventory/device_types.py b/inventory/device_types.py index ba993e8..8b0ba91 100644 --- a/inventory/device_types.py +++ b/inventory/device_types.py @@ -3,18 +3,19 @@ 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"} +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 = { - "laptop": COMPUTER_FIELDS, - "aio": COMPUTER_FIELDS - {"battery"}, + "laptop": COMPUTER_FIELDS | {"camera", "sound"}, + "aio": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"}, "desktop": COMPUTER_FIELDS - {"battery", "display_inches"}, - "bildschirm": frozenset({"display_inches", "resolution", "ports"}), - "monitor": frozenset({"display_inches", "resolution", "ports"}), + "bildschirm": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}), + "monitor": frozenset({"display_inches", "resolution", "ports", "hdmi", "vga", "dvi", "displayport", "aspect_ratio"}), "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"}, + "all-in-one": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"}, + "all in one": (COMPUTER_FIELDS - {"battery"}) | {"camera", "sound"}, } @@ -38,7 +39,7 @@ def clear_inapplicable(device): 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) + 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: device.custom_values = {**device.custom_values, "legacy_hardware": legacy} return old_values diff --git a/inventory/forms.py b/inventory/forms.py index a1a7688..d514034 100644 --- a/inventory/forms.py +++ b/inventory/forms.py @@ -5,7 +5,7 @@ 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 +from .device_types import HARDWARE_FIELDS, TRISTATE_FIELDS, applicable_fields 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() for name in sorted(HARDWARE_FIELDS): field = deepcopy(DeviceForm.base_fields[name]) - if name in {"battery", "wifi", "lan", "duplex", "color_printing"}: + if name in TRISTATE_FIELDS: field.required = False field.initial = Device.TriState.UNKNOWN if self.is_bound and name not in applicable_fields(selected_type): @@ -60,7 +60,7 @@ class QuickAddForm(forms.Form): 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: + if name in TRISTATE_FIELDS and not value: value = Device.TriState.UNKNOWN values[name] = value return values @@ -88,6 +88,13 @@ class QuickAddForm(forms.Form): 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) wifi = forms.ChoiceField(label="WLAN", 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) 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: + if name in TRISTATE_FIELDS and not value: value = Device.TriState.UNKNOWN setattr(device, name, value) if commit: @@ -179,11 +186,18 @@ class InspectionForm(forms.Form): 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"): + 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): self.fields[name] = deepcopy(DeviceForm.base_fields[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 def specification_fields(self): return [self[name] for name in self.fields if name != "defects"] diff --git a/inventory/import_export.py b/inventory/import_export.py index d74bf5a..40013de 100644 --- a/inventory/import_export.py +++ b/inventory/import_export.py @@ -14,11 +14,18 @@ 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 .device_types import TRISTATE_FIELDS, clear_inapplicable from .services import add_audit 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", "datum": "date", "empfänger": "recipient", "empfaenger": "recipient", "ort": "location", "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"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("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(), print_technology=str(data.get("print_technology") or "").strip(), 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(), 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: 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) if hasattr(value, "label"): return value.label diff --git a/inventory/migrations/0014_allinonespecification_camera_and_more.py b/inventory/migrations/0014_allinonespecification_camera_and_more.py new file mode 100644 index 0000000..ae19791 --- /dev/null +++ b/inventory/migrations/0014_allinonespecification_camera_and_more.py @@ -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), + ), + ] diff --git a/inventory/models.py b/inventory/models.py index 3cb466c..21d25a4 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -153,6 +153,13 @@ class NumberSequence(models.Model): HARDWARE_DEFAULTS = { + "camera": "UNKNOWN", + "sound": "UNKNOWN", + "hdmi": "UNKNOWN", + "vga": "UNKNOWN", + "dvi": "UNKNOWN", + "displayport": "UNKNOWN", + "aspect_ratio": "", "battery": "UNKNOWN", "wifi": "UNKNOWN", "lan": "UNKNOWN", "display_inches": None, "ram_gb": None, "ssd_gb": None, "hdd_gb": None, "cpu": "", "hwid": "", "ports": "", "resolution": "", @@ -216,6 +223,13 @@ class Device(TimeStampedModel): cpu = HardwareValue("cpu") hwid = HardwareValue("hwid") 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") print_technology = HardwareValue("print_technology") duplex = HardwareValue("duplex") @@ -358,10 +372,12 @@ class ComputerCore(models.Model): 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) 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) + 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): @@ -370,17 +386,24 @@ class DesktopSpecification(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) 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): - 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) 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) + 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): diff --git a/inventory/query.py b/inventory/query.py index 102a72e..ea9e5c6 100644 --- a/inventory/query.py +++ b/inventory/query.py @@ -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)) else: 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() for value in params.getlist("cpu_vendor"): if value in CPU_VENDOR_QUERIES: diff --git a/inventory/services.py b/inventory/services.py index e544589..24ed815 100644 --- a/inventory/services.py +++ b/inventory/services.py @@ -11,10 +11,17 @@ 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 +from .device_types import TRISTATE_FIELDS, HARDWARE_FIELDS, applicable_fields, bulk_create_specifications, clear_inapplicable 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", "location": "Ort", "ticket": "Ticket", "device_type": "Typ", "price": "Preis", "battery": "Akku", "wifi": "WLAN", "lan": "LAN", "display_inches": "Zoll", @@ -30,7 +37,6 @@ RELATION_FIELDS = { } INTEGER_FIELDS = {"ram_gb", "ssd_gb", "hdd_gb"} DECIMAL_FIELDS = {"price", "display_inches"} -TRISTATE_FIELDS = {"battery", "wifi", "lan", "duplex", "color_printing"} _UNSET = object() diff --git a/inventory/static/inventory/css/app.css b/inventory/static/inventory/css/app.css index b1be769..7a48794 100644 --- a/inventory/static/inventory/css/app.css +++ b/inventory/static/inventory/css/app.css @@ -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}} /* 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 */ .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} diff --git a/inventory/static/inventory/js/app.js b/inventory/static/inventory/js/app.js index 979a9a5..1e116e2 100644 --- a/inventory/static/inventory/js/app.js +++ b/inventory/static/inventory/js/app.js @@ -221,7 +221,7 @@ 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))); + const visible=new Set(storedVisible||availableKeys); if(!localStorage.getItem(visibilityUpgradeKey)){ requiredPhotoColumns.forEach(key=>visible.add(key)); localStorage.setItem(visibilityUpgradeKey,"1"); @@ -229,6 +229,12 @@ localStorage.setItem(visibleStorageKey,JSON.stringify(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")); panel.replaceChildren(); @@ -243,7 +249,7 @@ 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)); + const values=[...visible]; localStorage.setItem(visibleStorageKey,JSON.stringify(values)); savePreferences({visible_columns:values}); applyVisibility(); diff --git a/inventory/templates/inventory/_device_table.html b/inventory/templates/inventory/_device_table.html index 36e07ee..74b3d76 100644 --- a/inventory/templates/inventory/_device_table.html +++ b/inventory/templates/inventory/_device_table.html @@ -21,6 +21,7 @@ {% if "ssd_gb" in visible_hardware_fields %} SSD{% endif %} {% if "hdd_gb" in visible_hardware_fields %} HDD{% endif %} {% if "cpu" in visible_hardware_fields %} CPU{% endif %} + {% for name, label in extra_hardware_columns %}{{ label }}{% endfor %} Modell Bearbeiter Spender @@ -49,11 +50,12 @@ {% if "ssd_gb" in visible_hardware_fields %} {% if "ssd_gb" in device.hardware_fields %}{% if device.ssd_gb != None %}{{ device.ssd_gb }} GB{% else %}—{% endif %}{% else %}—{% endif %}{% endif %} {% if "hdd_gb" in visible_hardware_fields %} {% if "hdd_gb" in device.hardware_fields %}{% if device.hdd_gb != None %}{{ device.hdd_gb }} GB{% else %}—{% endif %}{% else %}—{% endif %}{% endif %} {% if "cpu" in visible_hardware_fields %} {% if "cpu" in device.hardware_fields %}{{ device.cpu|default:'—' }}{% else %}—{% endif %}{% endif %} + {% for name, label in extra_hardware_columns %}{% if name in device.hardware_fields %}{% hardware_display device name %}{% else %}—{% endif %}{% endfor %} {{ device.model|default:'—' }} {{ device.employee.code|default:'—' }} {{ device.donor|default:'—' }} {{ device.comment|default:'—' }} - {% for field in custom_columns %}{{ device.custom_values|get_item:field.key|display_custom|default:'—' }}{% endfor %} + {% for field in custom_columns %}{{ device.custom_values|get_item:field.key|display_custom|default_if_none:'—' }}{% endfor %} {% empty %}Keine Geräte gefundenFilter anpassen oder ein neues Gerät anlegen.{% endfor %} diff --git a/inventory/templates/inventory/device_detail.html b/inventory/templates/inventory/device_detail.html index f81e4fe..69b854b 100644 --- a/inventory/templates/inventory/device_detail.html +++ b/inventory/templates/inventory/device_detail.html @@ -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 %}
← Zur Geräteliste

Gerät #{{ device.number }}

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

Ablauf: {{ device.get_stage_display }}

@@ -30,7 +30,7 @@

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 %} + {% if definitions %}

Zusatzfelder

{% for field in definitions %}{% endfor %}
{% endif %}
Abbrechen{% if can_edit_devices %}{% endif %}
{% if can_edit_devices and not device.archived_at %}
{% csrf_token %}
{% endif %} diff --git a/inventory/templates/inventory/device_list.html b/inventory/templates/inventory/device_list.html index 5f13206..35031b6 100644 --- a/inventory/templates/inventory/device_list.html +++ b/inventory/templates/inventory/device_list.html @@ -74,6 +74,13 @@ + + + + + + +
diff --git a/inventory/templatetags/inventory_tags.py b/inventory/templatetags/inventory_tags.py index eee574b..9fda033 100644 --- a/inventory/templatetags/inventory_tags.py +++ b/inventory/templatetags/inventory_tags.py @@ -46,6 +46,8 @@ def contains_value(values, value): @register.filter def display_custom(value): + if value is None or value == "": + return "—" if isinstance(value, list): return ", ".join(str(item) for item in value) if value is True: @@ -64,3 +66,9 @@ def german_date(value): return datetime.strptime(text, "%Y-%m-%d").strftime("%d.%m.%Y") except ValueError: 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 "—" diff --git a/inventory/test_issue_regressions.py b/inventory/test_issue_regressions.py new file mode 100644 index 0000000..91c6698 --- /dev/null +++ b/inventory/test_issue_regressions.py @@ -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, '0', html=True) + self.assertContains(listing, 'NEIN', 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, 'JA', html=True) + self.assertNotContains(listing, '') + + 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]) diff --git a/inventory/test_specification_migration.py b/inventory/test_specification_migration.py index 888812a..2c528ca 100644 --- a/inventory/test_specification_migration.py +++ b/inventory/test_specification_migration.py @@ -7,7 +7,11 @@ from django.test import TransactionTestCase OLD = [("inventory", "0008_normalize_hardware_by_type")] 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): @@ -40,7 +44,7 @@ class SpecificationMigrationTests(TransactionTestCase): self.assertEqual(str(Monitor.objects.get(device_id=screen_id).display_inches), "24.0") self.assertFalse(Computer.objects.filter(device_id=printer_id).exists()) finally: - MigrationExecutor(connection).migrate(LATEST) + restore_latest_schema() class DeviceTypeTableMigrationTests(TransactionTestCase): @@ -60,8 +64,8 @@ class DeviceTypeTableMigrationTests(TransactionTestCase): 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) ids[label] = device.pk - MigrationExecutor(connection).migrate(LATEST) - apps = MigrationExecutor(connection).loader.project_state(LATEST).apps + restore_latest_schema() + 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", "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") @@ -69,4 +73,30 @@ class DeviceTypeTableMigrationTests(TransactionTestCase): 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()) finally: - MigrationExecutor(connection).migrate(LATEST) + restore_latest_schema() + + +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() diff --git a/inventory/views.py b/inventory/views.py index a061054..4117f12 100644 --- a/inventory/views.py +++ b/inventory/views.py @@ -199,11 +199,13 @@ def _list_context(request: HttpRequest, archived: bool = False) -> dict: "statuses": ChoiceValue.objects.filter(kind=ChoiceValue.Kind.STATUS, active=True), "stages": Device.Stage.choices, "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), "employees": Employee.objects.filter(active=True), "cpu_vendors": CPU_VENDOR_CHOICES, "cpu_families": CPU_FAMILY_CHOICES, + "tristate_choices": Device.TriState.choices, "custom_columns": FieldDefinition.objects.filter(active=True, show_in_list=True), "custom_filters": FieldDefinition.objects.filter(active=True, filterable=True).prefetch_related("options"), "total_count": page.paginator.count, @@ -410,6 +412,10 @@ def device_detail(request: HttpRequest, pk: int) -> HttpResponse: type_field_map = _hardware_type_map() selected_type_id = str(form["device_type"].value() or "") 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), "hardware_fields": type_field_map.get(selected_type_id, applicable_fields(device.device_type)), "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"), ("wifi", "WLAN"), ("lan", "LAN"), ("display_inches", "Zoll"), ("ram_gb", "RAM"), ("ssd_gb", "SSD"), ("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"), ("duplex", "Duplex"), ("color_printing", "Farbdruck"), ("model", "Modell"), ("employee", "Bearbeiter"), ("donor", "Spender"), ("comment", "Kommentar")]] diff --git a/scripts/test_browser.py b/scripts/test_browser.py index b205cc1..8014857 100644 --- a/scripts/test_browser.py +++ b/scripts/test_browser.py @@ -76,7 +76,7 @@ def run(): django.setup() from django.contrib.auth.models import User 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("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")): 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() base_url = f"http://127.0.0.1:{port}" 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") 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/", "/devices/1/", "/configuration/", "/users/", "/import/", "/archive/", "/history/"): response = page.goto(f"{base_url}{route}") @@ -257,6 +280,17 @@ def run(): expect(mobile_page.locator('.mobile-header')).to_be_visible() mobile_page.locator('[data-sidebar-toggle]').click() 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() print("PASS dark mode persistence and mobile navigation", flush=True) diff --git a/scripts/test_frontend.cjs b/scripts/test_frontend.cjs index 6c92200..24ac7f5 100644 --- a/scripts/test_frontend.cjs +++ b/scripts/test_frontend.cjs @@ -55,4 +55,29 @@ async function run() { console.log("Frontend regression checks passed: safe confirmation text, preserved selection, no-op blur, revision save."); } finally { dom.window.close(); } } -run().catch(error => { console.error(error); process.exitCode = 1; }); +async function testCustomColumns() { + const dom = new JSDOM(` +
+
+ + +
Nr.Checked
1JA
`, {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; });