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