Compare commits

...

5 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
Codex
57764eec03 Restore current schema after historical migration tests
Some checks failed
Tests / postgres (push) Has been cancelled
Tests / postgres (pull_request) Has been cancelled
2026-10-14 19:42:49 +02:00
Codex
c560c0450d Add hardware-linked USB intake and inspection workflow 2026-10-14 19:36:36 +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
32 changed files with 1101 additions and 39 deletions

View File

@@ -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

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
- 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,30 @@ 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.
## USB AIO station API
The USB station resolves hardware identity with `POST /api/v1/devices/resolve/`, then reads the returned inventory number, workflow `stage`, and status. For `INTAKE` devices only, `POST /api/v1/devices/<number>/intake/` accepts verified CPU/model/RAM/storage measurements plus `hwid` and `expected_revision`. Later workflow stages reject intake writes.
`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.
## 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,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.
@@ -18,12 +23,15 @@ Gunicorn sind nicht direkt über veröffentlichte Docker-Ports erreichbar.
## HTTPS im lokalen Netzwerk
Traefik verwendet für die private IP-Adresse eine lokale Zertifizierungsstelle.
Das öffentliche Stammzertifikat liegt in [homelab-root-ca.crt](homelab-root-ca.crt)
und auf dem Server unter `/home/denis/csr-inventory-root-ca.crt`.
Das damalige öffentliche Stammzertifikat wurde als `homelab-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
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
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):
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)

88
inventory/api_identity.py Normal file
View File

@@ -0,0 +1,88 @@
"""Atomic hardware identity registration for intake clients."""
import re
import uuid
from django.db import transaction
from django.db.models import Q
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from .api import SecureAPIResponseMixin, StrictSerializer, DeviceSerializer
from .models import Device, DeviceIdentity, NumberSequence, ChoiceValue, AuditLog
from .query import COMPUTER_RELATIONS
from .services import add_audit
def canonical_hwid(value):
value = value.strip().lower()
if value.startswith('dmi-uuid:'):
try:
token = uuid.UUID(value[9:])
except ValueError:
raise serializers.ValidationError('Ungültige Hardware-UUID.')
if token.int in (0, (1 << 128) - 1):
raise serializers.ValidationError('Platzhalter-UUID ist nicht erlaubt.')
return 'dmi-uuid:' + str(token)
if re.fullmatch(r'dmi-serial-sha256:[0-9a-f]{64}', value):
return value
raise serializers.ValidationError('Verifizierbare Hardware-Kennung erforderlich.')
class IdentitySerializer(StrictSerializer):
hwid = serializers.CharField(max_length=100)
aliases = serializers.ListField(child=serializers.CharField(max_length=100), max_length=2, required=False, default=list)
device_kind = serializers.ChoiceField(choices=['Laptop', 'Desktop', 'AiO'])
model = serializers.CharField(max_length=200, required=False, default='')
def validate_aliases(self, values):
return list(dict.fromkeys(canonical_hwid(v) for v in values))
def validate_hwid(self, value):
return canonical_hwid(value)
class DeviceResolveView(SecureAPIResponseMixin, APIView):
@transaction.atomic
def post(self, request):
data = IdentitySerializer(data=request.data)
data.is_valid(raise_exception=True)
values = data.validated_data
hwid = values['hwid']
# Serialize allocation and identity lookup across clients and processes.
NumberSequence.objects.select_for_update().get_or_create(name='device')
keys = list(dict.fromkeys([hwid, *values['aliases']]))
# At most one value per source. Differing UUIDs/serials need review.
if len({k.split(':')[0] for k in keys}) != len(keys):
return Response({'detail': 'Widersprüchliche Hardware-Kennungen.'}, status=409)
ids = set(DeviceIdentity.objects.filter(hwid__in=keys).values_list('device_id', flat=True))
query = Q()
for relation in COMPUTER_RELATIONS:
for key in keys:
query |= Q(**{f'{relation}__hwid__iexact': key})
if key.startswith('dmi-uuid:'):
query |= Q(**{f'{relation}__hwid__iexact': key[9:]})
ids.update(Device.objects.filter(query).values_list('pk', flat=True))
if len(ids) > 1:
return Response({'detail': 'Hardware-Kennungen sind mehreren Geräten zugeordnet.'}, status=409)
device = Device.objects.get(pk=next(iter(ids))) if ids else None
if device is not None:
if device.archived_at:
return Response({'detail': 'Gerät ist archiviert; zuerst im Inventory prüfen.'}, status=409)
if values['model'] and device.model and values['model'].casefold() != device.model.strip().casefold():
return Response({'detail': 'Hardware-Kennung vorhanden, aber Modell stimmt nicht überein.'}, status=409)
stored = list(device.hardware_identities.values_list('hwid', flat=True))
for key in keys:
if any(old.split(':')[0] == key.split(':')[0] and old != key for old in stored):
return Response({'detail': 'UUID oder Seriennummer hat sich geändert; Gerät manuell prüfen.'}, status=409)
for key in keys:
DeviceIdentity.objects.get_or_create(hwid=key, defaults={'device': device})
return Response(DeviceSerializer(device).data)
status = ChoiceValue.objects.filter(kind='status', active=True, label__iexact='Eingang').first()
kind = ChoiceValue.objects.filter(kind='device_type', active=True, label__iexact=values['device_kind']).first()
if not status or not kind:
raise serializers.ValidationError('Aktiver Status Eingang und passender Gerätetyp müssen konfiguriert sein.')
device = Device.objects.create(status=status, device_type=kind, hwid=hwid, model=values['model'])
for key in keys:
DeviceIdentity.objects.create(hwid=key, device=device)
add_audit(request.user, device, AuditLog.Action.CREATE,
new=f'Gerät #{device.number} per Hardware-Kennung', api_client=request.auth.name)
return Response(DeviceSerializer(device).data, status=201)

