Compare commits

...

2 Commits

Author SHA1 Message Date
72138203be Align deployment access with inventar user and remove retired CA
Some checks failed
Tests / postgres (push) Has been cancelled
2026-10-13 10:58:19 +02:00
048cec333e Fix device issues and document current LAN deployment 2026-10-13 10:58:18 +02:00
23 changed files with 713 additions and 39 deletions

View File

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

View File

@@ -23,6 +23,12 @@ Produktionsnahe interne Geräteverwaltung als kompakter Django-Monolith. Die Anw
- Docker Compose mit PostgreSQL, Django/Gunicorn und Traefik/HTTPS
- Healthchecks, persistente Volumes und einfache Backup-/Restore-Skripte
## Anleitung für den LAN-Server
Für Kollegen: [Lokales Deployment und Serverbetrieb](docs/LOCAL_DEPLOY.md).
Die Anleitung enthält Zugang, Update mit Backup, Prüfung, Fehlerbehebung und
Datenbankzugriff für den LAN-Server.
## Lokale Entwicklung
Voraussetzungen: Python 3.12 oder neuer und PostgreSQL 17, oder Docker Compose.
@@ -331,3 +337,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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@
@media(max-width:760px){.config-grid-three{grid-template-columns:1fr}.config-grid-three .panel:first-child{grid-column:auto}.config-row{align-items:flex-start}.config-row form{padding-top:6px}}
/* Fixierter Listenarbeitsbereich */
.device-list-page{height:100vh;overflow:hidden}.device-list-page .main{height:100vh;min-height:0;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 1 auto;min-height:0;flex-direction:column}.device-list-page #device-table-container .table-shell{flex:1 1 auto;min-height:0;overflow:auto}.device-list-page #device-table-container .pagination{flex:0 0 auto;margin:0;padding:10px 2px 0}.device-list-page .device-table th{top:0;z-index:6}.device-list-page .table-shell{scrollbar-gutter:stable}
.device-list-page{min-height:100vh}.device-list-page .main{height:100vh;min-height:600px;display:flex;flex-direction:column;padding-bottom:12px}.device-list-page .page-head,.device-list-page .toolbar,.device-list-page .main>nav{flex:0 0 auto}.device-list-page #device-table-container{display:flex;flex:1 0 240px;min-height:240px;flex-direction:column}.device-list-page #device-table-container .table-shell{flex:1 1 auto;min-height:0;overflow:auto}.device-list-page #device-table-container .pagination{flex:0 0 auto;margin:0;padding:10px 2px 0}.device-list-page .device-table th{top:0;z-index:6}.device-list-page .table-shell{scrollbar-gutter:stable}
/* Bearbeitbare Statusfarben */
.status-colored{color:var(--status-color);background:color-mix(in srgb,var(--status-color) 12%,white);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--status-color) 24%,white)}.status-color-dot{display:inline-block;width:11px;height:11px;flex:0 0 auto;border-radius:50%;background:var(--status-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--status-color) 13%,white)}.config-value-label{flex-direction:row!important;align-items:center;gap:9px}.color-field{border:0;margin:0;padding:0}.color-field legend{font-size:13px;font-weight:600;margin-bottom:7px}.color-field>small{display:block;color:var(--muted);margin-top:7px}.color-options{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}.color-option{position:relative;min-height:48px;display:flex!important;flex-direction:row!important;align-items:center;gap:8px;padding:8px 9px;border:1px solid #d0d5dd;border-radius:7px;cursor:pointer;font-weight:600}.color-option:hover{background:#f8fafc}.color-option:has(input:checked){border-color:var(--choice-color);box-shadow:0 0 0 3px color-mix(in srgb,var(--choice-color) 14%,white)}.color-option input{position:absolute;opacity:0;width:1px!important;min-height:1px!important}.color-option i{width:18px;height:18px;flex:0 0 auto;border-radius:50%;background:var(--choice-color);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}.color-option span{font-size:12px}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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