Compare commits
6 Commits
e2870c2fda
...
feature/us
| Author | SHA1 | Date | |
|---|---|---|---|
| dee25b5cbb | |||
|
|
57764eec03 | ||
|
|
c560c0450d | ||
| 72138203be | |||
| 048cec333e | |||
| 35f88746ff |
@@ -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
|
||||
|
||||
33
README.md
33
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,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
240
docs/LOCAL_DEPLOY.md
Normal 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.
|
||||
@@ -1,11 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBojCCAUmgAwIBAgIQUS4ubv+VPmHfsOZnHA55kDAKBggqhkjOPQQDAjAwMS4w
|
||||
LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X
|
||||
DTI2MDkyMzE3MjEyMVoXDTM2MDgwMTE3MjEyMVowMDEuMCwGA1UEAxMlQ2FkZHkg
|
||||
TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG
|
||||
SM49AwEHA0IABNfvDgBuLlx110lhzZllEhvq9OJ9y/BxFUJnR8RK5idzm1R9H/U/
|
||||
PlszTxrP5QkxaJgBgjecOJS+19ziBEYdBXyjRTBDMA4GA1UdDwEB/wQEAwIBBjAS
|
||||
BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSUYvaXe7IkfVqEf49jgl5npdiw
|
||||
BTAKBggqhkjOPQQDAgNHADBEAiBjTG79jTLkoYy5yhXPPbegqDlpt0+sWrp71ZZc
|
||||
f/hkXgIgLG0JFVISRSCzm+bM5DM/iAwX4nyNrGKoVGuRaNtlD6w=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -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
|
||||
|
||||
@@ -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
88
inventory/api_identity.py
Normal 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
98
inventory/api_station.py
Normal 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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
11
inventory/migrations/0014_deviceidentity.py
Normal file
11
inventory/migrations/0014_deviceidentity.py
Normal 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')),
|
||||
])]
|
||||
16
inventory/migrations/0015_inspectionsubmission.py
Normal file
16
inventory/migrations/0015_inspectionsubmission.py
Normal 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)]
|
||||
10
inventory/migrations/0016_merge_usb_station_and_equipment.py
Normal file
10
inventory/migrations/0016_merge_usb_station_and_equipment.py
Normal 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 = []
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "—"
|
||||
|
||||
84
inventory/test_identity.py
Normal file
84
inventory/test_identity.py
Normal 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)
|
||||
123
inventory/test_issue_regressions.py
Normal file
123
inventory/test_issue_regressions.py
Normal 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])
|
||||
@@ -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
51
inventory/test_station.py
Normal 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)
|
||||
@@ -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")]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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; });
|
||||
|
||||
Reference in New Issue
Block a user