98
inventory/api_station.py Normal file
View File

@@ -0,0 +1,98 @@
"""USB station: guarded intake updates and idempotent inspection snapshots."""
import hashlib
import json
from django.db import transaction
from django.shortcuts import get_object_or_404
from rest_framework import serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from .api import SecureAPIResponseMixin, StrictSerializer, DeviceSerializer, apply_changes
from .api_identity import canonical_hwid
from .models import Device, DeviceIdentity, ChoiceValue, InspectionSubmission, InspectionRecord, AuditLog
from .services import add_audit
from .workflow import advance_device
class IdentityRequest(StrictSerializer):
hwid = serializers.CharField(max_length=100)
expected_revision = serializers.IntegerField(min_value=1)
def validate_hwid(self, value): return canonical_hwid(value)
class SpecsSerializer(StrictSerializer):
model = serializers.CharField(max_length=200, required=False)
cpu = serializers.CharField(max_length=200, required=False)
ram_gb = serializers.IntegerField(min_value=1, max_value=2048, required=False)
ssd_gb = serializers.IntegerField(min_value=1, required=False)
hdd_gb = serializers.IntegerField(min_value=1, required=False)
class IntakeRequest(IdentityRequest):
specifications = SpecsSerializer()
class ResultSerializer(StrictSerializer):
outcome = serializers.ChoiceField(choices=['NOT_TESTED','NOT_APPLICABLE','PASS','HARDWARE_FAILURE','UNAVAILABLE','SETUP_PROBLEM','NEEDS_REVIEW'])
notes = serializers.CharField(max_length=10000, allow_blank=True, required=False, default='')
evidence = serializers.CharField(max_length=100000, allow_blank=True, required=False, default='')
history = serializers.ListField(child=serializers.JSONField(), max_length=500, required=False, default=list)
class InspectionRequest(IdentityRequest):
request_id = serializers.UUIDField()
phase = serializers.ChoiceField(choices=['STARTED','IN_PROGRESS','COMPLETE'])
technician = serializers.CharField(max_length=200, allow_blank=True)
notes = serializers.CharField(max_length=10000, allow_blank=True)
results = serializers.DictField(child=ResultSerializer())
def validate_results(self, value):
if len(value)>50 or len(json.dumps(value))>500000:
raise serializers.ValidationError('Too many inspection results.')
if any(len(k)>80 for k in value): raise serializers.ValidationError('Invalid test name.')
return value
def validate(self, attrs):
if attrs['phase']=='COMPLETE' and (not attrs['technician'].strip() or not attrs['results'] or any(r['outcome'] in ['NOT_TESTED','NEEDS_REVIEW'] for r in attrs['results'].values())):
raise serializers.ValidationError('Complete all tests and enter the technician name first.')
return attrs
def locked_device(number, hwid):
d=get_object_or_404(Device.objects.select_for_update(), number=number, archived_at__isnull=True)
if not DeviceIdentity.objects.filter(device=d,hwid=hwid).exists():
raise serializers.ValidationError('Hardware identity does not match this device.')
return d
class IntakeSyncView(SecureAPIResponseMixin, APIView):
@transaction.atomic
def post(self, request, number):
s=IntakeRequest(data=request.data);s.is_valid(raise_exception=True);v=s.validated_data
d=locked_device(number,v['hwid'])
if d.stage != Device.Stage.INTAKE or d.revision != v['expected_revision']:
return Response({'detail':'Device state changed. Refresh before intake sync.'},status=409)
changes={k:x for k,x in v['specifications'].items() if getattr(d,k)!=x}
if changes:
d=apply_changes(d,changes,user=request.user,api_client=request.auth.name,expected_revision=d.revision)
return Response(DeviceSerializer(d).data)
class InspectionSyncView(SecureAPIResponseMixin, APIView):
@transaction.atomic
def post(self, request, number):
s=InspectionRequest(data=request.data);s.is_valid(raise_exception=True);v=s.validated_data
d=locked_device(number,v['hwid'])
payload={k:x for k,x in request.data.items() if k not in ['expected_revision','request_id']}
digest=hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',',':')).encode()).hexdigest()
previous=InspectionSubmission.objects.filter(request_id=v['request_id']).first()
if previous:
if previous.device_id != d.pk or previous.payload_hash != digest:
return Response({'detail':'Request ID already used for different results.'},status=409)
return Response(DeviceSerializer(d).data)
if d.revision != v['expected_revision'] or d.stage not in [Device.Stage.INTAKE,Device.Stage.INSPECTION,Device.Stage.REPAIR]:
return Response({'detail':'Device state changed. Refresh before inspection sync.'},status=409)
if d.stage != Device.Stage.INSPECTION:
d=advance_device(d.pk,Device.Stage.INSPECTION,user=request.user,note='USB inspection',expected_revision=d.revision)
state=ChoiceValue.objects.get(kind='status',label='Inspektion',active=True)
if d.status_id != state.pk:
old=d.status;d.status=state;d.save(update_fields=['status'])
add_audit(request.user,d,AuditLog.Action.UPDATE,'Status',old,state,api_client=request.auth.name)
else:
d.save(update_fields=['updated_at'])
InspectionSubmission.objects.create(request_id=v['request_id'],device=d,payload_hash=digest,payload=payload,recorded_by=request.user)
lines=[f"USB {v['phase']} · {v['technician']}",v['notes']]
lines += [f"{key}: {result['outcome']} — {result['notes']}" for key,result in v['results'].items()]
summary='\n'.join(lines)
InspectionRecord.objects.create(device=d,defects=summary,recorded_by=request.user)
add_audit(request.user,d,AuditLog.Action.UPDATE,'USB inspection','',summary,api_client=request.auth.name)
return Response(DeviceSerializer(d).data,status=201)

View File

@@ -3,9 +3,15 @@ from django.urls import path
from .api import BulkUpdateView, ChoicesView, DeviceDetailView, DeviceListView
from .api_identity import DeviceResolveView
from .api_station import IntakeSyncView, InspectionSyncView
app_name = "inventory-api"
urlpatterns = [
path("devices/<int:number>/intake/", IntakeSyncView.as_view(), name="device-intake"),
path("devices/<int:number>/inspection/", InspectionSyncView.as_view(), name="device-inspection"),
path("devices/resolve/", DeviceResolveView.as_view(), name="device-resolve"),
path("devices/", DeviceListView.as_view(), name="device-list"),
path("devices/bulk-update/", BulkUpdateView.as_view(), name="device-bulk-update"),
path("devices/<int:number>/", DeviceDetailView.as_view(), name="device-detail"),

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

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,11 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [('inventory', '0013_deviceregistration')]
operations = [migrations.CreateModel(name='DeviceIdentity', fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('hwid', models.CharField(max_length=100, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hardware_identities', to='inventory.device')),
])]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
def add_status(apps, schema_editor):
apps.get_model('inventory','ChoiceValue').objects.get_or_create(kind='status',label='Inspektion',defaults={'active':True})
class Migration(migrations.Migration):
dependencies=[('inventory','0014_deviceidentity'),migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations=[migrations.CreateModel(name='InspectionSubmission',fields=[
('id',models.BigAutoField(auto_created=True,primary_key=True,serialize=False,verbose_name='ID')),
('request_id',models.UUIDField(unique=True)),('payload_hash',models.CharField(max_length=64)),
('payload',models.JSONField()),('created_at',models.DateTimeField(auto_now_add=True)),
('device',models.ForeignKey(on_delete=django.db.models.deletion.PROTECT,related_name='usb_inspections',to='inventory.device')),
('recorded_by',models.ForeignKey(null=True,on_delete=django.db.models.deletion.SET_NULL,to=settings.AUTH_USER_MODEL)),
]),migrations.RunPython(add_status,migrations.RunPython.noop)]

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 = {
"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):
@@ -570,3 +593,18 @@ class ImportJob(TimeStampedModel):
headers = models.JSONField(default=list)
preview_rows = models.JSONField(default=list)
result = models.JSONField(default=dict)
class DeviceIdentity(models.Model):
"""Stable intake identity, independent of mutable specifications and USBs."""
hwid = models.CharField(max_length=100, unique=True)
device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='hardware_identities')
created_at = models.DateTimeField(auto_now_add=True)
class InspectionSubmission(models.Model):
request_id = models.UUIDField(unique=True)
device = models.ForeignKey(Device, on_delete=models.PROTECT, related_name='usb_inspections')
payload_hash = models.CharField(max_length=64)
payload = models.JSONField()
recorded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)

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))
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:

View File

@@ -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()

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}}
/* 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}

View File

@@ -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();

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 "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 %}
{% 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="employee"><a href="?{% sort_query 'employee' %}">Bearbeiter</a></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 "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 %}
{% 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="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="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>
{% 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>

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>
<div class="device-workbench">
<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><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>
{% 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>
</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 %}

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>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>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>Ticket enthält<input name="ticket_contains" value="{{ request.GET.ticket_contains }}"></label>
</div>

View File

@@ -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 "—"

View File

@@ -0,0 +1,84 @@
import json
import uuid
from datetime import timedelta
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from django.contrib.auth.models import User
from django.db import close_old_connections, IntegrityError, transaction
from django.test import TestCase, TransactionTestCase, Client, skipUnlessDBFeature
from django.utils import timezone
from .models import Device, DeviceIdentity, ChoiceValue, InventoryAPIKey, NumberSequence
class IdentityTests(TestCase):
def setUp(self):
self.user = User.objects.create_user('identity-operator', is_superuser=True)
_, self.key = InventoryAPIKey.objects.create_key(name='identity', service_user=self.user,
can_read=True, can_write=True, expiry_date=timezone.now()+timedelta(days=1))
self.status = ChoiceValue.objects.create(kind='status', label='Eingang')
self.kind = ChoiceValue.objects.create(kind='device_type', label='Laptop')
self.payload = {'hwid': 'dmi-uuid:'+str(uuid.uuid4()), 'device_kind': 'Laptop', 'model': 'Test Laptop'}
def post(self, data=None, key=None):
return self.client.post('/api/v1/devices/resolve/', json.dumps(data or self.payload), content_type='application/json', HTTP_AUTHORIZATION='Api-Key '+(key or self.key))
def test_repeat_uses_same_number_and_preserves_repairs(self):
a=self.post(); self.assertEqual(a.status_code,201,a.content)
device=Device.objects.get(); device.comment='Repaired'; device.save()
b=self.post(); self.assertEqual(b.status_code,200,b.content)
self.assertEqual(a.json()['number'],b.json()['number'])
self.assertEqual(Device.objects.get().comment,'Repaired')
self.assertEqual(DeviceIdentity.objects.count(),1)
def test_existing_raw_uuid_is_reused(self):
d=Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid'][9:],model='Test Laptop')
r=self.post(); self.assertEqual(r.status_code,200,r.content); self.assertEqual(r.json()['number'],d.number)
def test_invalid_identifier_and_model_conflict(self):
for hwid in ['','unknown','dmi-uuid:00000000-0000-0000-0000-000000000000','dmi-uuid:ffffffff-ffff-ffff-ffff-ffffffffffff']:
self.assertEqual(self.post({**self.payload,'hwid':hwid}).status_code,400)
self.post(); self.assertEqual(self.post({**self.payload,'model':'Different'}).status_code,409)
self.assertEqual(Device.objects.count(),1)
def test_existing_ambiguous_or_archived_is_not_duplicated(self):
d=Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid'],archived_at=timezone.now())
self.assertEqual(self.post().status_code,409)
Device.objects.create(status=self.status,device_type=self.kind,hwid=self.payload['hwid'])
self.assertEqual(self.post().status_code,409)
self.assertEqual(Device.objects.count(),2)
def test_readonly_cannot_register(self):
_,key=InventoryAPIKey.objects.create_key(name='read',service_user=self.user,can_read=True,can_write=False,expiry_date=timezone.now()+timedelta(days=1))
self.assertEqual(self.post(key=key).status_code,403)
self.assertFalse(Device.objects.exists())
def test_serial_fallback_then_uuid_does_not_duplicate(self):
serial='dmi-serial-sha256:'+'a'*64
first=self.post({**self.payload,'hwid':serial})
second=self.post({**self.payload,'aliases':[serial]})
self.assertEqual(first.status_code,201,first.content)
self.assertEqual(second.status_code,200,second.content)
self.assertEqual(first.json()['number'],second.json()['number'])
self.assertEqual(DeviceIdentity.objects.count(),2)
conflict=self.post({**self.payload,'aliases':['dmi-serial-sha256:'+'b'*64]})
self.assertEqual(conflict.status_code,409,conflict.content)
self.assertEqual(Device.objects.count(),1)
def test_database_enforces_unique_identity(self):
self.post()
with self.assertRaises(IntegrityError), transaction.atomic():
DeviceIdentity.objects.create(hwid=self.payload['hwid'],device=Device.objects.get())
class ConcurrentIdentityTests(TransactionTestCase):
@skipUnlessDBFeature('has_select_for_update')
def test_two_clients_allocate_once(self):
user=User.objects.create_user('concurrent-identity',is_superuser=True)
_,key=InventoryAPIKey.objects.create_key(name='identity',service_user=user,can_read=True,can_write=True,expiry_date=timezone.now()+timedelta(days=1))
ChoiceValue.objects.create(kind='status',label='Eingang')
ChoiceValue.objects.create(kind='device_type',label='Laptop')
NumberSequence.objects.update_or_create(name='device',defaults={'last_value':0})
payload={'hwid':'dmi-uuid:'+str(uuid.uuid4()),'device_kind':'Laptop'}
barrier=Barrier(2)
def submit(_):
close_old_connections()
try:
barrier.wait(timeout=10)
r=Client().post('/api/v1/devices/resolve/',json.dumps(payload),content_type='application/json',HTTP_AUTHORIZATION='Api-Key '+key)
return r.status_code,r.json()['number']
finally: close_old_connections()
with ThreadPoolExecutor(max_workers=2) as pool: results=list(pool.map(submit,range(2)))
self.assertEqual(sorted(x[0] for x in results),[200,201])
self.assertEqual(len({x[1] for x in results}),1)
self.assertEqual(Device.objects.count(),1)

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")]
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()

51
inventory/test_station.py Normal file
View File

@@ -0,0 +1,51 @@
import json,uuid
from datetime import timedelta
from django.test import TestCase
from django.contrib.auth.models import User
from django.utils import timezone
from .models import Device,DeviceIdentity,ChoiceValue,InventoryAPIKey,InspectionSubmission,InspectionRecord
class StationTests(TestCase):
def setUp(self):
self.user=User.objects.create_user('station',is_superuser=True)
_,self.key=InventoryAPIKey.objects.create_key(name='station',service_user=self.user,can_read=True,can_write=True,expiry_date=timezone.now()+timedelta(days=1))
state=ChoiceValue.objects.create(kind='status',label='Eingang')
ChoiceValue.objects.get_or_create(kind='status',label='Inspektion',defaults={'active':True})
kind=ChoiceValue.objects.create(kind='device_type',label='Laptop')
self.d=Device.objects.create(status=state,device_type=kind,model='Test',ram_gb=8)
self.hwid='dmi-uuid:'+str(uuid.uuid4())
DeviceIdentity.objects.create(hwid=self.hwid,device=self.d)
def post(self,route,body,key=None):
return self.client.post(f'/api/v1/devices/{self.d.number}/{route}/',json.dumps(body),content_type='application/json',HTTP_AUTHORIZATION='Api-Key '+(key or self.key))
def inspection(self,phase='STARTED'):
self.d.refresh_from_db()
return {'hwid':self.hwid,'expected_revision':self.d.revision,'request_id':str(uuid.uuid4()),'phase':phase,'technician':'Tech','notes':'','results':{'wifi':{'outcome':'PASS','notes':'Connected','history':[]}}}
def test_intake_specs_then_start_changes_stage_and_status(self):
r=self.post('intake',{'hwid':self.hwid,'expected_revision':self.d.revision,'specifications':{'ram_gb':16,'ssd_gb':512}})
self.assertEqual(r.status_code,200,r.content);self.assertEqual(r.json()['stage'],'INTAKE')
r=self.post('inspection',self.inspection());self.assertEqual(r.status_code,201,r.content)
self.assertEqual(r.json()['stage'],'INSPECTION');self.assertEqual(r.json()['status']['label'],'Inspektion')
self.assertEqual(InspectionSubmission.objects.count(),1)
def test_repeat_and_completion_preserve_structured_results(self):
body=self.inspection('COMPLETE');body['results']['wifi']['outcome']='UNAVAILABLE'
r=self.post('inspection',body);self.assertEqual(r.status_code,201,r.content)
self.assertEqual(self.post('inspection',body).status_code,200)
self.assertEqual(InspectionSubmission.objects.count(),1);self.assertEqual(InspectionRecord.objects.count(),1)
self.assertEqual(InspectionSubmission.objects.get().payload['results']['wifi']['outcome'],'UNAVAILABLE')
body['notes']='different';self.assertEqual(self.post('inspection',body).status_code,409)
def test_incomplete_cannot_be_completed(self):
body=self.inspection('COMPLETE');body['results']['wifi']['outcome']='NOT_TESTED'
self.assertEqual(self.post('inspection',body).status_code,400)
self.assertFalse(InspectionSubmission.objects.exists())
def test_stale_revision_wrong_device_and_later_stages_blocked(self):
body=self.inspection();self.d.comment='Changed';self.d.save()
self.assertEqual(self.post('inspection',body).status_code,409)
body=self.inspection();body['hwid']='dmi-uuid:'+str(uuid.uuid4())
self.assertEqual(self.post('inspection',body).status_code,400)
self.d.stage='STORAGE';self.d.save()
self.assertEqual(self.post('inspection',self.inspection()).status_code,409)
self.assertEqual(self.post('intake',{'hwid':self.hwid,'expected_revision':self.d.revision,'specifications':{'ram_gb':32}}).status_code,409)
self.d.refresh_from_db();self.assertEqual(self.d.ram_gb,8)
def test_readonly_cannot_change_inspection(self):
_,key=InventoryAPIKey.objects.create_key(name='read',service_user=self.user,can_read=True,can_write=False,expiry_date=timezone.now()+timedelta(days=1))
self.assertEqual(self.post('inspection',self.inspection(),key).status_code,403)

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),
"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")]]

View File

@@ -17,6 +17,7 @@ TRANSITIONS = {
Device.Stage.ISSUED: {Device.Stage.INTAKE},
}
STATUS_BY_STAGE = {
Device.Stage.INSPECTION: "Inspektion",
Device.Stage.INTAKE: "Eingang",
Device.Stage.REPAIR: "Defekt",
Device.Stage.STORAGE: "Fertig",

View File

@@ -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)

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.");
} 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; });